From 29903ac1d968125e823b783d900f4aa0a19a38dd Mon Sep 17 00:00:00 2001 From: Jonathan Gilbert Date: Sat, 14 Jun 2025 13:03:35 +1000 Subject: [PATCH 1/7] refactor: break out asset sources into distinct composable pools --- AggregatingAssetPoolTests.cs | 248 +++++++++ .../ImmichFrame.Core.Tests.csproj | 37 ++ .../Logic/Pool/AggregatingAssetPoolTests.cs | 208 +++++++ .../Logic/Pool/AlbumAssetsPoolTests.cs | 101 ++++ .../Logic/Pool/AllAssetsPoolTests.cs | 136 +++++ .../Logic/Pool/CachingApiAssetsPoolTests.cs | 289 ++++++++++ .../Logic/Pool/FavoriteAssetsPoolTests.cs | 92 +++ .../Logic/Pool/FixtureHelpers.cs | 15 + .../Logic/Pool/MemoryAssetsPoolTests.cs | 210 +++++++ .../Logic/Pool/MultiAssetPoolTests.cs | 304 ++++++++++ .../Logic/Pool/PersonAssetsPoolTests.cs | 111 ++++ .../Logic/Pool/QueuingAssetPoolTests.cs | 242 ++++++++ ImmichFrame.Core/Helpers/ApiCache.cs | 17 +- .../Helpers/ListExtensionMethods.cs | 33 +- .../Interfaces/IImmichFrameLogic.cs | 2 +- ImmichFrame.Core/Logic/ImmichFrameLogic.cs | 521 ----------------- .../Logic/MultiImmichFrameLogicDelegate.cs | 13 +- .../Logic/OptimizedImmichFrameLogic.cs | 407 -------------- .../Logic/Pool/AggregatingAssetPool.cs | 14 + .../Logic/Pool/AlbumAssetsPool.cs | 29 + ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs | 78 +++ .../Logic/Pool/CachingApiAssetsPool.cs | 55 ++ .../Logic/Pool/FavoriteAssetsPool.cs | 37 ++ ImmichFrame.Core/Logic/Pool/IAssetPool.cs | 36 ++ .../Logic/Pool/MemoryAssetsPool.cs | 34 ++ ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs | 19 + .../Logic/Pool/PeopleAssetsPool.cs | 40 ++ .../Logic/Pool/QueuingAssetPool.cs | 65 +++ .../Logic/PooledImmichFrameLogic.cs | 130 +++++ .../Logic/SimpleAccountSelectionStrategy.cs | 29 - .../TotalAccountImagesSelectionStrategy.cs | 39 +- .../Helpers/Config/ConfigLoaderTest.cs | 2 - .../ImmichFrameAuthenticationHandler.cs | 3 +- ImmichFrame.WebApi/Program.cs | 2 +- ImmichFrame.sln | 6 + MultiAssetPoolTests.cs | 526 ++++++++++++++++++ 36 files changed, 3125 insertions(+), 1005 deletions(-) create mode 100644 AggregatingAssetPoolTests.cs create mode 100644 ImmichFrame.Core.Tests/ImmichFrame.Core.Tests.csproj create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/MultiAssetPoolTests.cs create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/QueuingAssetPoolTests.cs delete mode 100644 ImmichFrame.Core/Logic/ImmichFrameLogic.cs delete mode 100644 ImmichFrame.Core/Logic/OptimizedImmichFrameLogic.cs create mode 100644 ImmichFrame.Core/Logic/Pool/AggregatingAssetPool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/IAssetPool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs create mode 100644 ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs delete mode 100644 ImmichFrame.Core/Logic/SimpleAccountSelectionStrategy.cs create mode 100644 MultiAssetPoolTests.cs diff --git a/AggregatingAssetPoolTests.cs b/AggregatingAssetPoolTests.cs new file mode 100644 index 00000000..e045ad22 --- /dev/null +++ b/AggregatingAssetPoolTests.cs @@ -0,0 +1,248 @@ +using NUnit.Framework; +using Moq; +using System.Collections.Generic; +using System.Linq; + +// Assuming the classes are in a namespace, adjust if necessary +// namespace YourNamespace.Tests; + +[TestFixture] +public class AggregatingAssetPoolTests +{ + private Mock> _mockPool1; + private Mock> _mockPool2; + private List> _assetPools; + private AggregatingAssetPool _aggregatingPool; + + [SetUp] + public void Setup() + { + _mockPool1 = new Mock>(); + _mockPool2 = new Mock>(); + _assetPools = new List>(); + // AggregatingAssetPool needs to be defined or imported. + // For now, I'll assume it exists and can be instantiated. + // If not, this will be a placeholder for its actual instantiation. + // _aggregatingPool = new AggregatingAssetPool(_assetPools); + } + + // Placeholder for AggregatingAssetPool and IAssetPool if not defined elsewhere + // This is just for the structure and will be replaced by actual implementation details + public interface IAssetPool + { + int GetAssetCount(); + IEnumerable GetAssets(int count); + T GetNextAsset(); + } + + public class AggregatingAssetPool + { + private readonly List> _pools; + private int _currentPoolIndex = 0; + + public AggregatingAssetPool(List> pools) + { + _pools = pools; + } + + public int GetAssetCount() + { + return _pools.Sum(p => p.GetAssetCount()); + } + + public IEnumerable GetAssets(int count) + { + if (count == 0) return Enumerable.Empty(); + + var assets = new List(); + var remainingAssetsToFetch = count; + + foreach (var pool in _pools) + { + if (remainingAssetsToFetch == 0) break; + + var assetsFromPool = pool.GetAssets(remainingAssetsToFetch); + if (assetsFromPool != null) + { + assets.AddRange(assetsFromPool); + remainingAssetsToFetch -= assetsFromPool.Count(); + } + } + return assets; + } + + public T GetNextAsset() + { + if (_pools == null || _pools.Count == 0) + { + return default(T); // Or throw exception + } + + while (_currentPoolIndex < _pools.Count) + { + var asset = _pools[_currentPoolIndex].GetNextAsset(); + if (asset != null) + { + return asset; + } + _currentPoolIndex++; + } + return default(T); // Or throw exception if no more assets + } + } + + [Test] + public void GetAssetCount_NoPools_ReturnsZero() + { + _aggregatingPool = new AggregatingAssetPool(_assetPools); + Assert.AreEqual(0, _aggregatingPool.GetAssetCount()); + } + + [Test] + public void GetAssetCount_OnePool_ReturnsCorrectCount() + { + _mockPool1.Setup(p => p.GetAssetCount()).Returns(5); + _assetPools.Add(_mockPool1.Object); + _aggregatingPool = new AggregatingAssetPool(_assetPools); + Assert.AreEqual(5, _aggregatingPool.GetAssetCount()); + } + + [Test] + public void GetAssetCount_MultiplePools_ReturnsSumOfCounts() + { + _mockPool1.Setup(p => p.GetAssetCount()).Returns(5); + _mockPool2.Setup(p => p.GetAssetCount()).Returns(10); + _assetPools.Add(_mockPool1.Object); + _assetPools.Add(_mockPool2.Object); + _aggregatingPool = new AggregatingAssetPool(_assetPools); + Assert.AreEqual(15, _aggregatingPool.GetAssetCount()); + } + + [Test] + public void GetAssets_RequestZeroAssets_ReturnsEmptyCollection() + { + _aggregatingPool = new AggregatingAssetPool(_assetPools); + var result = _aggregatingPool.GetAssets(0); + Assert.IsEmpty(result); + } + + [Test] + public void GetAssets_TotalLessThanRequested_ReturnsAllAvailableAssets() + { + var assets1 = new List { new object(), new object() }; + _mockPool1.Setup(p => p.GetAssets(It.IsAny())).Returns(assets1); + _mockPool1.Setup(p => p.GetAssetCount()).Returns(assets1.Count); + _assetPools.Add(_mockPool1.Object); + + _aggregatingPool = new AggregatingAssetPool(_assetPools); + var result = _aggregatingPool.GetAssets(5); // Request 5, but only 2 available + Assert.AreEqual(2, result.Count()); + Assert.IsTrue(result.SequenceEqual(assets1)); + } + + [Test] + public void GetAssets_TotalMoreThanRequested_ReturnsRequestedNumberOfAssetsAggregated() + { + var assets1 = new List { new object(), new object() }; // Pool 1 has 2 assets + var assets2 = new List { new object(), new object(), new object() }; // Pool 2 has 3 assets + + _mockPool1.Setup(p => p.GetAssetCount()).Returns(assets1.Count); + _mockPool1.Setup(p => p.GetAssets(It.IsAny())).Returns(count => assets1.Take(count).ToList()); + + _mockPool2.Setup(p => p.GetAssetCount()).Returns(assets2.Count); + _mockPool2.Setup(p => p.GetAssets(It.IsAny())).Returns(count => assets2.Take(count).ToList()); + + _assetPools.Add(_mockPool1.Object); + _assetPools.Add(_mockPool2.Object); + _aggregatingPool = new AggregatingAssetPool(_assetPools); + + // Request 3 assets. Should get 2 from pool1 and 1 from pool2. + var result = _aggregatingPool.GetAssets(3); + Assert.AreEqual(3, result.Count()); + Assert.IsTrue(result.Take(2).SequenceEqual(assets1)); // First 2 from pool1 + Assert.IsTrue(result.Skip(2).SequenceEqual(assets2.Take(1))); // Next 1 from pool2 + } + + [Test] + public void GetAssets_PoolReturnsFewerAssetsThanCountSuggests_HandlesGracefully() + { + var assets1 = new List { new object(), new object() }; + _mockPool1.Setup(p => p.GetAssetCount()).Returns(5); // Reports 5 assets + _mockPool1.Setup(p => p.GetAssets(It.IsAny())).Returns(assets1); // But only returns 2 + + _assetPools.Add(_mockPool1.Object); + _aggregatingPool = new AggregatingAssetPool(_assetPools); + + var result = _aggregatingPool.GetAssets(5); // Request 5 + Assert.AreEqual(2, result.Count()); // Should only get 2 + Assert.IsTrue(result.SequenceEqual(assets1)); + } + + [Test] + public void GetNextAsset_NoAssets_ReturnsDefault() + { + _aggregatingPool = new AggregatingAssetPool(_assetPools); // No pools added + Assert.IsNull(_aggregatingPool.GetNextAsset()); // Assuming default(T) is null for object + } + + [Test] + public void GetNextAsset_RetrievesAllAssetsSequentiallyFromMultiplePools() + { + var asset1 = new object(); + var asset2 = new object(); + var asset3 = new object(); + + _mockPool1.SetupSequence(p => p.GetNextAsset()) + .Returns(asset1) + .Returns(asset2) + .Returns(default(object)); // Pool 1 exhausted + + _mockPool2.SetupSequence(p => p.GetNextAsset()) + .Returns(asset3) + .Returns(default(object)); // Pool 2 exhausted + + _assetPools.Add(_mockPool1.Object); + _assetPools.Add(_mockPool2.Object); + _aggregatingPool = new AggregatingAssetPool(_assetPools); + + Assert.AreSame(asset1, _aggregatingPool.GetNextAsset()); + Assert.AreSame(asset2, _aggregatingPool.GetNextAsset()); + Assert.AreSame(asset3, _aggregatingPool.GetNextAsset()); + Assert.IsNull(_aggregatingPool.GetNextAsset()); // All assets retrieved + } + + [Test] + public void GetNextAsset_PoolExhausted_SwitchesToNextPool() + { + var asset1 = new object(); + var asset2 = new object(); + + _mockPool1.SetupSequence(p => p.GetNextAsset()) + .Returns(asset1) + .Returns(default(object)); // Pool 1 exhausted + + _mockPool2.Setup(p => p.GetNextAsset()).Returns(asset2); + + _assetPools.Add(_mockPool1.Object); + _assetPools.Add(_mockPool2.Object); + _aggregatingPool = new AggregatingAssetPool(_assetPools); + + Assert.AreSame(asset1, _aggregatingPool.GetNextAsset()); // From pool 1 + Assert.AreSame(asset2, _aggregatingPool.GetNextAsset()); // From pool 2 + } + + [Test] + public void GetNextAsset_CalledWhenNoAssetsAvailableAfterSomeRetrievals_ReturnsDefault() + { + var asset1 = new object(); + _mockPool1.SetupSequence(p => p.GetNextAsset()) + .Returns(asset1) + .Returns(default(object)); + _assetPools.Add(_mockPool1.Object); + _aggregatingPool = new AggregatingAssetPool(_assetPools); + + Assert.AreSame(asset1, _aggregatingPool.GetNextAsset()); // Retrieve one asset + Assert.IsNull(_aggregatingPool.GetNextAsset()); // No more assets + Assert.IsNull(_aggregatingPool.GetNextAsset()); // Called again, should still be null + } +} diff --git a/ImmichFrame.Core.Tests/ImmichFrame.Core.Tests.csproj b/ImmichFrame.Core.Tests/ImmichFrame.Core.Tests.csproj new file mode 100644 index 00000000..3b8fa20d --- /dev/null +++ b/ImmichFrame.Core.Tests/ImmichFrame.Core.Tests.csproj @@ -0,0 +1,37 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + ..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.Extensions.Logging.dll + + + + diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs new file mode 100644 index 00000000..c72d7e12 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs @@ -0,0 +1,208 @@ +using Moq; +using NUnit.Framework; +using ImmichFrame.Core.Api; // For AssetResponseDto +using ImmichFrame.Core.Logic.Pool; // For AggregatingAssetPool and IAssetPool (non-generic) + +namespace ImmichFrame.Core.Tests.Logic.Pool +{ + [TestFixture] + public class AggregatingAssetPoolTests + { + private Mock _mockPool1; + private Mock _mockPool2; + private MultiAssetPool _aggregatingPool; + private List _assetPools; + + [SetUp] + public void Setup() + { + _mockPool1 = new Mock(); + _mockPool2 = new Mock(); + _assetPools = new List(); + // AggregatingAssetPool takes IEnumerable in constructor + } + + private AssetResponseDto CreateAsset(string id) => new AssetResponseDto { Id = id, OriginalPath = $"/path/{id}.jpg", Type = AssetTypeEnum.IMAGE, ExifInfo = new ExifResponseDto() }; + + [Test] + public async Task GetAssetCount_NoPools_ReturnsZero() + { + _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool + Assert.That(await _aggregatingPool.GetAssetCount(CancellationToken.None), Is.EqualTo(0)); + } + + [Test] + public async Task GetAssetCount_OnePool_ReturnsCorrectCount() + { + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(5L); + _assetPools.Add(_mockPool1.Object); + _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool + Assert.That(await _aggregatingPool.GetAssetCount(CancellationToken.None), Is.EqualTo(5)); + } + + [Test] + public async Task GetAssetCount_MultiplePools_ReturnsSumOfCounts() + { + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(5L); + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(10L); + _assetPools.Add(_mockPool1.Object); + _assetPools.Add(_mockPool2.Object); + _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool + Assert.That(await _aggregatingPool.GetAssetCount(CancellationToken.None), Is.EqualTo(15)); + } + + [Test] + public async Task GetAssets_RequestZeroAssets_ReturnsEmptyCollection() + { + _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool + var result = await _aggregatingPool.GetAssets(0, CancellationToken.None); + Assert.That(result, Is.Empty); + } + + [Test] + public async Task GetAssets_TotalLessThanRequested_ReturnsAllAvailableAssets() + { + var asset1 = CreateAsset("a1"); + var asset2 = CreateAsset("a2"); + var pool1AvailableAssets = new Queue(new List { asset1, asset2 }); + var allAssetsFromPool1 = new List { asset1, asset2 }; + + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())) + .ReturnsAsync(() => (long)pool1AvailableAssets.Count); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) // AggregatingAssetPool.GetNextAsset calls GetAssets(1,...) + .ReturnsAsync(() => pool1AvailableAssets.Any() + ? new List { pool1AvailableAssets.Dequeue() } + : new List()); + + _assetPools.Add(_mockPool1.Object); + _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool + + var result = (await _aggregatingPool.GetAssets(5, CancellationToken.None)).ToList(); + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.All(x => allAssetsFromPool1.Contains(x)), Is.True); + Assert.That(allAssetsFromPool1.All(x => result.Contains(x)), Is.True); + } + + [Test] + public async Task GetAssets_TotalMoreThanRequested_AggregatesAssetsFromPools() + { + var assetP1A1 = CreateAsset("p1a1"); + var assetP1A2 = CreateAsset("p1a2"); + var pool1Queue = new Queue(new[] { assetP1A1, assetP1A2 }); + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)pool1Queue.Count); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => pool1Queue.Any() ? new List { pool1Queue.Dequeue() } : new List()); + + var assetP2A1 = CreateAsset("p2a1"); + var pool2Queue = new Queue(new[] { assetP2A1 }); + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)pool2Queue.Count); + _mockPool2.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => pool2Queue.Any() ? new List { pool2Queue.Dequeue() } : new List()); + + _assetPools.Add(_mockPool1.Object); + _assetPools.Add(_mockPool2.Object); + _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool + + // Request 3 assets. Pool1 has 2, Pool2 has 1. + var result = (await _aggregatingPool.GetAssets(3, CancellationToken.None)).ToList(); + Assert.That(result.Count, Is.EqualTo(3)); + // Check presence of all expected assets, order might vary based on AggregatingAssetPool internal logic + Assert.That(result, Does.Contain(assetP1A1)); + Assert.That(result, Does.Contain(assetP1A2)); + Assert.That(result, Does.Contain(assetP2A1)); + } + + [Test] + public async Task GetAssets_PoolReturnsFewerAssetsThanCountSuggests_HandlesGracefully() + { + var p1a1 = CreateAsset("p1a1"); + var pool1AvailableAssets = new Queue(new List { p1a1 }); + var originalPool1Assets = new List { p1a1 }; + + // Pool1 reports 5 assets, but its queue only has 1. + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(5L); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => + { + if (pool1AvailableAssets.Any()) + { + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(0L); // Update count after last asset + return new List { pool1AvailableAssets.Dequeue() }; + } + + return new List(); + }); + + var p2a1 = CreateAsset("p2a1"); + var pool2AvailableAssets = new Queue(new List { p2a1 }); + var originalPool2Assets = new List { p2a1 }; + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())) + .ReturnsAsync(() => (long)pool2AvailableAssets.Count); + _mockPool2.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => pool2AvailableAssets.Any() ? new List { pool2AvailableAssets.Dequeue() } : new List()); + + _assetPools.Add(_mockPool1.Object); + _assetPools.Add(_mockPool2.Object); + _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool + + // Request 5 assets. Actual available: 1 from pool1, 1 from pool2. Total 2. + // AggregatingAssetPool.GetAssets calls GetNextAsset. GetNextAsset iterates pools sequentially. + // It will get p1a1 from pool1. Then pool1 is exhausted (its GetAssets(1,..) will return empty). + // Then it will get p2a1 from pool2. Then pool2 is exhausted. + // The loop in AggregatingAssetPool.GetAssets should break when GetNextAsset returns null. + var result = (await _aggregatingPool.GetAssets(5, CancellationToken.None)).ToList(); + + Assert.That(result.Count, Is.EqualTo(2)); + var expectedTotalAssets = originalPool1Assets.Concat(originalPool2Assets).ToList(); + Assert.That(result.All(x => expectedTotalAssets.Contains(x)), Is.True); + Assert.That(expectedTotalAssets.All(x => result.Contains(x)), Is.True); + } + + // GetNextAsset is protected in AggregatingAssetPool. We test its behavior via GetAssets(1, ...) + [Test] + public async Task GetNextAssetBehavior_RetrievesAllAssets() + { + var asset1 = CreateAsset("asset1"); + var asset2 = CreateAsset("asset2"); + var asset3 = CreateAsset("asset3"); + + var q1 = new Queue(new[] { asset1, asset2 }); + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)q1.Count); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => q1.Any() ? new List { q1.Dequeue() } : new List()); + + var q2 = new Queue(new[] { asset3 }); + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)q2.Count); + _mockPool2.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => q2.Any() ? new List { q2.Dequeue() } : new List()); + + _assetPools.Add(_mockPool1.Object); + _assetPools.Add(_mockPool2.Object); + _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool + + var retrievedAssets = new List(); + AssetResponseDto currentAsset; + while ((currentAsset = (await _aggregatingPool.GetAssets(1, CancellationToken.None)).FirstOrDefault()) != null) + { + retrievedAssets.Add(currentAsset); + } + + Assert.That(retrievedAssets.Count, Is.EqualTo(3)); + Assert.That(retrievedAssets, Does.Contain(asset1)); + Assert.That(retrievedAssets, Does.Contain(asset2)); + Assert.That(retrievedAssets, Does.Contain(asset3)); + Assert.That((await _aggregatingPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(), Is.Null); // All exhausted + } + + [Test] + public async Task GetNextAssetBehavior_NoAssets_ReturnsNull() + { + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(0L); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())).ReturnsAsync(new List()); + _assetPools.Add(_mockPool1.Object); + _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool + + Assert.That((await _aggregatingPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(), Is.Null); + } + } +} \ No newline at end of file diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs new file mode 100644 index 00000000..ad9c96c4 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs @@ -0,0 +1,101 @@ +using NUnit.Framework; +using Moq; +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic.Pool; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; + +namespace ImmichFrame.Core.Tests.Logic.Pool; + +[TestFixture] +public class AlbumAssetsPoolTests +{ + private Mock _mockApiCache; + private Mock _mockImmichApi; + private Mock _mockAccountSettings; + private TestableAlbumAssetsPool _albumAssetsPool; + + private class TestableAlbumAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + : AlbumAssetsPool(apiCache, immichApi, accountSettings) + { + // Expose LoadAssets for testing + public Task> TestLoadAssets(CancellationToken ct = default) => base.LoadAssets(ct); + } + + [SetUp] + public void Setup() + { + _mockApiCache = new Mock(TimeSpan.MaxValue); + _mockImmichApi = new Mock("", null); + _mockAccountSettings = new Mock(); + _albumAssetsPool = new TestableAlbumAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + + _mockAccountSettings.SetupGet(s => s.Albums).Returns(new List()); + _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List()); + } + + private AssetResponseDto CreateAsset(string id) => new AssetResponseDto { Id = id, Type = AssetTypeEnum.IMAGE }; + + [Test] + public async Task LoadAssets_ReturnsAssetsPresentIIncludedNotExcludedAlbums() + { + // Arrange + var album1Id = Guid.NewGuid(); + var excludedAlbumId = Guid.NewGuid(); + + var assetA = CreateAsset("A"); // In album1 + var assetB = CreateAsset("B"); // In album1 and excludedAlbum + var assetC = CreateAsset("C"); // In excludedAlbum only + var assetD = CreateAsset("D"); // In album1 only (but not B) + + _mockAccountSettings.SetupGet(s => s.Albums).Returns(new List { album1Id }); + _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List { excludedAlbumId }); + + _mockImmichApi.Setup(api => api.GetAlbumInfoAsync(album1Id, null, null, It.IsAny())) + .ReturnsAsync(new AlbumResponseDto { Assets = new List { assetA, assetB, assetD } }); + _mockImmichApi.Setup(api => api.GetAlbumInfoAsync(excludedAlbumId, null, null, It.IsAny())) + .ReturnsAsync(new AlbumResponseDto { Assets = new List { assetB, assetC } }); + + // Act + var result = (await _albumAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.Any(a => a.Id == "A")); + Assert.That(result.Any(a => a.Id == "D")); + _mockImmichApi.Verify(api => api.GetAlbumInfoAsync(album1Id, null, null, It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.GetAlbumInfoAsync(excludedAlbumId, null, null, It.IsAny()), Times.Once); + } + + [Test] + public async Task LoadAssets_NoIncludedAlbums_ReturnsEmpty() + { + _mockAccountSettings.SetupGet(s => s.Albums).Returns(new List()); + _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List { Guid.NewGuid() }); + _mockImmichApi.Setup(api => api.GetAlbumInfoAsync(It.IsAny(), null, null, It.IsAny())) + .ReturnsAsync(new AlbumResponseDto { Assets = new List { CreateAsset("excluded_only") } }); + + + var result = (await _albumAssetsPool.TestLoadAssets()).ToList(); + Assert.That(result, Is.Empty); + } + + [Test] + public async Task LoadAssets_NoExcludedAlbums_ReturnsAlbums() + { + var album1Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.Albums).Returns(new List { album1Id }); + _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List()); // Empty excluded + + _mockImmichApi.Setup(api => api.GetAlbumInfoAsync(album1Id, null, null, It.IsAny())) + .ReturnsAsync(new AlbumResponseDto { Assets = new List { CreateAsset("A") } }); + + var result = (await _albumAssetsPool.TestLoadAssets()).ToList(); + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result.Any(a => a.Id == "A")); + } +} diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs new file mode 100644 index 00000000..548d0570 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs @@ -0,0 +1,136 @@ +using NUnit.Framework; +using Moq; +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic.Pool; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; + +namespace ImmichFrame.Core.Tests.Logic.Pool; + +[TestFixture] +public class AllAssetsPoolTests +{ + private Mock _mockApiCache; + private Mock _mockImmichApi; + private Mock _mockAccountSettings; + private AllAssetsPool _allAssetsPool; + + [SetUp] + public void Setup() + { + _mockApiCache = new Mock(null); + _mockImmichApi = new Mock(null, null); + _mockAccountSettings = new Mock(); + _allAssetsPool = new AllAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + + // Default account settings + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); + _mockAccountSettings.SetupGet(s => s.ImagesFromDate).Returns((DateTime?)null); + _mockAccountSettings.SetupGet(s => s.ImagesUntilDate).Returns((DateTime?)null); + _mockAccountSettings.SetupGet(s => s.ImagesFromDays).Returns((int?)null); + _mockAccountSettings.SetupGet(s => s.Rating).Returns((int?)null); + _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List()); + + // Default ApiCache setup + _mockApiCache.Setup(c => c.GetOrAddAsync( + It.IsAny(), + It.IsAny>>() // For GetAssetCount + )) + .Returns>>(async (key, factory) => await factory()); + } + + private List CreateSampleAssets(int count, string idPrefix = "asset") + { + return Enumerable.Range(0, count) + .Select(i => new AssetResponseDto { Id = $"{idPrefix}{i}", Type = AssetTypeEnum.IMAGE }) + .ToList(); + } + + [Test] + public async Task GetAssetCount_CallsApiAndCache() + { + // Arrange + var stats = new AssetStatsResponseDto { Images = 100 }; + _mockImmichApi.Setup(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny())).ReturnsAsync(stats); + + // Act + var count = await _allAssetsPool.GetAssetCount(); + + // Assert + Assert.That(count, Is.EqualTo(100)); + _mockImmichApi.Verify(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny()), Times.Once); + _mockApiCache.Verify(cache => cache.GetOrAddAsync(nameof(AllAssetsPool), It.IsAny>>()), Times.Once); + } + + [Test] + public async Task GetAssets_CallsSearchRandomAsync_WithCorrectParameters() + { + // Arrange + var requestedCount = 5; + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); + _mockAccountSettings.SetupGet(s => s.Rating).Returns(3); + var returnedAssets = CreateSampleAssets(requestedCount); + _mockImmichApi.Setup(api => api.SearchRandomAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(returnedAssets); + + // Act + var assets = await _allAssetsPool.GetAssets(requestedCount); + + // Assert + Assert.That(assets.Count(), Is.EqualTo(requestedCount)); + _mockImmichApi.Verify(api => api.SearchRandomAsync( + It.Is(dto => + dto.Size == requestedCount && + dto.Type == AssetTypeEnum.IMAGE && + dto.WithExif == true && + dto.WithPeople == true && + dto.Visibility == AssetVisibility.Archive && // ShowArchived = true + dto.Rating == 3 + ), It.IsAny()), Times.Once); + } + + [Test] + public async Task GetAssets_AppliesDateFilters_FromDays() + { + _mockAccountSettings.SetupGet(s => s.ImagesFromDays).Returns(10); + var expectedFromDate = DateTime.Today.AddDays(-10); + _mockImmichApi.Setup(api => api.SearchRandomAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + await _allAssetsPool.GetAssets(5); + + _mockImmichApi.Verify(api => api.SearchRandomAsync( + It.Is(dto => dto.TakenAfter.HasValue && dto.TakenAfter.Value.Date == expectedFromDate.Date), + It.IsAny()), Times.Once); + } + + [Test] + public async Task GetAssets_ExcludesAssetsFromExcludedAlbums() + { + // Arrange + var mainAssets = CreateSampleAssets(3, "main"); // main0, main1, main2 + var excludedAsset = new AssetResponseDto { Id = "excluded1", Type = AssetTypeEnum.IMAGE }; + var assetsToReturnFromSearch = new List(mainAssets) { excludedAsset }; + + var excludedAlbumId = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List { excludedAlbumId }); + + _mockImmichApi.Setup(api => api.SearchRandomAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(assetsToReturnFromSearch); + _mockImmichApi.Setup(api => api.GetAlbumInfoAsync(excludedAlbumId, null, null, It.IsAny())) + .ReturnsAsync(new AlbumResponseDto { Assets = new List { excludedAsset }, AssetCount = 1 }); + + // Act + var result = (await _allAssetsPool.GetAssets(4)).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(3)); + Assert.That(result.Any(a => a.Id == "excluded1"), Is.False); + Assert.That(result.All(a => a.Id.StartsWith("main"))); + _mockImmichApi.Verify(api => api.GetAlbumInfoAsync(excludedAlbumId, null, null, It.IsAny()), Times.Once); + } +} diff --git a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs new file mode 100644 index 00000000..5839693f --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs @@ -0,0 +1,289 @@ +using NUnit.Framework; +using Moq; +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic.Pool; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; + +namespace ImmichFrame.Core.Tests.Logic.Pool; + +[TestFixture] +public class CachingApiAssetsPoolTests +{ + private Mock _mockApiCache; + private Mock _mockImmichApi; // Dependency for constructor, may not be used directly in base class tests + private Mock _mockAccountSettings; + private TestableCachingApiAssetsPool _testPool; + + // Concrete implementation for testing the abstract class + private class TestableCachingApiAssetsPool : CachingApiAssetsPool + { + public Func>> LoadAssetsFunc { get; set; } + + public TestableCachingApiAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + : base(apiCache, immichApi, accountSettings) + { + } + + protected override Task> LoadAssets(CancellationToken ct = default) + { + return LoadAssetsFunc != null ? LoadAssetsFunc() : Task.FromResult(Enumerable.Empty()); + } + } + + [SetUp] + public void Setup() + { + _mockApiCache = new Mock(null); // ILogger, IOptions + _mockImmichApi = new Mock(null, null); // ILogger, IHttpClientFactory, IOptions + _mockAccountSettings = new Mock(); + + _testPool = new TestableCachingApiAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + + // Default setup for ApiCache to execute the factory function + _mockApiCache.Setup(c => c.GetOrAddAsync( + It.IsAny(), + It.IsAny>>>() + )) + .Returns>>>(async (key, factory) => await factory()); + + // Default account settings + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); + _mockAccountSettings.SetupGet(s => s.ImagesFromDate).Returns((DateTime?)null); + _mockAccountSettings.SetupGet(s => s.ImagesUntilDate).Returns((DateTime?)null); + _mockAccountSettings.SetupGet(s => s.ImagesFromDays).Returns((int?)null); + _mockAccountSettings.SetupGet(s => s.Rating).Returns((int?)null); + } + + private List CreateSampleAssets() + { + return new List + { + new AssetResponseDto { Id = "1", Type = AssetTypeEnum.IMAGE, IsArchived = false, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddDays(-10), Rating = 5 } }, + new AssetResponseDto { Id = "2", Type = AssetTypeEnum.VIDEO, IsArchived = false, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddDays(-10) } }, // Should be filtered out by type + new AssetResponseDto { Id = "3", Type = AssetTypeEnum.IMAGE, IsArchived = true, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddDays(-5), Rating = 3 } }, // Potentially filtered by archive status + new AssetResponseDto { Id = "4", Type = AssetTypeEnum.IMAGE, IsArchived = false, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddDays(-2), Rating = 5 } }, + new AssetResponseDto { Id = "5", Type = AssetTypeEnum.IMAGE, IsArchived = false, ExifInfo = new ExifResponseDto { DateTimeOriginal = DateTime.Now.AddYears(-1), Rating = 1 } }, + }; + } + + [Test] + public async Task GetAssetCount_ReturnsCorrectCount_AfterFiltering() + { + // Arrange + var assets = CreateSampleAssets(); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); // Filter out archived + + // Act + var count = await _testPool.GetAssetCount(); + + // Assert + // Expected: Asset "1", "4", "5" (Asset "2" is video, Asset "3" is archived) + Assert.That(count, Is.EqualTo(3)); + } + + [Test] + public async Task GetAssets_ReturnsRequestedNumberOfAssets() + { + // Arrange + var assets = CreateSampleAssets(); // Total 5 assets, 4 images if ShowArchived = true + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); // Asset "3" included + + // Act + var result = (await _testPool.GetAssets(2)).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(2)); + // All returned assets should be images + Assert.That(result.All(a => a.Type == AssetTypeEnum.IMAGE)); + } + + [Test] + 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); + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); + + // Act + var result = (await _testPool.GetAssets(5)).ToList(); // Request 5, but only 3 available after filtering + + // Assert + Assert.That(result.Count, Is.EqualTo(3)); + } + + + [Test] + public async Task AllAssets_UsesCache_LoadAssetsCalledOnce() + { + // Arrange + var assets = CreateSampleAssets(); + var loadAssetsCallCount = 0; + _testPool.LoadAssetsFunc = () => + { + loadAssetsCallCount++; + return Task.FromResult>(assets); + }; + + // Setup cache to really cache after the first call + IEnumerable cachedValue = null; + _mockApiCache.Setup(c => c.GetOrAddAsync( + It.IsAny(), + It.IsAny>>>() + )) + .Returns>>>(async (key, factory) => + { + if (cachedValue == null) + { + cachedValue = await factory(); + } + + return cachedValue; + }); + + // Act + await _testPool.GetAssetCount(); // First call, should trigger LoadAssets + await _testPool.GetAssetCount(); // Second call, should use cache + await _testPool.GetAssets(1); // Third call, should use cache + + // Assert + Assert.That(loadAssetsCallCount, Is.EqualTo(1), "LoadAssets should only be called once."); + } + + [Test] + public async Task ApplyAccountFilters_FiltersArchived() + { + // Arrange + var assets = CreateSampleAssets(); // Asset "3" is archived + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); + + // Act + var result = (await _testPool.GetAssets(5)).ToList(); // Request more than available to get all filtered + + // Assert + Assert.That(result.Any(a => a.Id == "3"), Is.False); + Assert.That(result.Count, Is.EqualTo(3)); // 1, 4, 5 + } + + [Test] + public async Task ApplyAccountFilters_FiltersImagesUntilDate() + { + // Arrange + var assets = CreateSampleAssets(); + _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 + + // Act + var result = (await _testPool.GetAssets(5)).ToList(); + + // Assert (all are images already by default) + // Assets: 1 (10d), 3 (5d, archived), 4 (2d), 5 (1y) + // Filter: ShowArchived=true. UntilDate = -7d. + // Expected: Asset "1", "5" + Assert.That(result.All(a => a.ExifInfo.DateTimeOriginal <= untilDate)); + Assert.That(result.Count, Is.EqualTo(2), string.Join(",", result.Select(x => x.Id))); + Assert.That(result.Any(a => a.Id == "1")); + Assert.That(result.Any(a => a.Id == "5")); + } + + [Test] + public async Task ApplyAccountFilters_FiltersImagesFromDate() + { + // Arrange + var assets = CreateSampleAssets(); + _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); + + // Act + var result = (await _testPool.GetAssets(5)).ToList(); + + // Assert + // Assets: 1 (10d), 3 (5d, archived), 4 (2d), 5 (1y) + // Filter: ShowArchived=true. FromDate = -7d. + // Expected: Asset "3", "4" + Assert.That(result.All(a => a.ExifInfo.DateTimeOriginal >= fromDate)); + Assert.That(result.Count, Is.EqualTo(2), string.Join(",", result.Select(x => x.Id))); + Assert.That(result.Any(a => a.Id == "3")); + Assert.That(result.Any(a => a.Id == "4")); + } + + [Test] + public async Task ApplyAccountFilters_FiltersImagesFromDays() + { + // Arrange + var assets = CreateSampleAssets(); + _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); + + + // Act + var result = (await _testPool.GetAssets(5)).ToList(); + + // Assert + // Assets: 1 (10d), 3 (5d, archived), 4 (2d), 5 (1y) + // Filter: ShowArchived=true. FromDays = 7 (so fromDate approx -7d from today). + // Expected: Asset "3", "4" + Assert.That(result.All(a => a.ExifInfo.DateTimeOriginal >= fromDate)); + Assert.That(result.Count, Is.EqualTo(2), string.Join(",", result.Select(x => x.Id))); + Assert.That(result.Any(a => a.Id == "3")); + Assert.That(result.Any(a => a.Id == "4")); + } + + [Test] + 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); + _mockAccountSettings.SetupGet(s => s.Rating).Returns(5); + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); + + + // Act + var result = (await _testPool.GetAssets(5)).ToList(); + + // Assert + // Expected: Asset "1", "4" (both rating 5) + Assert.That(result.All(a => a.ExifInfo.Rating == 5)); + Assert.That(result.Count, Is.EqualTo(2), string.Join(",", result.Select(x => x.Id))); + Assert.That(result.Any(a => a.Id == "1")); + Assert.That(result.Any(a => a.Id == "4")); + } + + [Test] + public async Task ApplyAccountFilters_CombinedFilters() + { + // Arrange + var assets = CreateSampleAssets(); + _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) + // Assets "1" (10d), "4" (2d) remain + _mockAccountSettings.SetupGet(s => s.Rating).Returns(5); // Asset "1" (rating 5), Asset "4" (rating 5) + + // Act + var result = (await _testPool.GetAssets(5)).ToList(); + + // Assert + // Expected: Assets "1", "4" + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.Any(a => a.Id == "1")); + Assert.That(result.Any(a => a.Id == "4")); + Assert.That(result.Any(a => a.Id == "3" || a.Id == "5" || a.Id == "2"), Is.False); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs new file mode 100644 index 00000000..8a58657a --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs @@ -0,0 +1,92 @@ +using NUnit.Framework; +using Moq; +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic.Pool; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; + +namespace ImmichFrame.Core.Tests.Logic.Pool; + +[TestFixture] +public class FavoriteAssetsPoolTests +{ + private Mock _mockApiCache; + private Mock _mockImmichApi; + private Mock _mockAccountSettings; // Though not directly used by LoadAssets here + private TestableFavoriteAssetsPool _favoriteAssetsPool; + + private class TestableFavoriteAssetsPool : FavoriteAssetsPool + { + public TestableFavoriteAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + : base(apiCache, immichApi, accountSettings) { } + + public Task> TestLoadAssets(CancellationToken ct = default) + { + return base.LoadAssets(ct); + } + } + + [SetUp] + public void Setup() + { + _mockApiCache = new Mock(null); + _mockImmichApi = new Mock(null, null); + _mockAccountSettings = new Mock(); + _favoriteAssetsPool = new TestableFavoriteAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + } + + private AssetResponseDto CreateAsset(string id) => new AssetResponseDto { Id = id, Type = AssetTypeEnum.IMAGE }; + private SearchResponseDto CreateSearchResult(List assets, int total) => + new SearchResponseDto { Assets = new SearchAssetResponseDto { Items = assets, Total = total } }; + + [Test] + public async Task LoadAssets_CallsSearchAssetsAsync_WithFavoriteTrue_AndPaginates() + { + // Arrange + var batchSize = 1000; // From FavoriteAssetsPool.cs + var assetsPage1 = Enumerable.Range(0, batchSize).Select(i => CreateAsset($"fav_p1_{i}")).ToList(); + var assetsPage2 = Enumerable.Range(0, 50).Select(i => CreateAsset($"fav_p2_{i}")).ToList(); + + _mockImmichApi.SetupSequence(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateSearchResult(assetsPage1, batchSize)) // Page 1, total indicates more might be available + .ReturnsAsync(CreateSearchResult(assetsPage2, 50)); // Page 2, total indicates this is the last page + + // Act + var result = (await _favoriteAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(batchSize + 50)); + Assert.That(result.Any(a => a.Id == "fav_p1_0")); + Assert.That(result.Any(a => a.Id == "fav_p2_49")); + + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.Is(dto => + dto.IsFavorite == true && + dto.Type == AssetTypeEnum.IMAGE && + dto.WithExif == true && + dto.WithPeople == true && + dto.Page == 1 && dto.Size == batchSize), + It.IsAny()), Times.Once); + + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.Is(dto => + dto.IsFavorite == true && + dto.Type == AssetTypeEnum.IMAGE && + dto.Page == 2 && dto.Size == batchSize), + It.IsAny()), Times.Once); + } + + [Test] + public async Task LoadAssets_HandlesEmptyFavorites() + { + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List(), 0)); + + var result = (await _favoriteAssetsPool.TestLoadAssets()).ToList(); + Assert.That(result, Is.Empty); + } +} diff --git a/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs b/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs new file mode 100644 index 00000000..4c4f6ff2 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; +namespace ImmichFrame.Core.Tests.Logic.Pool; + +public static class FixtureHelpers +{ + public static ILogger TestLogger() + { + var loggerFactory = LoggerFactory.Create(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Debug); + }); + return loggerFactory.CreateLogger(); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs new file mode 100644 index 00000000..b90c89f5 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs @@ -0,0 +1,210 @@ +using NUnit.Framework; +using Moq; +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic.Pool; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; + +namespace ImmichFrame.Core.Tests.Logic.Pool; + +[TestFixture] +public class MemoryAssetsPoolTests +{ + private Mock _mockApiCache; + private Mock _mockImmichApi; + private Mock _mockAccountSettings; + private MemoryAssetsPool _memoryAssetsPool; + + [SetUp] + public void Setup() + { + _mockApiCache = new Mock(null); // Base constructor requires ILogger and IOptions, pass null for simplicity in mock + _mockImmichApi = new Mock(null, null); // Base constructor requires ILogger, IHttpClientFactory, IOptions, pass null + _mockAccountSettings = new Mock(); + + _memoryAssetsPool = new MemoryAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + } + + private List CreateSampleAssets(int count, bool withExif, int yearCreated) + { + var assets = new List(); + for (int i = 0; i < count; i++) + { + var asset = new AssetResponseDto + { + Id = Guid.NewGuid().ToString(), + OriginalPath = $"/path/to/image{i}.jpg", + Type = AssetTypeEnum.IMAGE, + ExifInfo = withExif ? new ExifResponseDto { DateTimeOriginal = new DateTime(yearCreated, 1, 1) } : null, + People = new List() + }; + assets.Add(asset); + } + return assets; + } + + private List CreateSampleMemories(int memoryCount, int assetsPerMemory, bool withExifInAssets, int memoryYear) + { + var memories = new List(); + for (int i = 0; i < memoryCount; i++) + { + var memory = new MemoryResponseDto + { + Id = $"Memory {i}", + Assets = CreateSampleAssets(assetsPerMemory, withExifInAssets, memoryYear), + Data = new OnThisDayDto { Year = memoryYear } + }; + memories.Add(memory); + } + return memories; + } + + [Test] + public async Task LoadAssets_CallsSearchMemoriesAsync() + { + // Arrange + _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(new List()); + + // Act + // Access protected method via reflection for testing, or make it internal/public if design allows + // For now, we assume LoadAssets is implicitly called by a public method of CachingApiAssetsPool (e.g. GetAsset) + // Let's simulate this by calling a method that would trigger LoadAssets if cache is empty. + // Since LoadAssets is protected, we'll test its effects via GetAsset. + // We need to ensure the cache is empty or expired for LoadAssets to be called. + _mockApiCache.Setup(c => c.GetOrAddAsync(It.IsAny(), It.IsAny>>>())) + .Returns>>>(async (key, factory) => await factory()); + + + await _memoryAssetsPool.GetAssets(1, CancellationToken.None); // This should trigger LoadAssets + + // Assert + _mockImmichApi.Verify(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny()), Times.Once); + } + + [Test] + public async Task LoadAssets_FetchesAssetInfo_WhenExifInfoIsNull() + { + // Arrange + var memoryYear = DateTime.Now.Year - 2; + var memories = CreateSampleMemories(1, 1, false, memoryYear); // Asset without ExifInfo + var assetId = memories[0].Assets.First().Id; + + _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(memories); + _mockImmichApi.Setup(x => x.GetAssetInfoAsync(new Guid(assetId), null, It.IsAny())) + .ReturnsAsync(new AssetResponseDto { Id = assetId, ExifInfo = new ExifResponseDto { DateTimeOriginal = new DateTime(memoryYear, 1, 1) }, People = new List() }); + + _mockApiCache.Setup(c => c.GetOrAddAsync>(It.IsAny(), It.IsAny>>>())) + .Returns>>>(async (key, factory) => await factory()); + + // Act + var resultAsset = (await _memoryAssetsPool.GetAssets(1, CancellationToken.None)).First(); // Triggers LoadAssets + + // Assert + _mockImmichApi.Verify(x => x.GetAssetInfoAsync(new Guid(assetId), null, It.IsAny()), Times.Once); + Assert.That(resultAsset.ExifInfo, Is.Not.Null); + Assert.That(resultAsset.ExifInfo.Description, Is.EqualTo("2 years ago")); + } + + [Test] + public async Task LoadAssets_DoesNotFetchAssetInfo_WhenExifInfoIsPresent() + { + // Arrange + var memoryYear = DateTime.Now.Year - 1; + var memories = CreateSampleMemories(1, 1, true, memoryYear); // Asset with ExifInfo + var assetId = memories[0].Assets.First().Id; + + _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(memories); + + _mockApiCache.Setup(c => c.GetOrAddAsync>(It.IsAny(), It.IsAny>>>())) + .Returns>>>(async (key, factory) => await factory()); + + // Act + var resultAsset = (await _memoryAssetsPool.GetAssets(1, CancellationToken.None)).First(); // Triggers LoadAssets + + // Assert + _mockImmichApi.Verify(x => x.GetAssetInfoAsync(It.IsAny(), null, It.IsAny()), Times.Never); + Assert.That(resultAsset.ExifInfo, Is.Not.Null); + Assert.That(resultAsset.ExifInfo.Description, Is.EqualTo("1 year ago")); + } + + [Test] + public async Task LoadAssets_CorrectlyFormatsDescription_YearsAgo() + { + // Arrange + var currentYear = DateTime.Now.Year; + var testCases = new[] + { + new { year = currentYear - 1, expectedDesc = "1 year ago" }, + new { year = currentYear - 5, expectedDesc = "5 years ago" }, + new { year = currentYear, expectedDesc = "0 years ago" } // Or "This year" depending on desired logic, current is "0 years ago" + }; + + foreach (var tc in testCases) + { + var memories = CreateSampleMemories(1, 1, true, tc.year); + memories[0].Assets.First().ExifInfo.DateTimeOriginal = new DateTime(tc.year, 1, 1); // Ensure Exif has the year + + _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(memories); + + // Reset and re-setup cache mock for each iteration to ensure factory is called + _mockApiCache = new Mock(null); + _mockApiCache.Setup(c => c.GetOrAddAsync>(It.IsAny(), It.IsAny>>>())) + .Returns>>>(async (key, factory) => await factory()); + _memoryAssetsPool = new MemoryAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + + + // Act + var resultAsset = (await _memoryAssetsPool.GetAssets(1, CancellationToken.None)).First(); // Triggers LoadAssets + + // Assert + Assert.That(resultAsset.ExifInfo, Is.Not.Null); + Assert.That(resultAsset.ExifInfo.Description, Is.EqualTo(tc.expectedDesc), $"Failed for year {tc.year}"); + } + } + + [Test] + public async Task LoadAssets_AggregatesAssetsFromMultipleMemories() + { + // Arrange + var memoryYear = DateTime.Now.Year - 3; + var memories = CreateSampleMemories(2, 2, true, memoryYear); // 2 memories, 2 assets each + + _mockImmichApi.Setup(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny())) + .ReturnsAsync(memories).Verifiable(Times.Once); + + _mockApiCache.Setup(c => c.GetOrAddAsync(It.IsAny(), It.IsAny>>>())) + .Returns>>>(async (key, factory) => await factory()); + + // Act + // We will rely on the fact that the factory in GetFromCacheAsync is called, and it returns the list. + // The count can be indirectly verified if we could access the pool's internal list after LoadAssets. + + // Let's refine the test to ensure LoadAssets returns the correct number of assets. + // We need a way to inspect the result of LoadAssets directly. + // We can make LoadAssets internal and use InternalsVisibleTo, or use reflection. + // Or, we can rely on the setup of GetFromCacheAsync to capture the factory's result. + IEnumerable loadedAssets = null; + _mockApiCache.Setup(c => c.GetOrAddAsync>(It.IsAny(), It.IsAny>>>())) + .Returns>>>(async (key, factory) => + { + loadedAssets = await factory(); + return loadedAssets; + }); + + await _memoryAssetsPool.GetAssets(4, CancellationToken.None); // Trigger load + + // Assert + Assert.That(loadedAssets, Is.Not.Null); + Assert.That(loadedAssets.Count(), Is.EqualTo(4)); // 2 memories * 2 assets + _mockImmichApi.VerifyAll(); + + } +} diff --git a/ImmichFrame.Core.Tests/Logic/Pool/MultiAssetPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/MultiAssetPoolTests.cs new file mode 100644 index 00000000..e0919a1a --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/MultiAssetPoolTests.cs @@ -0,0 +1,304 @@ +using Moq; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Logic.Pool; +using NUnit.Framework.Constraints; + +namespace ImmichFrame.Core.Tests.Logic.Pool +{ + [TestFixture] + public class MultiAssetPoolTests + { + private Mock _mockPool1; + private Mock _mockPool2; + private Mock _mockPool3; + private MultiAssetPool _multiPool; + + [SetUp] + public void Setup() + { + _mockPool1 = new Mock(); + _mockPool2 = new Mock(); + _mockPool3 = new Mock(); + } + + private AssetResponseDto CreateAsset(string id) => new AssetResponseDto { Id = id, OriginalPath = $"/path/{id}.jpg", Type = AssetTypeEnum.IMAGE, ExifInfo = new ExifResponseDto() }; + + [Test] + public async Task GetAssetCount_NoPools_ReturnsZero() + { + _multiPool = new MultiAssetPool(new List()); + Assert.That(await _multiPool.GetAssetCount(CancellationToken.None), Is.EqualTo(0)); + } + + [Test] + public async Task GetAssetCount_OnePool_ReturnsCorrectCount() + { + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(5L); + _multiPool = new MultiAssetPool(new List { _mockPool1.Object }); + Assert.That(await _multiPool.GetAssetCount(CancellationToken.None), Is.EqualTo(5L)); + } + + [Test] + public async Task GetAssetCount_MultiplePools_ReturnsSumOfCounts() + { + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(5L); + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(10L); + _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); + Assert.That(await _multiPool.GetAssetCount(CancellationToken.None), Is.EqualTo(15L)); + } + + [Test] + public async Task GetAssets_RequestZeroAssets_ReturnsEmptyCollection() + { + _multiPool = new MultiAssetPool(new List { _mockPool1.Object }); + var result = await _multiPool.GetAssets(0, CancellationToken.None); + Assert.That(result, Is.Empty); + } + + [Test] + public async Task GetAssets_TotalLessThanRequested_ReturnsAllAvailableAssets() + { + var asset1 = CreateAsset("a1"); + var asset2 = CreateAsset("a2"); + var pool1AvailableAssets = new Queue(new List { asset1, asset2 }); + var allAssetsFromPool1 = new List { asset1, asset2 }; + + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())) + .ReturnsAsync(() => (long)pool1AvailableAssets.Count); + + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => pool1AvailableAssets.Any() + ? new List { pool1AvailableAssets.Dequeue() } + : new List()); // Moq wraps this in Task.FromResult + + _multiPool = new MultiAssetPool(new List { _mockPool1.Object }); + + var result = (await _multiPool.GetAssets(5, CancellationToken.None)).ToList(); + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.All(x => allAssetsFromPool1.Contains(x)), Is.True); + Assert.That(allAssetsFromPool1.All(x => result.Contains(x)), Is.True); + } + + [Test] + public async Task GetAssets_TotalMoreThanRequested_AggregatesAssets() + { + var p1a1 = CreateAsset("p1a1"); + var pool1Queue = new Queue(new[] { p1a1 }); + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)pool1Queue.Count); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => pool1Queue.Any() ? new List { pool1Queue.Dequeue() } : new List()); + + var p2a1 = CreateAsset("p2a1"); + var pool2Queue = new Queue(new[] { p2a1 }); + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)pool2Queue.Count); + _mockPool2.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => pool2Queue.Any() ? new List { pool2Queue.Dequeue() } : new List()); + + _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); + + var result = (await _multiPool.GetAssets(2, CancellationToken.None)).ToList(); + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.Contains(p1a1) && result.Contains(p2a1), Is.True); + } + + [Test] + public async Task GetAssets_PoolReturnsFewerAssetsThanCountSuggests_HandlesGracefully() + { + var p1a1 = CreateAsset("p1a1"); + var p1a2 = CreateAsset("p1a2"); + var pool1AvailableAssets = new Queue(new List { p1a1, p1a2 }); + var originalPool1Assets = new List { p1a1, p1a2 }; + + var pool1ReportedCount = 5L; + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())) + .ReturnsAsync(() => pool1ReportedCount); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => + { + if (pool1AvailableAssets.Any()) + { + if (pool1AvailableAssets.Count == 1) pool1ReportedCount = 0L; // Update reported count after last actual asset + return new List { pool1AvailableAssets.Dequeue() }; + } + + pool1ReportedCount = 0L; // Ensure reported count is 0 if called after exhaustion + return new List(); + }); + + var p2a1 = CreateAsset("p2a1"); + var pool2AvailableAssets = new Queue(new List { p2a1 }); + var originalPool2Assets = new List { p2a1 }; + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())) + .ReturnsAsync(() => (long)pool2AvailableAssets.Count); + _mockPool2.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => pool2AvailableAssets.Any() ? new List { pool2AvailableAssets.Dequeue() } : new List()); + + _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); + var result = (await _multiPool.GetAssets(5, CancellationToken.None)).ToList(); + + Assert.That(result.Count, Is.EqualTo(3)); + var expectedTotalAssets = originalPool1Assets.Concat(originalPool2Assets).ToList(); + Assert.That(result.All(x => expectedTotalAssets.Contains(x)), Is.True); + Assert.That(expectedTotalAssets.All(x => result.Contains(x)), Is.True); + } + + [Test] + public async Task GetAssets_DifferentAssetCounts_RetrievesAssetsFromPools() + { + var p1a1 = CreateAsset("p1a1"); + var p1a2 = CreateAsset("p1a2"); + var p1a3 = CreateAsset("p1a3"); + var pool1Queue = new Queue(new List { p1a1, p1a2, p1a3 }); + var originalPool1Assets = new List { p1a1, p1a2, p1a3 }; + + var p2a1 = CreateAsset("p2a1"); + var pool2Queue = new Queue(new List { p2a1 }); + var originalPool2Assets = new List { p2a1 }; + + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())) + .ReturnsAsync(() => (long)pool1Queue.Count); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => pool1Queue.Any() ? new List { pool1Queue.Dequeue() } : new List()); + + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())) + .ReturnsAsync(() => (long)pool2Queue.Count); + _mockPool2.Setup(p => p.GetAssets(1, It.IsAny())) + .ReturnsAsync(() => pool2Queue.Any() ? new List { pool2Queue.Dequeue() } : new List()); + + _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); + + var retrievedAssets = new List(); + for (int i = 0; i < 5; i++) + { + var assetResultList = await _multiPool.GetAssets(1, CancellationToken.None); + var assetResult = assetResultList.FirstOrDefault(); + if (assetResult != null) + { + retrievedAssets.Add(assetResult); + } + else if (retrievedAssets.Count >= 4) + { + break; + } + } + + Assert.That(retrievedAssets.Count, Is.EqualTo(4)); + Assert.That(retrievedAssets.Count(a => originalPool1Assets.Contains(a)), Is.EqualTo(3)); + Assert.That(retrievedAssets.Count(a => originalPool2Assets.Contains(a)), Is.EqualTo(1)); + } + + [Test] + public async Task GetAssets_PoolWithZeroCount_IsNotCalledForAssets() + { + var assets1 = new List { CreateAsset("p1a1") }; + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(1L); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())).ReturnsAsync(assets1); + + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(0L); + _mockPool2.Setup(p => p.GetAssets(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); + var result = (await _multiPool.GetAssets(1, CancellationToken.None)).ToList(); + + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result.First(), Is.SameAs(assets1.First())); + } + + [Test] + public async Task GetNextAssetBehavior_NoAssetsInAnyPool_ReturnsNull() + { + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(0L); + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(0L); + _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); + + var result = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + Assert.That(result, Is.Null); + } + + [Test] + public async Task GetNextAssetBehavior_RetrievesAllAssets() + { + var assetP1A1 = CreateAsset("P1A1"); + var assetP2A1 = CreateAsset("P2A1"); + var q1 = new Queue(new[] { assetP1A1 }); + var q2 = new Queue(new[] { assetP2A1 }); + + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)q1.Count); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())).ReturnsAsync(() => q1.Any() ? new List { q1.Dequeue() } : new List()); + + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)q2.Count); + _mockPool2.Setup(p => p.GetAssets(1, It.IsAny())).ReturnsAsync(() => q2.Any() ? new List { q2.Dequeue() } : new List()); + + _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); + + var results = new HashSet(); + var asset1 = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + if (asset1 != null) results.Add(asset1); + + var asset2 = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + if (asset2 != null) results.Add(asset2); + + Assert.That(results.Count, Is.EqualTo(2)); + Assert.That(results, Does.Contain(assetP1A1)); + Assert.That(results, Does.Contain(assetP2A1)); + + var asset3 = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + Assert.That(asset3, Is.Null); + } + + [Test] + public async Task GetNextAssetBehavior_PoolWithZeroCount_IsNotCalled() + { + var asset1 = CreateAsset("p1a1"); + var q1 = new Queue(new[] { asset1 }); + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)q1.Count); + _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())).ReturnsAsync(() => q1.Any() ? new List { q1.Dequeue() } : new List()); + + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(0L); + _mockPool2.Setup(p => p.GetAssets(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + + _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); + + var result = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + Assert.That(result, Is.SameAs(asset1)); + + _mockPool1.Verify(p => p.GetAssets(1, It.IsAny()), Times.Once()); + _mockPool2.Verify(p => p.GetAssets(1, It.IsAny()), Times.Never()); + + var nextResult = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + Assert.That(nextResult, Is.Null, "Pool1 exhausted, Pool2 zero count, should be null"); + } + + [Test] + public async Task GetNextAssetBehavior_PoolIsExhausted_SwitchesToNextAvailablePool() + { + var assetP2A1 = CreateAsset("P2A1"); + var assetP3A1 = CreateAsset("P3A1"); + var q2 = new Queue(new[] { assetP2A1 }); + var q3 = new Queue(new[] { assetP3A1 }); + + _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(0L); + _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)q2.Count); + _mockPool2.Setup(p => p.GetAssets(1, It.IsAny())).ReturnsAsync(() => q2.Any() ? new List { q2.Dequeue() } : new List()); + _mockPool3.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(() => (long)q3.Count); + _mockPool3.Setup(p => p.GetAssets(1, It.IsAny())).ReturnsAsync(() => q3.Any() ? new List { q3.Dequeue() } : new List()); + + _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object, _mockPool3.Object }); + + var result1 = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + Assert.That(result1, Is.EqualTo(assetP2A1).Or.EqualTo(assetP3A1)); + + var result2 = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + if (result1 == assetP2A1) + Assert.That(result2, Is.SameAs(assetP3A1), "Should get asset from Pool 3 if Pool 2 was first"); + else + Assert.That(result2, Is.SameAs(assetP2A1), "Should get asset from Pool 2 if Pool 3 was first"); + } + } +} \ No newline at end of file diff --git a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs new file mode 100644 index 00000000..9065212b --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs @@ -0,0 +1,111 @@ +using NUnit.Framework; +using Moq; +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic.Pool; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; + +namespace ImmichFrame.Core.Tests.Logic.Pool; + +[TestFixture] +public class PersonAssetsPoolTests // Renamed from PeopleAssetsPoolTests to match class name +{ + private Mock _mockApiCache; + private Mock _mockImmichApi; + private Mock _mockAccountSettings; + private TestablePersonAssetsPool _personAssetsPool; + + private class TestablePersonAssetsPool : PersonAssetsPool + { + public TestablePersonAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + : base(apiCache, immichApi, accountSettings) { } + + public Task> TestLoadAssets(CancellationToken ct = default) + { + return base.LoadAssets(ct); + } + } + + [SetUp] + public void Setup() + { + _mockApiCache = new Mock(null); + _mockImmichApi = new Mock(null, null); + _mockAccountSettings = new Mock(); + _personAssetsPool = new TestablePersonAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + + _mockAccountSettings.SetupGet(s => s.People).Returns(new List()); + } + + private AssetResponseDto CreateAsset(string id) => new AssetResponseDto { Id = id, Type = AssetTypeEnum.IMAGE }; + private SearchResponseDto CreateSearchResult(List assets, int total) => + new SearchResponseDto { Assets = new SearchAssetResponseDto { Items = assets, Total = total } }; + + [Test] + public async Task LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates() + { + // Arrange + var person1Id = Guid.NewGuid(); + var person2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + + var batchSize = 1000; // From PersonAssetsPool.cs + var p1AssetsPage1 = Enumerable.Range(0, batchSize).Select(i => CreateAsset($"p1_p1_{i}")).ToList(); + var p1AssetsPage2 = Enumerable.Range(0, 30).Select(i => CreateAsset($"p1_p2_{i}")).ToList(); + var p2AssetsPage1 = Enumerable.Range(0, 20).Select(i => CreateAsset($"p2_p1_{i}")).ToList(); + + // Person 1 - Page 1 + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 1), It.IsAny())) + .ReturnsAsync(CreateSearchResult(p1AssetsPage1, batchSize)); + // Person 1 - Page 2 + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 2), It.IsAny())) + .ReturnsAsync(CreateSearchResult(p1AssetsPage2, 30)); + // Person 2 - Page 1 + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id) && d.Page == 1), It.IsAny())) + .ReturnsAsync(CreateSearchResult(p2AssetsPage1, 20)); + + // Act + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(batchSize + 30 + 20)); + Assert.That(result.Any(a => a.Id == "p1_p1_0")); + Assert.That(result.Any(a => a.Id == "p1_p2_29")); + Assert.That(result.Any(a => a.Id == "p2_p1_19")); + + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 1), It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 2), It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id) && d.Page == 1), It.IsAny()), Times.Once); + } + + [Test] + public async Task LoadAssets_NoPeopleConfigured_ReturnsEmpty() + { + _mockAccountSettings.SetupGet(s => s.People).Returns(new List()); + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + Assert.That(result, Is.Empty); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task LoadAssets_PersonHasNoAssets_DoesNotAffectOthers() + { + var person1Id = Guid.NewGuid(); // Has assets + var person2Id = Guid.NewGuid(); // No assets + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + + var p1Assets = Enumerable.Range(0, 10).Select(i => CreateAsset($"p1_{i}")).ToList(); + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id)), It.IsAny())) + .ReturnsAsync(CreateSearchResult(p1Assets, 10)); + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id)), It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List(), 0)); + + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + Assert.That(result.Count, Is.EqualTo(10)); + Assert.That(result.All(a => a.Id.StartsWith("p1_"))); + } +} diff --git a/ImmichFrame.Core.Tests/Logic/Pool/QueuingAssetPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/QueuingAssetPoolTests.cs new file mode 100644 index 00000000..134609f5 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/QueuingAssetPoolTests.cs @@ -0,0 +1,242 @@ +using NUnit.Framework; +using Moq; +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Logic.Pool; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; +using System.Threading.Channels; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; + +namespace ImmichFrame.Core.Tests.Logic.Pool; + +[TestFixture] +public class QueuingAssetPoolTests +{ + private Mock _mockDelegatePool; + private QueuingAssetPool _queuingAssetPool; + + private const int ReloadBatchSize = 50; // Matches const in QueuingAssetPool + private const int ReloadThreshold = 10; // Matches const in QueuingAssetPool + + [SetUp] + public void Setup() + { + var logger = FixtureHelpers.TestLogger(); + _mockDelegatePool = new Mock(); + + // QueuingAssetPool inherits from AggregatingAssetPool, which has a constructor + // expecting IEnumerable. However, the QueuingAssetPool constructor + // only takes a single IAssetPool delegate. We pass the delegate in an array. + _queuingAssetPool = new QueuingAssetPool(logger, _mockDelegatePool.Object); + } + + private List CreateSampleAssets(int count, string prefix = "asset") + { + var assets = new List(); + for (int i = 0; i < count; i++) + { + assets.Add(new AssetResponseDto { Id = $"{prefix}_{i}", Type = AssetTypeEnum.IMAGE }); + } + + return assets; + } + + [Test] + public async Task GetAssetCount_DelegatesToInnerPool() + { + // Arrange + long expectedCount = 123; + _mockDelegatePool.Setup(dp => dp.GetAssetCount(It.IsAny())).ReturnsAsync(expectedCount); + + // Act + var actualCount = await _queuingAssetPool.GetAssetCount(); + + // Assert + Assert.That(actualCount, Is.EqualTo(expectedCount)); + _mockDelegatePool.Verify(dp => dp.GetAssetCount(It.IsAny()), Times.Once); + } + + [Test] + public async Task GetNextAsset_RetrievesFromInitiallyEmptyQueue_TriggersReload_ReturnsAsset() + { + // Arrange + var assetToReturn = CreateSampleAssets(1, "initial_load").First(); + _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, It.IsAny())) + .ReturnsAsync(new List { assetToReturn }) + .Verifiable(); + + // Act + var result = await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Id, Is.EqualTo(assetToReturn.Id)); + _mockDelegatePool.Verify(dp => dp.GetAssets(ReloadBatchSize, It.IsAny()), Times.Once, "ReloadAssetsAsync should have been called as queue was empty."); + } + + [Test] + public async Task GetNextAsset_QueueAboveThreshold_DoesNotTriggerReload() + { + // Arrange + // Pre-fill the queue to be above the threshold + var initialAssets = CreateSampleAssets(ReloadThreshold + 5, "prefill"); + foreach (var asset in initialAssets) + { + await _queuingAssetPool.WriteToChannelForTesting(asset); + } + + _mockDelegatePool.Setup(dp => dp.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); // Should not be called + + // Act + var result = await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Id, Is.EqualTo(initialAssets.First().Id)); + _mockDelegatePool.Verify(dp => dp.GetAssets(It.IsAny(), It.IsAny()), Times.Never, "ReloadAssetsAsync should not be called when queue is above threshold."); + } + + [Test] + public async Task GetNextAsset_QueueDropsBelowThreshold_TriggersReload() + { + // Arrange + // Pre-fill queue to threshold + 1. One read will take it to threshold, next below. + var initialAssetsCount = ReloadThreshold + 1; + var initialAssets = CreateSampleAssets(initialAssetsCount, "threshold_test"); + foreach (var asset in initialAssets) + { + await _queuingAssetPool.WriteToChannelForTesting(asset); + } + + var newAssetsToLoad = CreateSampleAssets(5, "reloaded"); + _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, It.IsAny())) + .ReturnsAsync(newAssetsToLoad) + .Verifiable(); // This should be called + + // Act & Assert + // Read one asset, queue count becomes ReloadThreshold. No reload yet. + await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None); + _mockDelegatePool.Verify(dp => dp.GetAssets(ReloadBatchSize, It.IsAny()), Times.Never, "Reload should not happen when count is AT threshold."); + + // Read another asset, queue count becomes ReloadThreshold - 1. Reload should be triggered. + await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None); + _mockDelegatePool.Verify(dp => dp.GetAssets(ReloadBatchSize, It.IsAny()), Times.Once, "Reload should happen when count is BELOW threshold."); + } + + [Test] + public async Task ReloadAssetsAsync_PreventsConcurrentReloads() + { + // Arrange + _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, It.IsAny())) + .ReturnsAsync(() => + { + // Simulate delay in fetching assets + Task.Delay(100).Wait(); + return CreateSampleAssets(ReloadBatchSize, "slow_load"); + }); + + // Act + // Trigger two reloads almost simultaneously. + // Since GetNextAsset calls ReloadAssetsAsync in a fire-and-forget way, + // we need to call ReloadAssetsAsync directly for this test or use GetNextAsset and manage timing. + // We'll call GetNextAsset, which internally calls ReloadAssetsAsync. + // The semaphore in ReloadAssetsAsync should prevent concurrent execution. + + // Call GetNextAsset twice. The first will trigger reload. + // The second, if called while the first is "running", should see the semaphore locked. + var task1 = _queuingAssetPool.GetAssets(1, CancellationToken.None); + var task2 = _queuingAssetPool.GetAssets(1, CancellationToken.None); + + await Task.WhenAll(task1, task2); + + // Assert + // The delegate's GetAssets should only be called once due to semaphore. + _mockDelegatePool.Verify(dp => dp.GetAssets(ReloadBatchSize, It.IsAny()), Times.Once); + } + + [Test] + public async Task GetNextAsset_HandlesOperationCanceledException_ReturnsNull() + { + // Arrange + var cts = new CancellationTokenSource(); + // Ensure queue is empty so ReadAsync is awaited + + // Act: Call GetNextAsset with a token that will be cancelled + var getAssetTask = _queuingAssetPool.GetNextAssetForTesting(cts.Token); + cts.Cancel(); // Cancel the operation + var result = await getAssetTask; + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public async Task ReloadAssetsAsync_AddsFetchedAssetsToQueue() + { + // Arrange + var assetsToLoad = CreateSampleAssets(5, "reloaded_assets"); + _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, It.IsAny())) + .ReturnsAsync(assetsToLoad); + + // Act + // Trigger reload by reading from empty queue + await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None); + + // Wait for the fire-and-forget ReloadAssetsAsync to potentially complete + // This is tricky. A small delay might work for testing but isn't robust. + // Better: check queue count or read multiple items. + await Task.Delay(50); // Give some time for the background reload to process + + // Assert + // Try to read all loaded assets + the first one that triggered the load. + var retrievedAssets = new List(); + retrievedAssets.Add(assetsToLoad.First()); // The one returned by GetNextAsset + + for (int i = 1; i < assetsToLoad.Count; i++) // Read remaining from queue + { + // Use a timeout for reading from channel in case reload didn't populate as expected + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + try + { + var asset = await _queuingAssetPool.GetNextAssetForTesting(timeoutCts.Token); + if (asset != null) retrievedAssets.Add(asset); + else break; + } + catch (OperationCanceledException) // Catch timeout + { + break; + } + } + + Assert.That(retrievedAssets.Count, Is.EqualTo(assetsToLoad.Count), "Should retrieve all assets loaded by ReloadAssetsAsync."); + foreach (var loadedAsset in assetsToLoad) + { + Assert.That(retrievedAssets.Any(ra => ra.Id == loadedAsset.Id), Is.True, $"Asset {loadedAsset.Id} not found in retrieved assets."); + } + } +} + +// Helper extension or make methods in QueuingAssetPool internal/public for testing +public static class QueuingAssetPoolTestExtensions +{ + // Expose GetNextAsset for testing (it's protected in AggregatingAssetPool) + public static Task GetNextAssetForTesting(this QueuingAssetPool pool, CancellationToken ct) + { + // Using reflection to call protected GetNextAsset + var methodInfo = typeof(AggregatingAssetPool).GetMethod("GetNextAsset", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return (Task)methodInfo.Invoke(pool, new object[] { ct }); + } + + // Helper to write to channel for test setup + public static async Task WriteToChannelForTesting(this QueuingAssetPool pool, AssetResponseDto asset) + { + var fieldInfo = typeof(QueuingAssetPool).GetField("_assetQueue", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var channel = (Channel)fieldInfo.GetValue(pool); + await channel.Writer.WriteAsync(asset); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Helpers/ApiCache.cs b/ImmichFrame.Core/Helpers/ApiCache.cs index 420348a5..5ad92264 100644 --- a/ImmichFrame.Core/Helpers/ApiCache.cs +++ b/ImmichFrame.Core/Helpers/ApiCache.cs @@ -8,7 +8,22 @@ public ApiCache(TimeSpan cacheDuration) _cacheDuration = cacheDuration; } - public async Task GetOrAddAsync(string key, Func> factory) + public async Task GetAsync(string key) + { + if (_cache.TryGetValue(key, out var entry)) + { + if (DateTime.UtcNow - entry.Timestamp < _cacheDuration) + { + return (T)entry.Data; + } + + Invalidate(key); // Cache expired + } + + return default; + } + + public virtual async Task GetOrAddAsync(string key, Func> factory) { if (_cache.TryGetValue(key, out var entry)) { diff --git a/ImmichFrame.Core/Helpers/ListExtensionMethods.cs b/ImmichFrame.Core/Helpers/ListExtensionMethods.cs index a142c0d0..0724b2b9 100644 --- a/ImmichFrame.Core/Helpers/ListExtensionMethods.cs +++ b/ImmichFrame.Core/Helpers/ListExtensionMethods.cs @@ -2,12 +2,43 @@ namespace ImmichFrame.Core.Helpers; public static class ListExtensionMethods { + private static readonly Random _random = new(); + public static IEnumerable TakeProportional(this IEnumerable enumerable, double proportion) { if (proportion <= 0) return []; var list = enumerable.ToList(); - var itemsToTake = (int) Math.Ceiling(list.Count * proportion); + var itemsToTake = (int)Math.Ceiling(list.Count * proportion); return list.Take(itemsToTake); } + + public static IEnumerable WhereExcludes(this IEnumerable source, IEnumerable excluded) + => WhereExcludes(source, excluded, t => t!); + + public static IEnumerable WhereExcludes(this IEnumerable source, IEnumerable excluded, Func comparator) + => source.Where(item1 => !excluded.Any(item2 => Equals(comparator(item2), comparator(item1)))); + + public static async Task ChooseOne(this IEnumerable sources, Func> probabilitySelector) + { + var sourcesAndCounts = await Task.WhenAll( + sources.Select(async source => (Source: source, Count: await probabilitySelector(source))) + .ToList()); + + var totalCount = sourcesAndCounts.Sum(source => source.Count); + + var randomIndex = _random.NextInt64(totalCount); + + foreach (var sourceAndCount in sourcesAndCounts) + { + if (randomIndex < sourceAndCount.Count) + { + return sourceAndCount.Source; + } + + randomIndex -= sourceAndCount.Count; + } + + throw new InvalidOperationException("Failed to select item"); + } } \ No newline at end of file diff --git a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs index 511aa475..b59cb685 100644 --- a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs +++ b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs @@ -9,7 +9,7 @@ public interface IImmichFrameLogic public Task GetAssetInfoById(Guid assetId); public Task> GetAlbumInfoById(Guid assetId); public Task<(string fileName, string ContentType, Stream fileStream)> GetImage(Guid id); - public Task GetAssetStats(); + public Task GetTotalAssets(); public Task SendWebhookNotification(IWebhookNotification notification); } diff --git a/ImmichFrame.Core/Logic/ImmichFrameLogic.cs b/ImmichFrame.Core/Logic/ImmichFrameLogic.cs deleted file mode 100644 index f091a2ea..00000000 --- a/ImmichFrame.Core/Logic/ImmichFrameLogic.cs +++ /dev/null @@ -1,521 +0,0 @@ -using ImmichFrame.Core.Api; -using ImmichFrame.Core.Exceptions; -using ImmichFrame.Core.Helpers; -using ImmichFrame.Core.Interfaces; -using System.Data; - -namespace ImmichFrame.Core.Logic -{ - public class ImmichFrameLogic : IImmichFrameLogic - { - private IAccountSettings _accountSettings; - private IGeneralSettings _frameSettings; - public ImmichFrameLogic(IAccountSettings accountSettings, IGeneralSettings frameSettings) - { - _accountSettings = accountSettings; - _frameSettings = frameSettings; - } - - private Task?>? _filteredAssetInfos; - private DateTime lastFilteredAssetRefesh; - private List ImmichFrameAlbumAssets = new List(); - private static AlbumResponseDto immichFrameAlbum = new AlbumResponseDto(); - - - private Task>? _excludedAlbumAssets; - private Task> ExcludedAlbumAssets - { - get - { - if (_excludedAlbumAssets == null) - _excludedAlbumAssets = GetExcludedAlbumAssets(); - - return _excludedAlbumAssets; - } - } - - private Task?> FilteredAssetInfos - { - get - { - TimeSpan timeSinceRefresh = DateTime.Now - lastFilteredAssetRefesh; - if (_filteredAssetInfos == null || timeSinceRefresh.TotalHours > _frameSettings.RefreshAlbumPeopleInterval) - { - lastFilteredAssetRefesh = DateTime.Now; - _filteredAssetInfos = GetFilteredAssetIds(); - } - - return _filteredAssetInfos; - } - } - - public Task GetAssetInfoById(Guid assetId) - { - using (var client = new HttpClient()) - { - client.UseApiKey(_accountSettings.ApiKey); - var immichApi = new ImmichApi((_accountSettings).ImmichServerUrl, client); - - return immichApi.GetAssetInfoAsync(assetId, null); - } - } - - public async Task> GetAlbumInfoById(Guid assetId) - { - using (var client = new HttpClient()) - { - client.UseApiKey(_accountSettings.ApiKey); - var immichApi = new ImmichApi(_accountSettings.ImmichServerUrl, client); - - return await immichApi.GetAllAlbumsAsync(assetId, null); - } - } - - private int _assetAmount = 250; - public async Task> GetAssets() - { - if ((await FilteredAssetInfos) != null) - { - return await GetRandomFilteredAssets(); - } - - return await GetRandomAssets(); - } - - public async Task GetNextAsset() - { - if ((await FilteredAssetInfos) != null) - { - return await GetRandomFilteredAsset(); - } - - return await GetRandomAsset(); - } - - string DownloadLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ImageCache"); - public async Task<(string fileName, string ContentType, Stream fileStream)> GetImage(Guid id) - { - if (_frameSettings.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 (_frameSettings.RenewImagesDuration > (DateTime.UtcNow - File.GetCreationTimeUtc(file)).Days) - { - var fs = File.OpenRead(file); - - var ext = Path.GetExtension(file); - - return (Path.GetFileName(file), $"image/{ext}", fs); - } - - File.Delete(file); - } - } - - using (var client = new HttpClient()) - { - client.UseApiKey(_accountSettings.ApiKey); - - var immichApi = new ImmichApi(_accountSettings.ImmichServerUrl, client); - - 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()?.ToString() ?? ""; - } - var ext = contentType.ToLower() == "image/webp" ? "webp" : "jpeg"; - var fileName = $"{id}.{ext}"; - - if (_frameSettings.DownloadImages) - { - var stream = data.Stream; - - var filePath = Path.Combine(DownloadLocation, fileName); - - // save to folder - var fs = File.Create(filePath); - stream.CopyTo(fs); - fs.Position = 0; - return (Path.GetFileName(filePath), contentType, fs); - } - - return (fileName, contentType, data.Stream); - } - } - private async Task?> GetFilteredAssetIds() - { - bool assetsAdded = false; - IEnumerable list = new List(); - if (_accountSettings.ShowMemories) - { - assetsAdded = true; - list = list.Union(await GetMemoryAssets()); - } - - if (_accountSettings.ShowFavorites) - { - assetsAdded = true; - list = list.Union(await GetRandomAssets()); - } - - if (_accountSettings.Albums?.Any() ?? false) - { - assetsAdded = true; - list = list.Union(await GetAlbumAssets()); - } - - if (_accountSettings.People?.Any() ?? false) - { - assetsAdded = true; - list = list.Union(await GetPeopleAssets()); - } - - if (_accountSettings.Rating.HasValue && list.Any()) - { - list = list.Where(x => x.ExifInfo.Rating == _accountSettings.Rating.Value); - } - - if (assetsAdded) - { - // Exclude videos - list = list.Where(x => x.Type != AssetTypeEnum.VIDEO); - - var takenBefore = _accountSettings.ImagesUntilDate.HasValue ? _accountSettings.ImagesUntilDate : null; - if (takenBefore.HasValue) - { - list = list.Where(x => x.FileCreatedAt < takenBefore.Value); - } - - var takenAfter = _accountSettings.ImagesFromDate.HasValue ? _accountSettings.ImagesFromDate : _accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-_accountSettings.ImagesFromDays.Value) : null; - if (takenAfter.HasValue) - { - list = list.Where(x => x.FileCreatedAt > takenAfter.Value); - } - - var excludedList = await ExcludedAlbumAssets; - - // Exclude assets if configured - if (excludedList.Any()) - list = list.Where(x => !excludedList.Contains(Guid.Parse(x.Id))); - - // return only unique assets, no duplicates, only with Thumbnail - return list.Where(x => x.Thumbhash != null).DistinctBy(x => x.Id).ToDictionary(x => Guid.Parse(x.Id)); - } - - return null; - } - private async Task> GetMemoryAssets() - { - using (var client = new HttpClient()) - { - client.UseApiKey(_accountSettings.ApiKey); - - var immichApi = new ImmichApi(_accountSettings.ImmichServerUrl, client); - - var allAssets = new List(); - - var date = DateTime.Today; - ICollection memories; - try - { - memories = await immichApi.SearchMemoriesAsync(DateTime.Now, null, null, null); - } - catch (ApiException ex) - { - throw new AlbumNotFoundException($"Memories were not found, check your settings file!{Environment.NewLine}{Environment.NewLine}{ex.Message}", ex); - } - - foreach (var memory in memories) - { - var assets = memory.Assets.ToList(); - // var yearsAgo = DateTime.Now.Year - lane.Data.Year; - // assets.ForEach(asset => asset.ExifInfo.Description = $"{yearsAgo} {(yearsAgo == 1 ? "year" : "years")} ago"); - - allAssets.AddRange(assets); - } - - return allAssets; - } - } - private async Task> GetAlbumAssets(Guid albumId, ImmichApi immichApi) - { - try - { - var albumInfo = await immichApi.GetAlbumInfoAsync(albumId, null, null); - - return _accountSettings.ShowArchived ? albumInfo.Assets : albumInfo.Assets.Where(x => !x.IsArchived); - } - catch (ApiException ex) - { - throw new AlbumNotFoundException($"Album '{albumId}' was not found, check your settings file!{Environment.NewLine}{Environment.NewLine}{ex.Message}", ex); - } - } - private async Task> GetAlbumAssets() - { - using var client = new HttpClient(); - - var allAssets = new List(); - - var immichApi = new ImmichApi(_accountSettings.ImmichServerUrl, client); - - client.UseApiKey(_accountSettings.ApiKey); - foreach (var albumId in _accountSettings.Albums!) - { - allAssets.AddRange(await GetAlbumAssets(albumId, immichApi)); - } - - return allAssets; - } - private async Task> GetExcludedAlbumAssets() - { - using var client = new HttpClient(); - - var allAssets = new List(); - - var immichApi = new ImmichApi(_accountSettings.ImmichServerUrl, client); - - client.UseApiKey(_accountSettings.ApiKey); - foreach (var albumId in _accountSettings.ExcludedAlbums!) - { - allAssets.AddRange(await GetAlbumAssets(albumId, immichApi)); - } - - return allAssets.Select(x => Guid.Parse(x.Id)); - } - - public Task GetAssetStats() - { - using var client = new HttpClient(); - - var allAssets = new List(); - - var immichApi = new ImmichApi(_accountSettings.ImmichServerUrl, client); - - client.UseApiKey(_accountSettings.ApiKey); - - return immichApi.GetAssetStatisticsAsync(null, false, null); - } - - private async Task> GetPeopleAssets() - { - using (var client = new HttpClient()) - { - var allAssets = new List(); - - var immichApi = new ImmichApi(_accountSettings.ImmichServerUrl, client); - - client.UseApiKey(_accountSettings.ApiKey); - foreach (var personId in _accountSettings.People!) - { - try - { - int page = 1; - int batchSize = 1000; - int total = 0; - do - { - var metadataBody = new MetadataSearchDto - { - Page = page, - Size = batchSize, - PersonIds = new[] { personId }, - Type = AssetTypeEnum.IMAGE, - WithExif = true, - WithPeople = true - }; - - if (_accountSettings.ShowArchived) - { - metadataBody.Visibility = AssetVisibility.Archive; - } - else - { - metadataBody.Visibility = AssetVisibility.Timeline; - } - - var takenBefore = _accountSettings.ImagesUntilDate.HasValue ? _accountSettings.ImagesUntilDate : null; - if (takenBefore.HasValue) - { - metadataBody.TakenBefore = takenBefore.Value; - } - - var takenAfter = _accountSettings.ImagesFromDate.HasValue ? _accountSettings.ImagesFromDate : _accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-_accountSettings.ImagesFromDays.Value) : null; - if (takenAfter.HasValue) - { - metadataBody.TakenAfter = takenAfter.Value; - } - - var personInfo = await immichApi.SearchAssetsAsync(metadataBody); - - total = personInfo.Assets.Total; - - allAssets.AddRange(personInfo.Assets.Items); - page++; - } - while (total == batchSize); - } - catch (ApiException ex) - { - throw new PersonNotFoundException($"Person '{personId}' was not found, check your settings file!{Environment.NewLine}{Environment.NewLine}{ex.Message}", ex); - } - } - - // Remove duplicates - var uniqueAssets = allAssets.DistinctBy(x => x.Id); - - return uniqueAssets; - } - } - private Random _random = new Random(); - private async Task GetRandomFilteredAsset() - { - var filteredAssetInfos = await FilteredAssetInfos; - if (filteredAssetInfos == null || !filteredAssetInfos.Any()) - throw new AssetNotFoundException(); - - var rnd = _random.Next(filteredAssetInfos.Count); - - return filteredAssetInfos.ElementAt(rnd).Value; - } - private async Task> GetRandomFilteredAssets() - { - var filteredAssetInfos = await FilteredAssetInfos; - if (filteredAssetInfos == null || !filteredAssetInfos.Any()) - return new List(); - - // If only memories, do not return random and order by date - if (_accountSettings.ShowMemories && !_accountSettings.Albums.Any() && !_accountSettings.People.Any()) - return filteredAssetInfos.OrderBy(x => x.Value.ExifInfo.DateTimeOriginal).Select(x => x.Value).ToList(); - - // Return randomly ordered list - return filteredAssetInfos.OrderBy(asset => _random.Next(filteredAssetInfos.Count)).Take(_assetAmount).Select(x => x.Value).ToList(); - } - - List RandomAssetList = new List(); - private async Task> GetRandomAssets() - { - if (RandomAssetList.Any()) - { - var assets = new List(RandomAssetList); - RandomAssetList.Clear(); - - return assets; - } - - if (await LoadRandomAssets()) - { - return await GetRandomAssets(); - } - - return new List(); - } - - private async Task GetRandomAsset() - { - if (RandomAssetList.Any()) - { - var randomAsset = RandomAssetList.First(); - RandomAssetList.Remove(randomAsset); - - // Skip this asset - if (randomAsset.Thumbhash == null) - return await GetRandomAsset(); - - return randomAsset; - } - - if (await LoadRandomAssets()) - { - return await GetRandomAsset(); - } - - return null; - } - - private async Task LoadRandomAssets() - { - using (var client = new HttpClient()) - { - client.UseApiKey(_accountSettings.ApiKey); - - var immichApi = new ImmichApi(_accountSettings.ImmichServerUrl, client); - try - { - var searchBody = new RandomSearchDto - { - Size = _assetAmount, - Type = AssetTypeEnum.IMAGE, - WithExif = true, - WithPeople = true, - }; - - if (_accountSettings.ShowArchived) - { - searchBody.Visibility = AssetVisibility.Archive; - } - else - { - searchBody.Visibility = AssetVisibility.Timeline; - } - - if (_accountSettings.ShowFavorites) - { - searchBody.IsFavorite = true; - } - - if (_accountSettings.Rating.HasValue) - { - searchBody.Rating = _accountSettings.Rating.Value; - } - - var takenBefore = _accountSettings.ImagesUntilDate.HasValue ? _accountSettings.ImagesUntilDate : null; - if (takenBefore.HasValue) - { - searchBody.TakenBefore = takenBefore.Value; - } - - var takenAfter = _accountSettings.ImagesFromDate.HasValue ? _accountSettings.ImagesFromDate : _accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-_accountSettings.ImagesFromDays.Value) : null; - if (takenAfter.HasValue) - { - searchBody.TakenAfter = takenAfter.Value; - } - - var searchResponse = await immichApi.SearchRandomAsync(searchBody); - - var randomAssets = searchResponse; - - if (randomAssets.Any()) - { - var excludedList = await ExcludedAlbumAssets; - - randomAssets = randomAssets.Where(x => !excludedList.Contains(Guid.Parse(x.Id))).ToList(); - - RandomAssetList.AddRange(randomAssets); - - return true; - } - } - catch (ApiException ex) - { - throw new PersonNotFoundException($"Asset was not found, check your settings file!{Environment.NewLine}{Environment.NewLine}{ex.Message}", ex); - } - - return false; - } - } - - public Task SendWebhookNotification(IWebhookNotification notification) => WebhookHelper.SendWebhookNotification(notification, _frameSettings.Webhook); - } -} diff --git a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs index 5e6524c6..3eec3e9e 100644 --- a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs +++ b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs @@ -88,17 +88,10 @@ public Task> GetAlbumInfoById(Guid assetId) return GetLogic(id).GetImage(id); } - public async Task GetAssetStats() + public async Task GetTotalAssets() { - var allInts = await Task.WhenAll(_accountToDelegate.Values.Select(account => account.GetAssetStats())); - - return allInts.Aggregate(new AssetStatsResponseDto(), (acc, response) => - { - acc.Images += response.Images; - acc.Total += response.Total; - acc.Videos += response.Videos; - return acc; - }); + var allInts = await Task.WhenAll(_accountToDelegate.Values.Select(account => account.GetTotalAssets())); + return allInts.Sum(); } diff --git a/ImmichFrame.Core/Logic/OptimizedImmichFrameLogic.cs b/ImmichFrame.Core/Logic/OptimizedImmichFrameLogic.cs deleted file mode 100644 index ddb1f9ed..00000000 --- a/ImmichFrame.Core/Logic/OptimizedImmichFrameLogic.cs +++ /dev/null @@ -1,407 +0,0 @@ -using System.Threading.Channels; -using ImmichFrame.Core.Api; -using ImmichFrame.Core.Exceptions; -using ImmichFrame.Core.Helpers; -using ImmichFrame.Core.Interfaces; -using Microsoft.Extensions.Logging; - -public class OptimizedImmichFrameLogic : IImmichFrameLogic, IDisposable -{ - private readonly IAccountSettings _accountSettings; - private readonly IGeneralSettings _frameSettings; - private readonly HttpClient _httpClient; - private readonly ImmichApi _immichApi; - private readonly ApiCache _apiCache; - private readonly ILogger _logger; - - public OptimizedImmichFrameLogic(IAccountSettings accountSettings, IGeneralSettings frameSettings, ILogger logger) - { - _accountSettings = accountSettings; - _frameSettings = frameSettings; - _logger = logger; - _httpClient = new HttpClient(); - _httpClient.UseApiKey(_accountSettings.ApiKey); - _immichApi = new ImmichApi(_accountSettings.ImmichServerUrl, _httpClient); - _apiCache = new ApiCache(TimeSpan.FromHours(_frameSettings.RefreshAlbumPeopleInterval)); - } - - public void Dispose() - { - _apiCache.Dispose(); - _httpClient.Dispose(); - } - - private Channel _assetQueue = Channel.CreateUnbounded(); - private readonly SemaphoreSlim _isReloadingAssets = new(1, 1); - - public async Task GetNextAsset() - { - try - { - if (_assetQueue.Reader.Count < 10) - { - // Fire-and-forget, reloading assets in the background - ReloadAssetsAsync(); - } - - return await _assetQueue.Reader.ReadAsync(new CancellationTokenSource(TimeSpan.FromMinutes(1)).Token); - } - catch (OperationCanceledException) - { - // This exception occurs if the CancellationTokenSource times out - _logger.LogWarning("Read asset list timed out"); - return null; - } - catch (Exception ex) - { - _logger.LogError($"An unexpected error occurred while reading assets: {ex.Message}"); - throw; - } - } - - private async Task ReloadAssetsAsync() - { - if (await _isReloadingAssets.WaitAsync(0)) - { - try - { - _logger.LogDebug("Reloading assets"); - foreach (var asset in await GetAssets()) - { - await _assetQueue.Writer.WriteAsync(asset); - } - } - finally - { - _isReloadingAssets.Release(); - } - } - else - { - _logger.LogDebug("Assets already being loaded; not attempting a concurrent reload"); - } - } - - public Task GetAssetInfoById(Guid assetId) - { - return _immichApi.GetAssetInfoAsync(assetId, null); - } - - public async Task> GetAlbumInfoById(Guid assetId) - { - return await _immichApi.GetAllAlbumsAsync(assetId, null); - } - - private int _assetAmount = 250; - private Random _random = new Random(); - - public async Task> GetAssets() - { - if (!_accountSettings.ShowFavorites && !_accountSettings.ShowMemories && !_accountSettings.Albums.Any() && !_accountSettings.People.Any()) - { - return await GetRandomAssets(); - } - - IEnumerable assets = new List(); - - if (_accountSettings.ShowFavorites) - assets = assets.Concat(await GetFavoriteAssets()); - if (_accountSettings.ShowMemories) - assets = assets.Concat(await GetMemoryAssets()); - if (_accountSettings.Albums.Any()) - assets = assets.Concat(await GetAlbumAssets()); - if (_accountSettings.People.Any()) - assets = assets.Concat(await GetPeopleAssets()); - - // Display only Images - assets = assets.Where(x => x.Type == AssetTypeEnum.IMAGE); - - if (!_accountSettings.ShowArchived) - assets = assets.Where(x => x.IsArchived == false); - - var takenBefore = _accountSettings.ImagesUntilDate.HasValue ? _accountSettings.ImagesUntilDate : null; - if (takenBefore.HasValue) - { - assets = assets.Where(x => x.ExifInfo.DateTimeOriginal <= takenBefore); - } - - var takenAfter = _accountSettings.ImagesFromDate.HasValue ? _accountSettings.ImagesFromDate : _accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-_accountSettings.ImagesFromDays.Value) : null; - if (takenAfter.HasValue) - { - assets = assets.Where(x => x.ExifInfo.DateTimeOriginal >= takenAfter); - } - - if (_accountSettings.Rating is int rating) - { - assets = assets.Where(x => x.ExifInfo.Rating == rating); - } - - if (_accountSettings.ExcludedAlbums.Any()) - { - var excludedAssetList = await GetExcludedAlbumAssets(); - var excludedAssetSet = excludedAssetList.Select(x => x.Id).ToHashSet(); - assets = assets.Where(x => !excludedAssetSet.Contains(x.Id)); - } - - assets = assets.OrderBy(asset => _random.Next()); - - var assetsList = assets.ToList(); - if (assetsList.Count > _assetAmount) - { - assetsList = assetsList.Take(_assetAmount).ToList(); - } - - return assetsList; - } - - public async Task> GetRandomAssets() - { - var searchDto = new RandomSearchDto - { - Size = _assetAmount, - Type = AssetTypeEnum.IMAGE, - WithExif = true, - WithPeople = true - }; - - if (_accountSettings.ShowArchived) - { - searchDto.Visibility = AssetVisibility.Archive; - } - else - { - searchDto.Visibility = AssetVisibility.Timeline; - } - - var takenBefore = _accountSettings.ImagesUntilDate.HasValue ? _accountSettings.ImagesUntilDate : null; - if (takenBefore.HasValue) - { - searchDto.TakenBefore = takenBefore; - } - var takenAfter = _accountSettings.ImagesFromDate.HasValue ? _accountSettings.ImagesFromDate : _accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-_accountSettings.ImagesFromDays.Value) : null; - - if (takenAfter.HasValue) - { - searchDto.TakenAfter = takenAfter; - } - - if (_accountSettings.Rating is int rating) - { - searchDto.Rating = rating; - } - - var assets = await _immichApi.SearchRandomAsync(searchDto); - - if (_accountSettings.ExcludedAlbums.Any()) - { - var excludedAssetList = await GetExcludedAlbumAssets(); - var excludedAssetSet = excludedAssetList.Select(x => x.Id).ToHashSet(); - assets = assets.Where(x => !excludedAssetSet.Contains(x.Id)).ToList(); - } - - return assets; - } - - public async Task> GetMemoryAssets() - { - return await _apiCache.GetOrAddAsync("MemoryAssets", async () => - { - var today = DateTime.Today; - var memories = await _immichApi.SearchMemoriesAsync(DateTime.Now, null, null, null); - - var memoryAssets = new List(); - foreach (var memory in memories) - { - var assets = memory.Assets.ToList(); - var yearsAgo = DateTime.Now.Year - memory.Data.Year; - - foreach (var asset in assets) - { - if (asset.ExifInfo == null) - { - var assetInfo = await GetAssetInfoById(new Guid(asset.Id)); - asset.ExifInfo = assetInfo.ExifInfo; - asset.People = assetInfo.People; - } - asset.ExifInfo.Description = $"{yearsAgo} {(yearsAgo == 1 ? "year" : "years")} ago"; - } - - memoryAssets.AddRange(assets); - } - - return memoryAssets; - }); - } - - public async Task GetAssetStats() - { - return await _apiCache.GetOrAddAsync("AssetStats", - () => _immichApi.GetAssetStatisticsAsync(null, false, null)); - } - - public async Task> GetFavoriteAssets() - { - return await _apiCache.GetOrAddAsync("FavoriteAssets", async () => - { - var favoriteAssets = new List(); - - int page = 1; - int batchSize = 1000; - int total; - do - { - var metadataBody = new MetadataSearchDto - { - Page = page, - Size = batchSize, - IsFavorite = true, - Type = AssetTypeEnum.IMAGE, - WithExif = true, - WithPeople = true - }; - - var favoriteInfo = await _immichApi.SearchAssetsAsync(metadataBody); - - total = favoriteInfo.Assets.Total; - - favoriteAssets.AddRange(favoriteInfo.Assets.Items); - page++; - } while (total == batchSize); - - return favoriteAssets; - }); - } - - public async Task> GetAlbumAssets() - { - return await _apiCache.GetOrAddAsync("AlbumAssets", async () => - { - var albumAssets = new List(); - - foreach (var albumId in _accountSettings.Albums) - { - var albumInfo = await _immichApi.GetAlbumInfoAsync(albumId, null, null); - - albumAssets.AddRange(albumInfo.Assets); - } - - return albumAssets; - }); - } - - public async Task> GetExcludedAlbumAssets() - { - return await _apiCache.GetOrAddAsync("ExcludedAlbumAssets", async () => - { - var excludedAlbumAssets = new List(); - - foreach (var albumId in _accountSettings.ExcludedAlbums) - { - var albumInfo = await _immichApi.GetAlbumInfoAsync(albumId, null, null); - - excludedAlbumAssets.AddRange(albumInfo.Assets); - } - - return excludedAlbumAssets; - }); - } - - public async Task> GetPeopleAssets() - { - return await _apiCache.GetOrAddAsync("PeopleAssets", async () => - { - var personAssets = new List(); - - foreach (var personId in _accountSettings.People) - { - int page = 1; - int batchSize = 1000; - int total; - do - { - var metadataBody = new MetadataSearchDto - { - Page = page, - Size = batchSize, - PersonIds = new[] { personId }, - Type = AssetTypeEnum.IMAGE, - WithExif = true, - WithPeople = true - }; - - var personInfo = await _immichApi.SearchAssetsAsync(metadataBody); - - total = personInfo.Assets.Total; - - personAssets.AddRange(personInfo.Assets.Items); - page++; - } while (total == batchSize); - } - - return personAssets; - }); - } - - readonly string DownloadLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ImageCache"); - - public async Task<(string fileName, string ContentType, Stream fileStream)> GetImage(Guid id) - { - // Check if the image is already downloaded - if (_frameSettings.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 (_frameSettings.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()?.ToString() ?? ""; - } - - var ext = contentType.ToLower() == "image/webp" ? "webp" : "jpeg"; - var fileName = $"{id}.{ext}"; - - if (_frameSettings.DownloadImages) - { - var stream = data.Stream; - - var filePath = Path.Combine(DownloadLocation, fileName); - - // save to folder - var fs = File.Create(filePath); - stream.CopyTo(fs); - fs.Position = 0; - return (Path.GetFileName(filePath), contentType, fs); - } - - return (fileName, contentType, data.Stream); - } - - public Task SendWebhookNotification(IWebhookNotification notification) => - WebhookHelper.SendWebhookNotification(notification, _frameSettings.Webhook); -} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/AggregatingAssetPool.cs b/ImmichFrame.Core/Logic/Pool/AggregatingAssetPool.cs new file mode 100644 index 00000000..4a887eda --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/AggregatingAssetPool.cs @@ -0,0 +1,14 @@ +using ImmichFrame.Core.Api; + +namespace ImmichFrame.Core.Logic.Pool; + +public abstract class AggregatingAssetPool : IAssetPool +{ + public abstract Task GetAssetCount(CancellationToken ct = default); + protected abstract Task GetNextAsset(CancellationToken ct); + + public Task> GetAssets(int requested, CancellationToken ct = default) + { + return IAssetPool.WaitAssets(requested, GetNextAsset, ct); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs new file mode 100644 index 00000000..c44d3b3c --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs @@ -0,0 +1,29 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Helpers; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Logic.Pool; + +public class AlbumAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) +{ + protected override async Task> LoadAssets(CancellationToken ct = default) + { + var excludedAlbumAssets = new List(); + + foreach (var albumId in accountSettings.ExcludedAlbums) + { + var albumInfo = await immichApi.GetAlbumInfoAsync(albumId, null, null, ct); + excludedAlbumAssets.AddRange(albumInfo.Assets); + } + + var albumAssets = new List(); + + foreach (var albumId in accountSettings.Albums) + { + var albumInfo = await immichApi.GetAlbumInfoAsync(albumId, null, null, ct); + albumAssets.AddRange(albumInfo.Assets); + } + + return albumAssets.WhereExcludes(excludedAlbumAssets, t => t.Id); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs new file mode 100644 index 00000000..9e3c0e63 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs @@ -0,0 +1,78 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Logic.Pool; + +public class AllAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool +{ + public async Task GetAssetCount(CancellationToken ct = default) + { + //Retrieve total images count (unfiltered); will update to query filtered stats from Immich + return (await apiCache.GetOrAddAsync(nameof(AllAssetsPool), + () => immichApi.GetAssetStatisticsAsync(null, false, null, ct))).Images; + } + + public async Task> GetAssets(int requested, CancellationToken ct = default) + { + var searchDto = new RandomSearchDto + { + Size = requested, + Type = AssetTypeEnum.IMAGE, + WithExif = true, + WithPeople = true + }; + + if (accountSettings.ShowArchived) + { + searchDto.Visibility = AssetVisibility.Archive; + } + else + { + searchDto.Visibility = AssetVisibility.Timeline; + } + + var takenBefore = accountSettings.ImagesUntilDate.HasValue ? accountSettings.ImagesUntilDate : null; + if (takenBefore.HasValue) + { + searchDto.TakenBefore = takenBefore; + } + var takenAfter = accountSettings.ImagesFromDate.HasValue ? accountSettings.ImagesFromDate : accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-accountSettings.ImagesFromDays.Value) : null; + + if (takenAfter.HasValue) + { + searchDto.TakenAfter = takenAfter; + } + + if (accountSettings.Rating is int rating) + { + searchDto.Rating = rating; + } + + var assets = await immichApi.SearchRandomAsync(searchDto, ct); + + if (accountSettings.ExcludedAlbums.Any()) + { + var excludedAssetList = await GetExcludedAlbumAssets(ct); + var excludedAssetSet = excludedAssetList.Select(x => x.Id).ToHashSet(); + assets = assets.Where(x => !excludedAssetSet.Contains(x.Id)).ToList(); + } + + return assets; + } + + + private async Task> GetExcludedAlbumAssets(CancellationToken ct = default) + { + var excludedAlbumAssets = new List(); + + foreach (var albumId in accountSettings.ExcludedAlbums) + { + var albumInfo = await immichApi.GetAlbumInfoAsync(albumId, null, null, ct); + + excludedAlbumAssets.AddRange(albumInfo.Assets); + } + + return excludedAlbumAssets; + } + +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs new file mode 100644 index 00000000..967bb1bb --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -0,0 +1,55 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Logic.Pool; + +public abstract class CachingApiAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool +{ + private readonly Random _random = new(); + + public async Task GetAssetCount(CancellationToken ct = default) + { + return (await AllAssets(ct)).Count(); + } + + public async Task> GetAssets(int requested, CancellationToken ct = default) + { + return (await AllAssets(ct)).OrderBy(_ => _random.Next()).Take(requested); + } + + private async Task> AllAssets(CancellationToken ct = default) + { + return await apiCache.GetOrAddAsync(GetType().FullName!, () => ApplyAccountFilters(LoadAssets(ct))); + } + + + protected async Task> ApplyAccountFilters(Task> unfiltered) + { + // Display only Images + var assets = (await unfiltered).Where(x => x.Type == AssetTypeEnum.IMAGE); + + if (!accountSettings.ShowArchived) + assets = assets.Where(x => x.IsArchived == false); + + var takenBefore = accountSettings.ImagesUntilDate.HasValue ? accountSettings.ImagesUntilDate : null; + if (takenBefore.HasValue) + { + assets = assets.Where(x => x.ExifInfo.DateTimeOriginal <= takenBefore); + } + + var takenAfter = accountSettings.ImagesFromDate.HasValue ? accountSettings.ImagesFromDate : accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-accountSettings.ImagesFromDays.Value) : null; + if (takenAfter.HasValue) + { + assets = assets.Where(x => x.ExifInfo.DateTimeOriginal >= takenAfter); + } + + if (accountSettings.Rating is int rating) + { + assets = assets.Where(x => x.ExifInfo.Rating == rating); + } + + return assets; + } + + 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 new file mode 100644 index 00000000..546d5f9b --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs @@ -0,0 +1,37 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Logic.Pool; + +public class FavoriteAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) +{ + protected override async Task> LoadAssets(CancellationToken ct = default) + { + var favoriteAssets = new List(); + + int page = 1; + int batchSize = 1000; + int total; + do + { + var metadataBody = new MetadataSearchDto + { + Page = page, + Size = batchSize, + IsFavorite = true, + Type = AssetTypeEnum.IMAGE, + WithExif = true, + WithPeople = true + }; + + var favoriteInfo = await immichApi.SearchAssetsAsync(metadataBody, ct); + + total = favoriteInfo.Assets.Total; + + favoriteAssets.AddRange(favoriteInfo.Assets.Items); + page++; + } while (total == batchSize); + + return favoriteAssets; + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/IAssetPool.cs b/ImmichFrame.Core/Logic/Pool/IAssetPool.cs new file mode 100644 index 00000000..4abe0912 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/IAssetPool.cs @@ -0,0 +1,36 @@ +using ImmichFrame.Core.Api; + +namespace ImmichFrame.Core.Logic.Pool; + +public interface IAssetPool +{ + Task GetAssetCount(CancellationToken ct = default); + Task> GetAssets(int requested, CancellationToken ct = default); + + protected static async Task> WaitAssets( + int requested, + Func> supplier, + CancellationToken? cancellationToken = null) + { + //allow up to one minute + var ct = cancellationToken ?? new CancellationTokenSource(TimeSpan.FromMinutes(1)).Token; + + var itemsRead = new List(requested > 0 ? requested : 0); + + for (var i = 0; i < requested; i++) + { + var asset = await supplier(ct); + + if (asset != null) + { + itemsRead.Add(asset); + } + else + { + break; + } + } + + return itemsRead; + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs new file mode 100644 index 00000000..6bdc64f6 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs @@ -0,0 +1,34 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Logic.Pool; + +public class MemoryAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) +{ + 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) + { + var assets = memory.Assets.ToList(); + var yearsAgo = DateTime.Now.Year - memory.Data.Year; + + foreach (var asset in assets) + { + if (asset.ExifInfo == null) + { + var assetInfo = await immichApi.GetAssetInfoAsync(new Guid(asset.Id), null, ct); + asset.ExifInfo = assetInfo.ExifInfo; + asset.People = assetInfo.People; + } + asset.ExifInfo.Description = $"{yearsAgo} {(yearsAgo == 1 ? "year" : "years")} ago"; + } + + memoryAssets.AddRange(assets); + } + + return memoryAssets; + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs b/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs new file mode 100644 index 00000000..84e9bff9 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs @@ -0,0 +1,19 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Helpers; + +namespace ImmichFrame.Core.Logic.Pool; + +public class MultiAssetPool(IEnumerable delegates) : AggregatingAssetPool +{ + public override async Task GetAssetCount(CancellationToken ct = default) + { + var counts = delegates.Select(pool => pool.GetAssetCount(ct)); + return (await Task.WhenAll(counts)).Sum(); + } + + protected override async Task GetNextAsset(CancellationToken ct) + { + var pool = await delegates.ChooseOne(async @delegate=> await @delegate.GetAssetCount(ct)); + return (await pool.GetAssets(1, ct)).FirstOrDefault(); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs new file mode 100644 index 00000000..12bd7ebc --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs @@ -0,0 +1,40 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Logic.Pool; + +public class PersonAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) +{ + protected override async Task> LoadAssets(CancellationToken ct = default) + { + var personAssets = new List(); + + foreach (var personId in accountSettings.People) + { + int page = 1; + int batchSize = 1000; + int total; + do + { + var metadataBody = new MetadataSearchDto + { + Page = page, + Size = batchSize, + PersonIds = [personId], + Type = AssetTypeEnum.IMAGE, + WithExif = true, + WithPeople = true + }; + + var personInfo = await immichApi.SearchAssetsAsync(metadataBody, ct); + + total = personInfo.Assets.Total; + + personAssets.AddRange(personInfo.Assets.Items); + page++; + } while (total == batchSize); + } + + return personAssets; + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs b/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs new file mode 100644 index 00000000..6265a8b6 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs @@ -0,0 +1,65 @@ +using System.Threading.Channels; +using ImmichFrame.Core.Api; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.Core.Logic.Pool; + +public class QueuingAssetPool(ILogger _logger, IAssetPool @delegate) : AggregatingAssetPool +{ + private const int RELOAD_BATCH_SIZE = 50; + private const int RELOAD_THRESHOLD = 10; + + private readonly SemaphoreSlim _isReloadingAssets = new(1, 1); + private Channel _assetQueue = Channel.CreateUnbounded(); + + public override Task GetAssetCount(CancellationToken ct = default) => @delegate.GetAssetCount(ct); + + + protected override async Task GetNextAsset(CancellationToken ct) + { + try + { + if (_assetQueue.Reader.Count <= RELOAD_THRESHOLD) + { + // Fire-and-forget, reloading assets in the background + _ = ReloadAssetsAsync(); + } + + return await _assetQueue.Reader.ReadAsync(ct); + } + catch (OperationCanceledException) + { + // This exception occurs if the CancellationTokenSource times out + _logger.LogWarning("Read asset list timed out"); + return null; + } + catch (Exception ex) + { + _logger.LogError($"An unexpected error occurred while reading assets: {ex.Message}"); + throw; + } + } + + private async Task ReloadAssetsAsync() + { + if (await _isReloadingAssets.WaitAsync(0)) + { + try + { + _logger.LogDebug("Reloading assets"); + foreach (var asset in await @delegate.GetAssets(RELOAD_BATCH_SIZE)) + { + await _assetQueue.Writer.WriteAsync(asset); + } + } + finally + { + _isReloadingAssets.Release(); + } + } + else + { + _logger.LogDebug("Assets already being loaded; not attempting a concurrent reload"); + } + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs new file mode 100644 index 00000000..428b4198 --- /dev/null +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -0,0 +1,130 @@ +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 : IImmichFrameLogic +{ + private readonly IGeneralSettings _generalSettings; + private readonly ApiCache _apiCache; + private readonly IAssetPool _pool; + private readonly ImmichApi _immichApi; + private readonly string _downloadLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ImageCache"); + + public PooledImmichFrameLogic(IAccountSettings accountSettings, IGeneralSettings generalSettings) + { + _generalSettings = generalSettings; + + var httpClient = new HttpClient(); + httpClient.UseApiKey(accountSettings.ApiKey); + _immichApi = new ImmichApi(accountSettings.ImmichServerUrl, httpClient); + _apiCache = new ApiCache(TimeSpan.FromHours(generalSettings.RefreshAlbumPeopleInterval)); + _pool = BuildPool(accountSettings); + } + + private IAssetPool BuildPool(IAccountSettings accountSettings) + { + if (!accountSettings.ShowFavorites && !accountSettings.ShowMemories && !accountSettings.Albums.Any() && !accountSettings.People.Any()) + { + return new AllAssetsPool(_apiCache, _immichApi, accountSettings); + } + + var pools = new List(); + + if (accountSettings.ShowFavorites) + pools.Add(new FavoriteAssetsPool(_apiCache, _immichApi, accountSettings)); + + if (accountSettings.ShowMemories) + pools.Add(new MemoryAssetsPool(_apiCache, _immichApi, accountSettings)); + + if (accountSettings.Albums.Any()) + pools.Add(new AlbumAssetsPool(_apiCache, _immichApi, accountSettings)); + + if (accountSettings.People.Any()) + pools.Add(new PersonAssetsPool(_apiCache, _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 _apiCache.GetOrAddAsync("GetAlbumInfoById", + () => _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); +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/SimpleAccountSelectionStrategy.cs b/ImmichFrame.Core/Logic/SimpleAccountSelectionStrategy.cs deleted file mode 100644 index 27fb6d46..00000000 --- a/ImmichFrame.Core/Logic/SimpleAccountSelectionStrategy.cs +++ /dev/null @@ -1,29 +0,0 @@ -using ImmichFrame.Core.Api; -using ImmichFrame.Core.Interfaces; - -namespace ImmichFrame.Core.Logic; - -public class SimpleAccountSelectionStrategy : IAccountSelectionStrategy -{ - private readonly Random _random = new(); - - public async Task<(IImmichFrameLogic, AssetResponseDto)?> GetNextAsset(IList accounts) - { - var account = accounts[_random.Next(accounts.Count)]; - AssetResponseDto? asset = await account.GetNextAsset(); // Await the task directly - - if (asset == null) - { - return null; - } - - return (account, asset); - } - - public async Task<(IImmichFrameLogic account, IEnumerable)[]> GetAssets(IList accounts) - { - var tasks = accounts.Select(async account => (account, await account.GetAssets())).ToList(); - - return await Task.WhenAll(tasks); - } -} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/TotalAccountImagesSelectionStrategy.cs b/ImmichFrame.Core/Logic/TotalAccountImagesSelectionStrategy.cs index a8301ca9..815dc763 100644 --- a/ImmichFrame.Core/Logic/TotalAccountImagesSelectionStrategy.cs +++ b/ImmichFrame.Core/Logic/TotalAccountImagesSelectionStrategy.cs @@ -4,46 +4,26 @@ namespace ImmichFrame.Core.Logic; -using Microsoft.Extensions.Caching.Memory; - public class TotalAccountImagesSelectionStrategy : IAccountSelectionStrategy { private readonly Random _random = new(); - private readonly IMemoryCache _accountToTotal = new MemoryCache(new MemoryCacheOptions()); - - private readonly MemoryCacheEntryOptions _memoryCacheEntryOptions = - new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromDays(1)); public async Task<(IImmichFrameLogic, AssetResponseDto)?> GetNextAsset(IList accounts) { - var (weights, sum) = await GetWeights(accounts); - var randomNumber = _random.Next(sum); - var selectedIndex = accounts.Count - 1; + var chosen = await accounts.ChooseOne(logic => logic.GetTotalAssets()); - for (var i = 0; i < accounts.Count; i++) - { - randomNumber -= weights[i]; - if (randomNumber <= 0) - { - selectedIndex = i; - break; - } - } - - var asset = await accounts[selectedIndex].GetNextAsset(); + var asset = await chosen.GetNextAsset(); if (asset != null) { - return (accounts[selectedIndex], asset); - } - else - { - return null; + return (chosen, asset); } + + return null; } - private async Task<(IList, int)> GetWeights(IList accounts) + private async Task<(IList, long)> GetWeights(IList accounts) { - var weights = await Task.WhenAll(accounts.Select(a => GetTotalForAccount(a))); + var weights = await Task.WhenAll(accounts.Select(GetTotalForAccount)); return (weights, weights.Sum()); } @@ -53,10 +33,9 @@ private async Task> GetProportions(IList accoun return totals.Select(t => (double)t / sum).ToList(); } - private Task GetTotalForAccount(IImmichFrameLogic account) + private Task GetTotalForAccount(IImmichFrameLogic account) { - return _accountToTotal.GetOrCreateAsync(account, async entry => (await account.GetAssetStats()).Images, - _memoryCacheEntryOptions); + return account.GetTotalAssets(); } public async Task<(IImmichFrameLogic account, IEnumerable)[]> GetAssets( diff --git a/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs b/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs index 444b6c9a..65dd012e 100644 --- a/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs +++ b/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs @@ -1,7 +1,5 @@ using System.Collections; using System.Reflection; -using System.Runtime.InteropServices.JavaScript; -using System.Text.Json; using ImmichFrame.Core.Interfaces; using ImmichFrame.WebApi.Helpers; using ImmichFrame.WebApi.Helpers.Config; diff --git a/ImmichFrame.WebApi/Helpers/ImmichFrameAuthenticationHandler.cs b/ImmichFrame.WebApi/Helpers/ImmichFrameAuthenticationHandler.cs index 098d2d4f..6a391fd7 100644 --- a/ImmichFrame.WebApi/Helpers/ImmichFrameAuthenticationHandler.cs +++ b/ImmichFrame.WebApi/Helpers/ImmichFrameAuthenticationHandler.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Options; using System.Security.Claims; using System.Text.Encodings.Web; -using System.Threading.Tasks; public class ImmichFrameAuthenticationHandler : AuthenticationHandler { @@ -18,7 +17,7 @@ public ImmichFrameAuthenticationHandler( IServerSettings settings) : base(options, logger, encoder) { - this._authenticationSecret = settings.GeneralSettings.AuthenticationSecret; + _authenticationSecret = settings.GeneralSettings.AuthenticationSecret; } protected override Task HandleAuthenticateAsync() diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index a1d49e39..f122f6c4 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -57,7 +57,7 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddTransient>(srv => account => ActivatorUtilities.CreateInstance(srv, account)); +builder.Services.AddTransient>(srv => account => ActivatorUtilities.CreateInstance(srv, account)); builder.Services.AddSingleton(); builder.Services.AddControllers(); diff --git a/ImmichFrame.sln b/ImmichFrame.sln index 19564a17..0f75f8c7 100644 --- a/ImmichFrame.sln +++ b/ImmichFrame.sln @@ -8,6 +8,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImmichFrame.WebApi", "Immic EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImmichFrame.WebApi.Tests", "ImmichFrame.WebApi.Tests\ImmichFrame.WebApi.Tests.csproj", "{DA9040F2-F048-4A31-909A-086C37056306}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImmichFrame.Core.Tests", "ImmichFrame.Core.Tests\ImmichFrame.Core.Tests.csproj", "{2A673E6B-CCDD-4E1C-92FA-59952990D8D7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,6 +28,10 @@ Global {DA9040F2-F048-4A31-909A-086C37056306}.Debug|Any CPU.Build.0 = Debug|Any CPU {DA9040F2-F048-4A31-909A-086C37056306}.Release|Any CPU.ActiveCfg = Release|Any CPU {DA9040F2-F048-4A31-909A-086C37056306}.Release|Any CPU.Build.0 = Release|Any CPU + {2A673E6B-CCDD-4E1C-92FA-59952990D8D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A673E6B-CCDD-4E1C-92FA-59952990D8D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A673E6B-CCDD-4E1C-92FA-59952990D8D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A673E6B-CCDD-4E1C-92FA-59952990D8D7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MultiAssetPoolTests.cs b/MultiAssetPoolTests.cs new file mode 100644 index 00000000..4759592e --- /dev/null +++ b/MultiAssetPoolTests.cs @@ -0,0 +1,526 @@ +using NUnit.Framework; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; + +// Assuming the classes are in a namespace, adjust if necessary +// namespace YourNamespace.Tests; + +[TestFixture] +public class MultiAssetPoolTests +{ + private Mock> _mockPool1; + private Mock> _mockPool2; + private Mock> _mockPool3; + // MultiAssetPool and its dependencies (like IAssetPool and WeightedPool) + // need to be defined or imported. + // For now, I'll assume they exist and can be instantiated. + // If not, this will be a placeholder for their actual instantiation. + private MultiAssetPool _multiPool; + + // Placeholder for IAssetPool if not defined elsewhere + public interface IAssetPool + { + int GetAssetCount(); + IEnumerable GetAssets(int count); + T GetNextAsset(); // Assuming GetNextAsset exists for IAssetPool + } + + // Placeholder for WeightedPool, assuming it's a simple container for a pool and its weight + public class WeightedPool + { + public IAssetPool Pool { get; set; } + public int Weight { get; set; } + public int AssetsTaken { get; set; } // For GetNextAsset logic + } + + // Placeholder for MultiAssetPool + public class MultiAssetPool + { + private readonly List> _weightedPools; + private int _totalWeight; + + public MultiAssetPool(List> weightedPools) + { + _weightedPools = weightedPools ?? new List>(); + _totalWeight = _weightedPools.Sum(wp => wp.Weight); + } + + public int GetAssetCount() + { + return _weightedPools.Sum(wp => wp.Pool.GetAssetCount()); + } + + public IEnumerable GetAssets(int count) + { + if (count == 0) return Enumerable.Empty(); + if (_totalWeight == 0) return Enumerable.Empty(); // No assets if total weight is 0 + + var assets = new List(); + var remainingAssetsToFetch = count; + + // Distribute requests based on weights + foreach (var wp in _weightedPools.OrderByDescending(p => p.Weight)) // Prioritize by weight for simplicity here + { + if (remainingAssetsToFetch == 0) break; + if (wp.Weight == 0) continue; // Skip pools with 0 weight + + // Naive distribution: Proportional to weight. + // A more sophisticated approach might be needed for perfect distribution, + // especially with small numbers. + var assetsToFetchFromPool = (int)Math.Ceiling((double)count * wp.Weight / _totalWeight); + if(assetsToFetchFromPool == 0 && count > 0 && wp.Weight > 0) assetsToFetchFromPool = 1; // Ensure at least one if possible + assetsToFetchFromPool = Math.Min(assetsToFetchFromPool, remainingAssetsToFetch); + + + var poolAssets = wp.Pool.GetAssets(assetsToFetchFromPool); + if (poolAssets != null) + { + var actualFetchedCount = poolAssets.Count(); + assets.AddRange(poolAssets); + remainingAssetsToFetch -= actualFetchedCount; + } + } + return assets.Take(count); // Ensure we don't return more than requested due to ceiling/min logic + } + + public T GetNextAsset() + { + if (_weightedPools == null || !_weightedPools.Any(wp => wp.Weight > 0 && wp.Pool.GetAssetCount() > wp.AssetsTaken)) + { + return default(T); // No assets available or all weighted pools are exhausted + } + + WeightedPool selectedPool = null; + double minRatio = double.MaxValue; + + // Select pool based on who has taken the least proportion of their "share" + // This aims to distribute GetNextAsset calls according to weights over time. + foreach (var wp in _weightedPools.Where(p => p.Weight > 0)) + { + // Check if pool has assets available (simplified check, relies on GetNextAsset of underlying pool to be null if exhausted) + // A more robust check might involve peeking or checking GetAssetCount against AssetsTaken. + // For this placeholder, we'll assume GetNextAsset handles its own exhaustion. + + double currentRatio = (double)wp.AssetsTaken / wp.Weight; + if (currentRatio < minRatio) + { + // Temporarily try to get an asset to see if it's not exhausted + // This is not ideal. A better IAssetPool would have an IsExhausted or TryGetNextAsset + var tempAsset = wp.Pool.GetNextAsset(); + if (tempAsset != null) + { + minRatio = currentRatio; + selectedPool = wp; + // "Return" the asset - this is tricky. The actual GetNextAsset should be called once. + // This placeholder logic for GetNextAsset is becoming complex due to IAssetPool limitations. + // For now, we'll assume this selection logic is good enough and the actual GetNextAsset call follows. + // To "undo" the GetNextAsset, we'd need a way to put it back or the pool to allow peeking. + // Let's simplify: we'll call GetNextAsset on the selected pool. If it's null, try another. + } + } + } + + // The above selection is flawed. Let's retry a simpler round-robin weighted approach for GetNextAsset. + // This is a common challenge with GetNextAsset on an aggregator. + // A better MultiAssetPool would maintain its own cursors or a more complex selection strategy. + + // Simplified strategy for placeholder: Iterate through pools, respecting weights loosely. + // This won't be perfectly weight-distributed for individual calls but aims for it over time. + // It will also require the underlying pools to correctly return null when exhausted. + + // Let's reset and try a GetNextAsset logic that is more testable with current IAssetPool + // It will try to pick a pool based on weights. This is a simplified heuristic. + + _weightedPools.RemoveAll(wp => wp.Pool == null); // Clean up any null pools + if(!_weightedPools.Any(wp => wp.Weight > 0)) return default(T); + + + // Try to pick a pool for GetNextAsset. This is a common difficult pattern. + // We will cycle through pools, giving more "chances" to higher weighted pools. + // This is a simplified simulation of weighted round-robin. + // It assumes GetNextAsset on the child pool will return null if exhausted. + + // For this placeholder, let's use a very simple GetNextAsset: + // Cycle through pools, if a pool has an asset, return it. + // This doesn't respect weights for GetNextAsset directly in this simplified form. + // A proper implementation would be more involved. + // Given the constraints, GetNextAsset in MultiAssetPool might be better designed + // to not exist, or to have different semantics (e.g. GetNextBatchByWeight). + + // Sticking to the interface: + // The GetNextAsset logic here is a placeholder and might not perfectly reflect weights + // on a per-call basis without more complex state management. + // It will iterate through pools and try to get the next asset. + // If a pool is exhausted, it moves to the next. + // This doesn't use weights for GetNextAsset in this simplified version. + + foreach (var wp in _weightedPools) // Simple iteration for now + { + if (wp.Weight > 0) + { + var asset = wp.Pool.GetNextAsset(); + if (asset != null) + { + wp.AssetsTaken++; // Track for GetAssets, though not used by this GetNextAsset + return asset; + } + } + } + return default(T); // All pools exhausted or no weighted pools + } + } + + + [SetUp] + public void Setup() + { + _mockPool1 = new Mock>(); + _mockPool2 = new Mock>(); + _mockPool3 = new Mock>(); + } + + [Test] + public void GetAssetCount_NoPools_ReturnsZero() + { + _multiPool = new MultiAssetPool(new List>()); + Assert.AreEqual(0, _multiPool.GetAssetCount()); + } + + [Test] + public void GetAssetCount_OnePool_ReturnsCorrectCount() + { + _mockPool1.Setup(p => p.GetAssetCount()).Returns(5); + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1 } + }; + _multiPool = new MultiAssetPool(weightedPools); + Assert.AreEqual(5, _multiPool.GetAssetCount()); + } + + [Test] + public void GetAssetCount_MultiplePools_ReturnsSumOfCounts() + { + _mockPool1.Setup(p => p.GetAssetCount()).Returns(5); + _mockPool2.Setup(p => p.GetAssetCount()).Returns(10); + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1 }, + new WeightedPool { Pool = _mockPool2.Object, Weight = 1 } + }; + _multiPool = new MultiAssetPool(weightedPools); + Assert.AreEqual(15, _multiPool.GetAssetCount()); + } + + [Test] + public void GetAssets_RequestZeroAssets_ReturnsEmptyCollection() + { + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1 } + }; + _multiPool = new MultiAssetPool(weightedPools); + var result = _multiPool.GetAssets(0); + Assert.IsEmpty(result); + } + + [Test] + public void GetAssets_TotalLessThanRequested_ReturnsAllAvailableAssets() + { + var assets1 = new List { new object(), new object() }; + _mockPool1.Setup(p => p.GetAssetCount()).Returns(assets1.Count); + _mockPool1.Setup(p => p.GetAssets(It.IsAny())).Returns(count => assets1.Take(Math.Min(count, assets1.Count)).ToList()); + + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1 } + }; + _multiPool = new MultiAssetPool(weightedPools); + + var result = _multiPool.GetAssets(5); // Request 5, but only 2 available + Assert.AreEqual(2, result.Count()); + Assert.IsTrue(result.SequenceEqual(assets1)); + } + + [Test] + public void GetAssets_TotalMoreThanRequested_AggregatesByWeight() + { + var assets1 = new List { new object(), new object(), new object() }; // Pool 1 has 3 assets + var assets2 = new List { new object(), new object(), new object() }; // Pool 2 has 3 assets + + _mockPool1.Setup(p => p.GetAssetCount()).Returns(assets1.Count); + _mockPool1.Setup(p => p.GetAssets(It.IsAny())).Returns(count => assets1.Take(Math.Min(count, assets1.Count)).ToList()); + + _mockPool2.Setup(p => p.GetAssetCount()).Returns(assets2.Count); + _mockPool2.Setup(p => p.GetAssets(It.IsAny())).Returns(count => assets2.Take(Math.Min(count, assets2.Count)).ToList()); + + // Pool1 weight 2, Pool2 weight 1. Total weight 3. + // Request 3 assets: Pool1 should provide 2, Pool2 should provide 1. + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 2 }, + new WeightedPool { Pool = _mockPool2.Object, Weight = 1 } + }; + _multiPool = new MultiAssetPool(weightedPools); + var result = _multiPool.GetAssets(3).ToList(); + + Assert.AreEqual(3, result.Count); + // Check how many came from each pool (this depends on the GetAssets implementation detail) + // The current placeholder GetAssets prioritizes higher weights first. + _mockPool1.Verify(p => p.GetAssets(It.Is(c => c == 2 || c == 3)), Times.AtLeastOnce()); // Approx 3 * (2/3) = 2 + _mockPool2.Verify(p => p.GetAssets(It.Is(c => c == 1 || c == 2)), Times.AtLeastOnce()); // Approx 3 * (1/3) = 1 + + // Verify actual assets based on a predictable fetch order if possible + // The placeholder GetAssets sorts by weight then takes proportionally. + // So Pool1 (weight 2) will be asked first. + // For 3 assets: Pool1 asked for ceil(3*2/3) = 2. Pool2 asked for ceil(3*1/3)=1 (or remaining) + var resultFromPool1 = result.Intersect(assets1).Count(); + var resultFromPool2 = result.Intersect(assets2).Count(); + + Assert.AreEqual(2, resultFromPool1, "Should take 2 from Pool1"); + Assert.AreEqual(1, resultFromPool2, "Should take 1 from Pool2"); + } + + + [Test] + public void GetAssets_PoolReturnsFewerAssetsThanCountSuggests_HandlesGracefully() + { + var assets1 = new List { new object(), new object() }; // Pool1 actually returns 2 + _mockPool1.Setup(p => p.GetAssetCount()).Returns(5); // Reports 5 assets + _mockPool1.Setup(p => p.GetAssets(It.IsAny())).Returns(count => assets1.Take(Math.Min(count,assets1.Count)).ToList()); + + var assets2 = new List { new object() }; // Pool2 has 1 asset + _mockPool2.Setup(p => p.GetAssetCount()).Returns(1); + _mockPool2.Setup(p => p.GetAssets(It.IsAny())).Returns(count => assets2.Take(Math.Min(count,assets2.Count)).ToList()); + + + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1 }, + new WeightedPool { Pool = _mockPool2.Object, Weight = 1 } + }; + _multiPool = new MultiAssetPool(weightedPools); + + // Request 5. Pool1 reports 5 but gives 2. Pool2 reports 1 and gives 1. Total 3. + var result = _multiPool.GetAssets(5); + Assert.AreEqual(3, result.Count()); + } + + [Test] + public void GetAssets_DifferentWeightDistributions() + { + var assets1 = Enumerable.Range(0, 10).Select(i => new object()).ToList(); // Pool 1 has 10 + var assets2 = Enumerable.Range(0, 10).Select(i => new object()).ToList(); // Pool 2 has 10 + + _mockPool1.Setup(p => p.GetAssetCount()).Returns(assets1.Count); + _mockPool1.Setup(p => p.GetAssets(It.IsAny())).Returns(count => assets1.Take(Math.Min(count, assets1.Count)).ToList()); + + _mockPool2.Setup(p => p.GetAssetCount()).Returns(assets2.Count); + _mockPool2.Setup(p => p.GetAssets(It.IsAny())).Returns(count => assets2.Take(Math.Min(count, assets2.Count)).ToList()); + + // Distribution 1: Pool1 weight 9, Pool2 weight 1. Total weight 10. + // Request 10 assets: Pool1 should provide 9, Pool2 should provide 1. + var weightedPools1 = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 9 }, + new WeightedPool { Pool = _mockPool2.Object, Weight = 1 } + }; + _multiPool = new MultiAssetPool(weightedPools1); + var result1 = _multiPool.GetAssets(10).ToList(); + + Assert.AreEqual(10, result1.Count); + // Based on placeholder logic (sorts by weight desc, then proportional) + // Pool1 (weight 9) asked for ceil(10*9/10) = 9. + // Pool2 (weight 1) asked for ceil(10*1/10) = 1 (or remaining from 10 items). + _mockPool1.Verify(p => p.GetAssets(9), Times.Once()); + _mockPool2.Verify(p => p.GetAssets(1), Times.Once()); + + var result1Pool1 = result1.Intersect(assets1).Count(); + var result1Pool2 = result1.Intersect(assets2).Count(); + Assert.AreEqual(9, result1Pool1); + Assert.AreEqual(1, result1Pool2); + + // Reset mocks for next verification + _mockPool1.Invocations.Clear(); + _mockPool2.Invocations.Clear(); + + // Distribution 2: Pool1 weight 1, Pool2 weight 9. Total weight 10. + // Request 10 assets: Pool1 should provide 1, Pool2 should provide 9. + var weightedPools2 = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1 }, // Lower weight now + new WeightedPool { Pool = _mockPool2.Object, Weight = 9 } // Higher weight now + }; + _multiPool = new MultiAssetPool(weightedPools2); + var result2 = _multiPool.GetAssets(10).ToList(); + Assert.AreEqual(10, result2.Count); + + // Pool2 (weight 9) asked for ceil(10*9/10) = 9. + // Pool1 (weight 1) asked for ceil(10*1/10) = 1. + _mockPool2.Verify(p => p.GetAssets(9), Times.Once()); + _mockPool1.Verify(p => p.GetAssets(1), Times.Once()); + + var result2Pool1 = result2.Intersect(assets1).Count(); + var result2Pool2 = result2.Intersect(assets2).Count(); + Assert.AreEqual(1, result2Pool1); + Assert.AreEqual(9, result2Pool2); + } + + [Test] + public void GetAssets_PoolWithZeroWeight_IsNotCalled() + { + var assets1 = new List { new object() }; + _mockPool1.Setup(p => p.GetAssetCount()).Returns(1); + _mockPool1.Setup(p => p.GetAssets(It.IsAny())).Returns(assets1); + + _mockPool2.Setup(p => p.GetAssetCount()).Returns(1); + _mockPool2.Setup(p => p.GetAssets(It.IsAny())).Returns(new List { new object() }); + + + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1 }, + new WeightedPool { Pool = _mockPool2.Object, Weight = 0 } // Pool2 has 0 weight + }; + _multiPool = new MultiAssetPool(weightedPools); + var result = _multiPool.GetAssets(1); + + Assert.AreEqual(1, result.Count()); + Assert.AreSame(assets1.First(), result.First()); + _mockPool1.Verify(p => p.GetAssets(It.IsAny()), Times.Once()); + _mockPool2.Verify(p => p.GetAssets(It.IsAny()), Times.Never()); + } + + [Test] + public void GetNextAsset_NoAssetsInAnyPool_ReturnsDefault() + { + _mockPool1.Setup(p => p.GetNextAsset()).Returns(default(object)); + _mockPool2.Setup(p => p.GetNextAsset()).Returns(default(object)); + + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1 }, + new WeightedPool { Pool = _mockPool2.Object, Weight = 1 } + }; + _multiPool = new MultiAssetPool(weightedPools); + + Assert.IsNull(_multiPool.GetNextAsset()); + } + + [Test] + public void GetNextAsset_RetrievesAssetsSequentiallyAccordingToPoolOrderAndAvailability() + { + // This test reflects the simplified GetNextAsset placeholder which iterates pools. + var asset1_1 = new object(); + var asset1_2 = new object(); + var asset2_1 = new object(); + + _mockPool1.SetupSequence(p => p.GetNextAsset()) + .Returns(asset1_1) + .Returns(asset1_2) + .Returns(default(object)); // Pool 1 exhausted + + _mockPool2.SetupSequence(p => p.GetNextAsset()) + .Returns(asset2_1) + .Returns(default(object)); // Pool 2 exhausted + + var weightedPools = new List> + { + // Order matters for the simple GetNextAsset implementation + new WeightedPool { Pool = _mockPool1.Object, Weight = 1, AssetsTaken = 0 }, + new WeightedPool { Pool = _mockPool2.Object, Weight = 2, AssetsTaken = 0 } // Weight difference won't matter for this simple GetNextAsset + }; + _multiPool = new MultiAssetPool(weightedPools); + + Assert.AreSame(asset1_1, _multiPool.GetNextAsset(), "Asset from Pool 1, Call 1"); + Assert.AreSame(asset1_2, _multiPool.GetNextAsset(), "Asset from Pool 1, Call 2"); + Assert.AreSame(asset2_1, _multiPool.GetNextAsset(), "Asset from Pool 2, Call 1 (Pool 1 exhausted)"); + Assert.IsNull(_multiPool.GetNextAsset(), "All pools exhausted"); + } + + [Test] + public void GetNextAsset_PoolWithZeroWeight_IsNotCalledForGetNextAsset() + { + var asset1 = new object(); + _mockPool1.Setup(p => p.GetNextAsset()).Returns(asset1); // Pool1 has an asset + _mockPool2.Setup(p => p.GetNextAsset()).Returns(new object()); // Pool2 has an asset but 0 weight + + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1, AssetsTaken = 0 }, + new WeightedPool { Pool = _mockPool2.Object, Weight = 0, AssetsTaken = 0 } // Pool2 has 0 weight + }; + _multiPool = new MultiAssetPool(weightedPools); + + var result = _multiPool.GetNextAsset(); + Assert.AreSame(asset1, result); + _mockPool1.Verify(p => p.GetNextAsset(), Times.Once()); + _mockPool2.Verify(p => p.GetNextAsset(), Times.Never()); + + // Check that subsequent calls also ignore the zero-weight pool + _mockPool1.Setup(p => p.GetNextAsset()).Returns(default(object)); // Pool 1 now exhausted + Assert.IsNull(_multiPool.GetNextAsset(), "Pool1 exhausted, Pool2 zero weight, should be null"); + _mockPool1.Verify(p => p.GetNextAsset(), Times.Exactly(2)); // Called again + _mockPool2.Verify(p => p.GetNextAsset(), Times.Never()); // Still never called + } + + [Test] + public void GetNextAsset_PoolIsExhausted_SwitchesToNextAvailablePool() + { + var asset2 = new object(); + var asset3 = new object(); + + _mockPool1.Setup(p => p.GetNextAsset()).Returns(default(object)); // Pool 1 is initially exhausted + _mockPool2.Setup(p => p.GetNextAsset()).Returns(asset2); + _mockPool3.Setup(p => p.GetNextAsset()).Returns(asset3); + + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1, AssetsTaken = 0 }, + new WeightedPool { Pool = _mockPool2.Object, Weight = 1, AssetsTaken = 0 }, + new WeightedPool { Pool = _mockPool3.Object, Weight = 1, AssetsTaken = 0 } + }; + _multiPool = new MultiAssetPool(weightedPools); + + // According to simple iteration: Pool1 (exhausted) -> Pool2 + Assert.AreSame(asset2, _multiPool.GetNextAsset(), "Should get asset from Pool 2 as Pool 1 is exhausted"); + _mockPool1.Verify(p => p.GetNextAsset(), Times.Once()); + _mockPool2.Verify(p => p.GetNextAsset(), Times.Once()); + _mockPool3.Verify(p => p.GetNextAsset(), Times.Never()); + + + _mockPool2.Setup(p => p.GetNextAsset()).Returns(default(object)); // Pool 2 now also exhausted + // According to simple iteration: Pool1 (exhausted) -> Pool2 (exhausted) -> Pool3 + Assert.AreSame(asset3, _multiPool.GetNextAsset(), "Should get asset from Pool 3 as Pool 1 & 2 are exhausted"); + _mockPool1.Verify(p => p.GetNextAsset(), Times.Exactly(2)); // Called again in the next GetNextAsset iteration + _mockPool2.Verify(p => p.GetNextAsset(), Times.Exactly(2)); // Called again + _mockPool3.Verify(p => p.GetNextAsset(), Times.Once()); + } + + [Test] + public void GetNextAsset_AllPoolsBecomeExhausted_ReturnsDefault() + { + var asset1 = new object(); + _mockPool1.SetupSequence(p => p.GetNextAsset()) + .Returns(asset1) + .Returns(default(object)); // Pool 1 exhausted after one asset + + _mockPool2.Setup(p => p.GetNextAsset()).Returns(default(object)); // Pool 2 is initially exhausted + + var weightedPools = new List> + { + new WeightedPool { Pool = _mockPool1.Object, Weight = 1, AssetsTaken = 0 }, + new WeightedPool { Pool = _mockPool2.Object, Weight = 1, AssetsTaken = 0 } + }; + _multiPool = new MultiAssetPool(weightedPools); + + Assert.AreSame(asset1, _multiPool.GetNextAsset(), "Retrieve the only asset from Pool 1"); + Assert.IsNull(_multiPool.GetNextAsset(), "Pool 1 now exhausted, Pool 2 was already exhausted"); + Assert.IsNull(_multiPool.GetNextAsset(), "Calling again when all exhausted should still return null"); + + _mockPool1.Verify(p => p.GetNextAsset(), Times.Exactly(2)); // Initial + one more time when checking after exhaustion + _mockPool2.Verify(p => p.GetNextAsset(), Times.Exactly(2)); // Checked twice as well + } +} From 46895709a00e0069fd5c91333c0a7a482567c6e8 Mon Sep 17 00:00:00 2001 From: Jonathan Gilbert Date: Sun, 15 Jun 2025 17:31:36 +1000 Subject: [PATCH 2/7] feat: favorites are not longer preloaded This will only work if Immich supports it, if not it will fallback to the old method Also refactors code to disambiguate preloading versus not pools --- .../AlbumAssetsPreloadPoolTests.cs} | 7 +- .../FavoriteAssetsPreloadPoolTests.cs} | 7 +- .../MemoryAssetsPreloadPoolTests.cs} | 11 +- .../{ => Preload}/PersonAssetsPoolTests.cs | 5 +- .../PreloadedAssetsPoolTests.cs} | 11 +- .../AllAssetsRemotePoolTests.cs} | 11 +- ImmichFrame.Core/Logic/AssetPoolFactory.cs | 41 + .../Logic/Pool/FavoriteAssetsPool.cs | 37 - .../Logic/Pool/PeopleAssetsPool.cs | 40 - .../AlbumAssetsPreloadPool.cs} | 4 +- .../Pool/Preload/FavoriteAssetsPreloadPool.cs | 15 + .../MemoryAssetsPreloadPool.cs} | 4 +- .../Pool/Preload/PeopleAssetsPreloadPool.cs | 24 + .../PreloadedAssetsPool.cs} | 36 +- .../AllAssetsRemotePool.cs} | 6 +- .../Pool/Remote/FavoriteAssetsRemotePool.cs | 40 + .../Logic/PooledImmichFrameLogic.cs | 29 +- .../OpenAPIs/immich-openapi-specs.json | 820 +++++++++++++++++- ImmichFrame.WebApi/Program.cs | 1 + 19 files changed, 962 insertions(+), 187 deletions(-) rename ImmichFrame.Core.Tests/Logic/Pool/{AlbumAssetsPoolTests.cs => Preload/AlbumAssetsPreloadPoolTests.cs} (95%) rename ImmichFrame.Core.Tests/Logic/Pool/{FavoriteAssetsPoolTests.cs => Preload/FavoriteAssetsPreloadPoolTests.cs} (94%) rename ImmichFrame.Core.Tests/Logic/Pool/{MemoryAssetsPoolTests.cs => Preload/MemoryAssetsPreloadPoolTests.cs} (95%) rename ImmichFrame.Core.Tests/Logic/Pool/{ => Preload}/PersonAssetsPoolTests.cs (96%) rename ImmichFrame.Core.Tests/Logic/Pool/{CachingApiAssetsPoolTests.cs => Preload/PreloadedAssetsPoolTests.cs} (96%) rename ImmichFrame.Core.Tests/Logic/Pool/{AllAssetsPoolTests.cs => Remote/AllAssetsRemotePoolTests.cs} (93%) create mode 100644 ImmichFrame.Core/Logic/AssetPoolFactory.cs delete mode 100644 ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs delete mode 100644 ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs rename ImmichFrame.Core/Logic/Pool/{AlbumAssetsPool.cs => Preload/AlbumAssetsPreloadPool.cs} (79%) create mode 100644 ImmichFrame.Core/Logic/Pool/Preload/FavoriteAssetsPreloadPool.cs rename ImmichFrame.Core/Logic/Pool/{MemoryAssetsPool.cs => Preload/MemoryAssetsPreloadPool.cs} (82%) create mode 100644 ImmichFrame.Core/Logic/Pool/Preload/PeopleAssetsPreloadPool.cs rename ImmichFrame.Core/Logic/Pool/{CachingApiAssetsPool.cs => Preload/PreloadedAssetsPool.cs} (69%) rename ImmichFrame.Core/Logic/Pool/{AllAssetsPool.cs => Remote/AllAssetsRemotePool.cs} (90%) create mode 100644 ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs similarity index 95% rename from ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs rename to ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs index ad9c96c4..c1c20150 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs @@ -8,11 +8,12 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using ImmichFrame.Core.Logic.Pool.Preload; -namespace ImmichFrame.Core.Tests.Logic.Pool; +namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; [TestFixture] -public class AlbumAssetsPoolTests +public class AlbumAssetsPreloadPoolTests { private Mock _mockApiCache; private Mock _mockImmichApi; @@ -20,7 +21,7 @@ public class AlbumAssetsPoolTests private TestableAlbumAssetsPool _albumAssetsPool; private class TestableAlbumAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) - : AlbumAssetsPool(apiCache, immichApi, accountSettings) + : AlbumAssetsPreloadPool(apiCache, immichApi, accountSettings) { // Expose LoadAssets for testing public Task> TestLoadAssets(CancellationToken ct = default) => base.LoadAssets(ct); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs similarity index 94% rename from ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs rename to ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs index 8a58657a..7d8c9a4c 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs @@ -8,18 +8,19 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using ImmichFrame.Core.Logic.Pool.Preload; -namespace ImmichFrame.Core.Tests.Logic.Pool; +namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; [TestFixture] -public class FavoriteAssetsPoolTests +public class FavoriteAssetsPreloadPoolTests { private Mock _mockApiCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; // Though not directly used by LoadAssets here private TestableFavoriteAssetsPool _favoriteAssetsPool; - private class TestableFavoriteAssetsPool : FavoriteAssetsPool + private class TestableFavoriteAssetsPool : FavoriteAssetsPreloadPool { public TestableFavoriteAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : base(apiCache, immichApi, accountSettings) { } diff --git a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs similarity index 95% rename from ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs rename to ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs index b90c89f5..bb56ac75 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs @@ -8,16 +8,17 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using ImmichFrame.Core.Logic.Pool.Preload; -namespace ImmichFrame.Core.Tests.Logic.Pool; +namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; [TestFixture] -public class MemoryAssetsPoolTests +public class MemoryAssetsPreloadPoolTests { private Mock _mockApiCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; - private MemoryAssetsPool _memoryAssetsPool; + private MemoryAssetsPreloadPool _memoryAssetsPool; [SetUp] public void Setup() @@ -26,7 +27,7 @@ public void Setup() _mockImmichApi = new Mock(null, null); // Base constructor requires ILogger, IHttpClientFactory, IOptions, pass null _mockAccountSettings = new Mock(); - _memoryAssetsPool = new MemoryAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + _memoryAssetsPool = new MemoryAssetsPreloadPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); } private List CreateSampleAssets(int count, bool withExif, int yearCreated) @@ -158,7 +159,7 @@ public async Task LoadAssets_CorrectlyFormatsDescription_YearsAgo() _mockApiCache = new Mock(null); _mockApiCache.Setup(c => c.GetOrAddAsync>(It.IsAny(), It.IsAny>>>())) .Returns>>>(async (key, factory) => await factory()); - _memoryAssetsPool = new MemoryAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + _memoryAssetsPool = new MemoryAssetsPreloadPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); // Act diff --git a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs similarity index 96% rename from ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs rename to ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs index 9065212b..5063d5da 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs @@ -8,18 +8,19 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using ImmichFrame.Core.Logic.Pool.Preload; namespace ImmichFrame.Core.Tests.Logic.Pool; [TestFixture] -public class PersonAssetsPoolTests // Renamed from PeopleAssetsPoolTests to match class name +public class PersonAssetsPreloadPoolTests // Renamed from PeopleAssetsPoolTests to match class name { private Mock _mockApiCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; private TestablePersonAssetsPool _personAssetsPool; - private class TestablePersonAssetsPool : PersonAssetsPool + private class TestablePersonAssetsPool : PersonAssetsPreloadPool { public TestablePersonAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : base(apiCache, immichApi, accountSettings) { } diff --git a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs similarity index 96% rename from ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs rename to ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs index 5839693f..47fe503a 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs @@ -8,23 +8,24 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using ImmichFrame.Core.Logic.Pool.Preload; namespace ImmichFrame.Core.Tests.Logic.Pool; [TestFixture] -public class CachingApiAssetsPoolTests +public class PreloadedAssetsPoolTests { private Mock _mockApiCache; private Mock _mockImmichApi; // Dependency for constructor, may not be used directly in base class tests private Mock _mockAccountSettings; - private TestableCachingApiAssetsPool _testPool; + private TestablePreloadedAssetsPool _testPool; // Concrete implementation for testing the abstract class - private class TestableCachingApiAssetsPool : CachingApiAssetsPool + private class TestablePreloadedAssetsPool : PreloadedAssetsPool { public Func>> LoadAssetsFunc { get; set; } - public TestableCachingApiAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + public TestablePreloadedAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : base(apiCache, immichApi, accountSettings) { } @@ -42,7 +43,7 @@ public void Setup() _mockImmichApi = new Mock(null, null); // ILogger, IHttpClientFactory, IOptions _mockAccountSettings = new Mock(); - _testPool = new TestableCachingApiAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + _testPool = new TestablePreloadedAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); // Default setup for ApiCache to execute the factory function _mockApiCache.Setup(c => c.GetOrAddAsync( diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs similarity index 93% rename from ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs rename to ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs index 548d0570..8dd8959f 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs @@ -8,16 +8,17 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using ImmichFrame.Core.Logic.Pool.Preload; -namespace ImmichFrame.Core.Tests.Logic.Pool; +namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; [TestFixture] -public class AllAssetsPoolTests +public class AllAssetsRemotePoolTests { private Mock _mockApiCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; - private AllAssetsPool _allAssetsPool; + private AllAssetsRemotePool _allAssetsPool; [SetUp] public void Setup() @@ -25,7 +26,7 @@ public void Setup() _mockApiCache = new Mock(null); _mockImmichApi = new Mock(null, null); _mockAccountSettings = new Mock(); - _allAssetsPool = new AllAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + _allAssetsPool = new AllAssetsRemotePool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); // Default account settings _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); @@ -63,7 +64,7 @@ public async Task GetAssetCount_CallsApiAndCache() // Assert Assert.That(count, Is.EqualTo(100)); _mockImmichApi.Verify(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny()), Times.Once); - _mockApiCache.Verify(cache => cache.GetOrAddAsync(nameof(AllAssetsPool), It.IsAny>>()), Times.Once); + _mockApiCache.Verify(cache => cache.GetOrAddAsync(nameof(AllAssetsRemotePool), It.IsAny>>()), Times.Once); } [Test] diff --git a/ImmichFrame.Core/Logic/AssetPoolFactory.cs b/ImmichFrame.Core/Logic/AssetPoolFactory.cs new file mode 100644 index 00000000..405e5b0c --- /dev/null +++ b/ImmichFrame.Core/Logic/AssetPoolFactory.cs @@ -0,0 +1,41 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Logic.Pool; +using ImmichFrame.Core.Logic.Pool.Preload; +using ImmichFrame.Core.Logic.Pool.Remote; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.Core.Logic; + +public interface IAssetPoolFactory +{ + IAssetPool BuildPool(IAccountSettings accountSettings, ApiCache apiCache, ImmichApi immichApi); +} + +public class AssetPoolFactory(ILoggerFactory loggerFactory) : IAssetPoolFactory +{ + public IAssetPool BuildPool(IAccountSettings accountSettings, ApiCache apiCache, ImmichApi immichApi) + { + if (!accountSettings.ShowFavorites && !accountSettings.ShowMemories && !accountSettings.Albums.Any() && !accountSettings.People.Any()) + { + return new AllAssetsRemotePool(apiCache, immichApi, accountSettings); + } + + var pools = new List(); + + if (accountSettings.ShowFavorites) + pools.Add(new FavoriteAssetsRemotePool(loggerFactory.CreateLogger(), immichApi, + new FavoriteAssetsPreloadPool(apiCache, immichApi, accountSettings))); + + if (accountSettings.ShowMemories) + pools.Add(new MemoryAssetsPreloadPool(apiCache, immichApi, accountSettings)); + + if (accountSettings.Albums.Any()) + pools.Add(new AlbumAssetsPreloadPool(apiCache, immichApi, accountSettings)); + + if (accountSettings.People.Any()) + pools.Add(new PersonAssetsPreloadPool(apiCache, immichApi, accountSettings)); + + return new MultiAssetPool(pools); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs deleted file mode 100644 index 546d5f9b..00000000 --- a/ImmichFrame.Core/Logic/Pool/FavoriteAssetsPool.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ImmichFrame.Core.Api; -using ImmichFrame.Core.Interfaces; - -namespace ImmichFrame.Core.Logic.Pool; - -public class FavoriteAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) -{ - protected override async Task> LoadAssets(CancellationToken ct = default) - { - var favoriteAssets = new List(); - - int page = 1; - int batchSize = 1000; - int total; - do - { - var metadataBody = new MetadataSearchDto - { - Page = page, - Size = batchSize, - IsFavorite = true, - Type = AssetTypeEnum.IMAGE, - WithExif = true, - WithPeople = true - }; - - var favoriteInfo = await immichApi.SearchAssetsAsync(metadataBody, ct); - - total = favoriteInfo.Assets.Total; - - favoriteAssets.AddRange(favoriteInfo.Assets.Items); - page++; - } while (total == batchSize); - - return favoriteAssets; - } -} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs deleted file mode 100644 index 12bd7ebc..00000000 --- a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ImmichFrame.Core.Api; -using ImmichFrame.Core.Interfaces; - -namespace ImmichFrame.Core.Logic.Pool; - -public class PersonAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) -{ - protected override async Task> LoadAssets(CancellationToken ct = default) - { - var personAssets = new List(); - - foreach (var personId in accountSettings.People) - { - int page = 1; - int batchSize = 1000; - int total; - do - { - var metadataBody = new MetadataSearchDto - { - Page = page, - Size = batchSize, - PersonIds = [personId], - Type = AssetTypeEnum.IMAGE, - WithExif = true, - WithPeople = true - }; - - var personInfo = await immichApi.SearchAssetsAsync(metadataBody, ct); - - total = personInfo.Assets.Total; - - personAssets.AddRange(personInfo.Assets.Items); - page++; - } while (total == batchSize); - } - - return personAssets; - } -} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/Preload/AlbumAssetsPreloadPool.cs similarity index 79% rename from ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs rename to ImmichFrame.Core/Logic/Pool/Preload/AlbumAssetsPreloadPool.cs index c44d3b3c..870ad5bc 100644 --- a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/Preload/AlbumAssetsPreloadPool.cs @@ -2,9 +2,9 @@ using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; -namespace ImmichFrame.Core.Logic.Pool; +namespace ImmichFrame.Core.Logic.Pool.Preload; -public class AlbumAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) +public class AlbumAssetsPreloadPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) { protected override async Task> LoadAssets(CancellationToken ct = default) { diff --git a/ImmichFrame.Core/Logic/Pool/Preload/FavoriteAssetsPreloadPool.cs b/ImmichFrame.Core/Logic/Pool/Preload/FavoriteAssetsPreloadPool.cs new file mode 100644 index 00000000..35a0f71c --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/Preload/FavoriteAssetsPreloadPool.cs @@ -0,0 +1,15 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Logic.Pool.Preload; + +public class FavoriteAssetsPreloadPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) +{ + protected override Task> LoadAssets(CancellationToken ct = default) + => LoadAssetsFromMetadataSearch(new MetadataSearchDto + { + IsFavorite = true, + WithExif = true, + WithPeople = true + }, ct); +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/Preload/MemoryAssetsPreloadPool.cs similarity index 82% rename from ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs rename to ImmichFrame.Core/Logic/Pool/Preload/MemoryAssetsPreloadPool.cs index 6bdc64f6..a905419d 100644 --- a/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/Preload/MemoryAssetsPreloadPool.cs @@ -1,9 +1,9 @@ using ImmichFrame.Core.Api; using ImmichFrame.Core.Interfaces; -namespace ImmichFrame.Core.Logic.Pool; +namespace ImmichFrame.Core.Logic.Pool.Preload; -public class MemoryAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings) +public class MemoryAssetsPreloadPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) { protected override async Task> LoadAssets(CancellationToken ct = default) { diff --git a/ImmichFrame.Core/Logic/Pool/Preload/PeopleAssetsPreloadPool.cs b/ImmichFrame.Core/Logic/Pool/Preload/PeopleAssetsPreloadPool.cs new file mode 100644 index 00000000..9d8a7aa9 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/Preload/PeopleAssetsPreloadPool.cs @@ -0,0 +1,24 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Logic.Pool.Preload; + +public class PersonAssetsPreloadPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) +{ + protected override async Task> LoadAssets(CancellationToken ct = default) + { + var all = accountSettings.People.Select(async personId => + + await LoadAssetsFromMetadataSearch(new MetadataSearchDto + { + PersonIds = [personId], + Type = AssetTypeEnum.IMAGE, + WithExif = true, + WithPeople = true + }, ct) + + ); + + return (await Task.WhenAll(all)).SelectMany(x => x); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/Preload/PreloadedAssetsPool.cs similarity index 69% rename from ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs rename to ImmichFrame.Core/Logic/Pool/Preload/PreloadedAssetsPool.cs index 967bb1bb..fbe433e5 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/Preload/PreloadedAssetsPool.cs @@ -1,17 +1,17 @@ using ImmichFrame.Core.Api; using ImmichFrame.Core.Interfaces; -namespace ImmichFrame.Core.Logic.Pool; +namespace ImmichFrame.Core.Logic.Pool.Preload; -public abstract class CachingApiAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool +public abstract class PreloadedAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool { private readonly Random _random = new(); - + public async Task GetAssetCount(CancellationToken ct = default) { return (await AllAssets(ct)).Count(); } - + public async Task> GetAssets(int requested, CancellationToken ct = default) { return (await AllAssets(ct)).OrderBy(_ => _random.Next()).Take(requested); @@ -47,9 +47,33 @@ protected async Task> ApplyAccountFilters(Task x.ExifInfo.Rating == rating); } - + + return assets; + } + + protected async Task> LoadAssetsFromMetadataSearch(MetadataSearchDto query, CancellationToken ct = default) + { + var assets = new List(); + + query.Type = AssetTypeEnum.IMAGE; + + int page = 1; + int batchSize = 1000; + int total; + do + { + query.Page = page; + query.Size = batchSize; + + var results = await immichApi.SearchAssetsAsync(query, ct); + + total = results.Assets.Total; + assets.AddRange(results.Assets.Items); + page++; + } while (total == batchSize); + return assets; } - + protected abstract Task> LoadAssets(CancellationToken ct = default); } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs similarity index 90% rename from ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs rename to ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs index 9e3c0e63..31ac2d87 100644 --- a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs @@ -1,14 +1,14 @@ using ImmichFrame.Core.Api; using ImmichFrame.Core.Interfaces; -namespace ImmichFrame.Core.Logic.Pool; +namespace ImmichFrame.Core.Logic.Pool.Preload; -public class AllAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool +public class AllAssetsRemotePool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool { public async Task GetAssetCount(CancellationToken ct = default) { //Retrieve total images count (unfiltered); will update to query filtered stats from Immich - return (await apiCache.GetOrAddAsync(nameof(AllAssetsPool), + return (await apiCache.GetOrAddAsync(nameof(AllAssetsRemotePool), () => immichApi.GetAssetStatisticsAsync(null, false, null, ct))).Images; } diff --git a/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs b/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs new file mode 100644 index 00000000..c611ae0a --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs @@ -0,0 +1,40 @@ +using ImmichFrame.Core.Api; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.Core.Logic.Pool.Remote; + +public class FavoriteAssetsRemotePool(ILogger _logger, ImmichApi _immichApi, IAssetPool fallbackPool) : IAssetPool +{ + public async Task GetAssetCount(CancellationToken ct = default) + { + try + { + return (await _immichApi.SearchAssetStatisticsAsync(new StatisticsSearchDto + { + IsFavorite = true, + }, ct)).Total; + } + catch (Exception e) + { + _logger.LogError(e, $"Failed to get asset count, falling back to preload [{e.Message}]"); + return await fallbackPool.GetAssetCount(ct); + } + } + + public async Task> GetAssets(int requested, CancellationToken ct = default) + { + try + { + return (await _immichApi.SearchAssetsAsync(new MetadataSearchDto + { + Size = requested, + IsFavorite = true, + }, ct)).Assets.Items; + } + catch (Exception e) + { + _logger.LogError(e, $"Failed to get assets, falling back to preload [{e.Message}]"); + return await fallbackPool.GetAssets(requested, ct); + } + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 428b4198..2c7ad219 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 ImmichFrame.Core.Logic.Pool.Preload; namespace ImmichFrame.Core.Logic; @@ -14,7 +15,7 @@ public class PooledImmichFrameLogic : IImmichFrameLogic private readonly ImmichApi _immichApi; private readonly string _downloadLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ImageCache"); - public PooledImmichFrameLogic(IAccountSettings accountSettings, IGeneralSettings generalSettings) + public PooledImmichFrameLogic(IAccountSettings accountSettings, IGeneralSettings generalSettings, IAssetPoolFactory assetPoolFactory) { _generalSettings = generalSettings; @@ -22,31 +23,7 @@ public PooledImmichFrameLogic(IAccountSettings accountSettings, IGeneralSettings httpClient.UseApiKey(accountSettings.ApiKey); _immichApi = new ImmichApi(accountSettings.ImmichServerUrl, httpClient); _apiCache = new ApiCache(TimeSpan.FromHours(generalSettings.RefreshAlbumPeopleInterval)); - _pool = BuildPool(accountSettings); - } - - private IAssetPool BuildPool(IAccountSettings accountSettings) - { - if (!accountSettings.ShowFavorites && !accountSettings.ShowMemories && !accountSettings.Albums.Any() && !accountSettings.People.Any()) - { - return new AllAssetsPool(_apiCache, _immichApi, accountSettings); - } - - var pools = new List(); - - if (accountSettings.ShowFavorites) - pools.Add(new FavoriteAssetsPool(_apiCache, _immichApi, accountSettings)); - - if (accountSettings.ShowMemories) - pools.Add(new MemoryAssetsPool(_apiCache, _immichApi, accountSettings)); - - if (accountSettings.Albums.Any()) - pools.Add(new AlbumAssetsPool(_apiCache, _immichApi, accountSettings)); - - if (accountSettings.People.Any()) - pools.Add(new PersonAssetsPool(_apiCache, _immichApi, accountSettings)); - - return new MultiAssetPool(pools); + _pool = assetPoolFactory.BuildPool(accountSettings, _apiCache, _immichApi); } public async Task GetNextAsset() diff --git a/ImmichFrame.Core/OpenAPIs/immich-openapi-specs.json b/ImmichFrame.Core/OpenAPIs/immich-openapi-specs.json index 1d6bd3b0..24284576 100644 --- a/ImmichFrame.Core/OpenAPIs/immich-openapi-specs.json +++ b/ImmichFrame.Core/OpenAPIs/immich-openapi-specs.json @@ -2698,6 +2698,39 @@ } }, "/duplicates": { + "delete": { + "operationId": "deleteDuplicates", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkIdsDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Duplicates" + ] + }, "get": { "operationId": "getAssetDuplicates", "parameters": [], @@ -2732,6 +2765,41 @@ ] } }, + "/duplicates/{id}": { + "delete": { + "operationId": "deleteDuplicate", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Duplicates" + ] + } + }, "/faces": { "get": { "operationId": "getFaces", @@ -3599,6 +3667,72 @@ ] } }, + "/memories/statistics": { + "get": { + "operationId": "memoriesStatistics", + "parameters": [ + { + "name": "for", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "isSaved", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/MemoryType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemoryStatisticsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Memories" + ] + } + }, "/memories/{id}": { "delete": { "operationId": "deleteMemory", @@ -5158,6 +5292,48 @@ ] } }, + "/search/statistics": { + "post": { + "operationId": "searchAssetStatistics", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatisticsSearchDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchStatisticsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/suggestions": { "get": { "operationId": "getSearchSuggestions", @@ -5275,6 +5451,38 @@ ] } }, + "/server/apk-links": { + "get": { + "operationId": "getApkLinks", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerApkLinksDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server" + ] + } + }, "/server/config": { "get": { "operationId": "getServerConfig", @@ -5563,6 +5771,38 @@ ] } }, + "/server/version-check": { + "get": { + "operationId": "getVersionCheck", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionCheckStateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server" + ] + } + }, "/server/version-history": { "get": { "operationId": "getVersionHistory", @@ -6846,6 +7086,38 @@ ] } }, + "/system-metadata/version-check-state": { + "get": { + "operationId": "getVersionCheckState", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionCheckStateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "System Metadata" + ] + } + }, "/tags": { "get": { "operationId": "getAllTags", @@ -7247,6 +7519,7 @@ "name": "albumId", "required": false, "in": "query", + "description": "Filter assets belonging to a specific album", "schema": { "format": "uuid", "type": "string" @@ -7256,6 +7529,7 @@ "name": "isFavorite", "required": false, "in": "query", + "description": "Filter by favorite status (true for favorites only, false for non-favorites only)", "schema": { "type": "boolean" } @@ -7264,6 +7538,7 @@ "name": "isTrashed", "required": false, "in": "query", + "description": "Filter by trash status (true for trashed assets only, false for non-trashed only)", "schema": { "type": "boolean" } @@ -7280,32 +7555,16 @@ "name": "order", "required": false, "in": "query", + "description": "Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)", "schema": { "$ref": "#/components/schemas/AssetOrder" } }, { - "name": "page", - "required": false, - "in": "query", - "schema": { - "minimum": 1, - "type": "number" - } - }, - { - "name": "pageSize", - "required": false, - "in": "query", - "schema": { - "minimum": 1, - "type": "number" - } - }, - { - "name": "personId", + "name": "personId", "required": false, "in": "query", + "description": "Filter assets containing a specific person (face recognition)", "schema": { "format": "uuid", "type": "string" @@ -7315,6 +7574,7 @@ "name": "tagId", "required": false, "in": "query", + "description": "Filter assets with a specific tag", "schema": { "format": "uuid", "type": "string" @@ -7324,7 +7584,9 @@ "name": "timeBucket", "required": true, "in": "query", + "description": "Time bucket identifier in YYYY-MM-DD format (e.g., \"2024-01-01\" for January 2024)", "schema": { + "example": "2024-01-01", "type": "string" } }, @@ -7332,6 +7594,7 @@ "name": "userId", "required": false, "in": "query", + "description": "Filter assets by specific user ID", "schema": { "format": "uuid", "type": "string" @@ -7341,6 +7604,7 @@ "name": "visibility", "required": false, "in": "query", + "description": "Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)", "schema": { "$ref": "#/components/schemas/AssetVisibility" } @@ -7349,6 +7613,7 @@ "name": "withPartners", "required": false, "in": "query", + "description": "Include assets shared by partners", "schema": { "type": "boolean" } @@ -7357,6 +7622,7 @@ "name": "withStacked", "required": false, "in": "query", + "description": "Include stacked assets in the response. When true, only primary assets from stacks are returned.", "schema": { "type": "boolean" } @@ -7398,6 +7664,7 @@ "name": "albumId", "required": false, "in": "query", + "description": "Filter assets belonging to a specific album", "schema": { "format": "uuid", "type": "string" @@ -7407,6 +7674,7 @@ "name": "isFavorite", "required": false, "in": "query", + "description": "Filter by favorite status (true for favorites only, false for non-favorites only)", "schema": { "type": "boolean" } @@ -7415,6 +7683,7 @@ "name": "isTrashed", "required": false, "in": "query", + "description": "Filter by trash status (true for trashed assets only, false for non-trashed only)", "schema": { "type": "boolean" } @@ -7431,6 +7700,7 @@ "name": "order", "required": false, "in": "query", + "description": "Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)", "schema": { "$ref": "#/components/schemas/AssetOrder" } @@ -7439,6 +7709,7 @@ "name": "personId", "required": false, "in": "query", + "description": "Filter assets containing a specific person (face recognition)", "schema": { "format": "uuid", "type": "string" @@ -7448,6 +7719,7 @@ "name": "tagId", "required": false, "in": "query", + "description": "Filter assets with a specific tag", "schema": { "format": "uuid", "type": "string" @@ -7457,6 +7729,7 @@ "name": "userId", "required": false, "in": "query", + "description": "Filter assets by specific user ID", "schema": { "format": "uuid", "type": "string" @@ -7466,6 +7739,7 @@ "name": "visibility", "required": false, "in": "query", + "description": "Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)", "schema": { "$ref": "#/components/schemas/AssetVisibility" } @@ -7474,6 +7748,7 @@ "name": "withPartners", "required": false, "in": "query", + "description": "Include assets shared by partners", "schema": { "type": "boolean" } @@ -7482,6 +7757,7 @@ "name": "withStacked", "required": false, "in": "query", + "description": "Include stacked assets in the response. When true, only primary assets from stacks are returned.", "schema": { "type": "boolean" } @@ -7826,6 +8102,101 @@ ] } }, + "/users/me/onboarding": { + "delete": { + "operationId": "deleteUserOnboarding", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Users" + ] + }, + "get": { + "operationId": "getUserOnboarding", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Users" + ] + }, + "put": { + "operationId": "setUserOnboarding", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnboardingResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Users" + ] + } + }, "/users/me/preferences": { "get": { "operationId": "getMyPreferences", @@ -8132,7 +8503,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.133.0", + "version": "1.134.0", "contact": {} }, "tags": [], @@ -8230,11 +8601,15 @@ "properties": { "name": { "type": "string" + }, + "permissions": { + "items": { + "$ref": "#/components/schemas/Permission" + }, + "minItems": 1, + "type": "array" } }, - "required": [ - "name" - ], "type": "object" }, "ActivityCreateDto": { @@ -8305,10 +8680,14 @@ "properties": { "comments": { "type": "integer" + }, + "likes": { + "type": "integer" } }, "required": [ - "comments" + "comments", + "likes" ], "type": "object" }, @@ -8514,6 +8893,34 @@ ], "type": "string" }, + "AlbumsResponse": { + "properties": { + "defaultAssetOrder": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ], + "default": "desc" + } + }, + "required": [ + "defaultAssetOrder" + ], + "type": "object" + }, + "AlbumsUpdate": { + "properties": { + "defaultAssetOrder": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetOrder" + } + ] + } + }, + "type": "object" + }, "AllJobStatusResponseDto": { "properties": { "backgroundTask": { @@ -9062,6 +9469,9 @@ "format": "date-time", "type": "string" }, + "filename": { + "type": "string" + }, "isFavorite": { "type": "boolean" }, @@ -9112,6 +9522,9 @@ "fileModifiedAt": { "format": "date-time", "type": "string" + }, + "filename": { + "type": "string" } }, "required": [ @@ -9188,10 +9601,14 @@ "$ref": "#/components/schemas/ExifResponseDto" }, "fileCreatedAt": { + "description": "The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.", + "example": "2024-01-15T19:30:00.000Z", "format": "date-time", "type": "string" }, "fileModifiedAt": { + "description": "The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.", + "example": "2024-01-16T10:15:00.000Z", "format": "date-time", "type": "string" }, @@ -9224,6 +9641,8 @@ "type": "string" }, "localDateTime": { + "description": "The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer's local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by \"local\" days and months.", + "example": "2024-01-15T14:30:00.000Z", "format": "date-time", "type": "string" }, @@ -9285,6 +9704,8 @@ "type": "array" }, "updatedAt": { + "description": "The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.", + "example": "2024-01-16T12:45:30.000Z", "format": "date-time", "type": "string" }, @@ -9484,6 +9905,26 @@ ], "type": "string" }, + "CastResponse": { + "properties": { + "gCastEnabled": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "gCastEnabled" + ], + "type": "object" + }, + "CastUpdate": { + "properties": { + "gCastEnabled": { + "type": "boolean" + } + }, + "type": "object" + }, "ChangePasswordDto": { "properties": { "newPassword": { @@ -10284,6 +10725,9 @@ "isAdmin": { "type": "boolean" }, + "isOnboarded": { + "type": "boolean" + }, "name": { "type": "string" }, @@ -10303,6 +10747,7 @@ "required": [ "accessToken", "isAdmin", + "isOnboarded", "name", "profileImagePath", "shouldChangePassword", @@ -10522,6 +10967,17 @@ ], "type": "object" }, + "MemoryStatisticsResponseDto": { + "properties": { + "total": { + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + }, "MemoryType": { "enum": [ "on_this_day" @@ -10561,6 +11017,13 @@ }, "MetadataSearchDto": { "properties": { + "albumIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "checksum": { "type": "string" }, @@ -10947,6 +11410,28 @@ ], "type": "object" }, + "OnboardingDto": { + "properties": { + "isOnboarded": { + "type": "boolean" + } + }, + "required": [ + "isOnboarded" + ], + "type": "object" + }, + "OnboardingResponseDto": { + "properties": { + "isOnboarded": { + "type": "boolean" + } + }, + "required": [ + "isOnboarded" + ], + "type": "object" + }, "PartnerDirection": { "enum": [ "shared-by", @@ -11458,6 +11943,13 @@ }, "RandomSearchDto": { "properties": { + "albumIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "city": { "nullable": true, "type": "string" @@ -11784,6 +12276,17 @@ ], "type": "object" }, + "SearchStatisticsResponseDto": { + "properties": { + "total": { + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + }, "SearchSuggestionType": { "enum": [ "country", @@ -11867,6 +12370,29 @@ ], "type": "object" }, + "ServerApkLinksDto": { + "properties": { + "arm64v8a": { + "type": "string" + }, + "armeabiv7a": { + "type": "string" + }, + "universal": { + "type": "string" + }, + "x86_64": { + "type": "string" + } + }, + "required": [ + "arm64v8a", + "armeabiv7a", + "universal", + "x86_64" + ], + "type": "object" + }, "ServerConfigDto": { "properties": { "externalDomain": { @@ -12472,6 +12998,13 @@ }, "SmartSearchDto": { "properties": { + "albumIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "city": { "nullable": true, "type": "string" @@ -12666,6 +13199,132 @@ }, "type": "object" }, + "StatisticsSearchDto": { + "properties": { + "albumIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, + "createdAfter": { + "format": "date-time", + "type": "string" + }, + "createdBefore": { + "format": "date-time", + "type": "string" + }, + "description": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "isEncoded": { + "type": "boolean" + }, + "isFavorite": { + "type": "boolean" + }, + "isMotion": { + "type": "boolean" + }, + "isNotInAlbum": { + "type": "boolean" + }, + "isOffline": { + "type": "boolean" + }, + "lensModel": { + "nullable": true, + "type": "string" + }, + "libraryId": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "make": { + "type": "string" + }, + "model": { + "nullable": true, + "type": "string" + }, + "personIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "rating": { + "maximum": 5, + "minimum": -1, + "type": "number" + }, + "state": { + "nullable": true, + "type": "string" + }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "takenAfter": { + "format": "date-time", + "type": "string" + }, + "takenBefore": { + "format": "date-time", + "type": "string" + }, + "trashedAfter": { + "format": "date-time", + "type": "string" + }, + "trashedBefore": { + "format": "date-time", + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetTypeEnum" + } + ] + }, + "updatedAfter": { + "format": "date-time", + "type": "string" + }, + "updatedBefore": { + "format": "date-time", + "type": "string" + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] + } + }, + "type": "object" + }, "SyncAckDeleteDto": { "properties": { "types": { @@ -12742,11 +13401,11 @@ "type": "string" }, "role": { - "enum": [ - "editor", - "viewer" - ], - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/AlbumUserRole" + } + ] }, "userId": { "type": "string" @@ -12984,6 +13643,9 @@ "nullable": true, "type": "string" }, + "originalFileName": { + "type": "string" + }, "ownerId": { "type": "string" }, @@ -12992,22 +13654,18 @@ "type": "string" }, "type": { - "enum": [ - "IMAGE", - "VIDEO", - "AUDIO", - "OTHER" - ], - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/AssetTypeEnum" + } + ] }, "visibility": { - "enum": [ - "archive", - "timeline", - "hidden", - "locked" - ], - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] } }, "required": [ @@ -13018,6 +13676,7 @@ "id", "isFavorite", "localDateTime", + "originalFileName", "ownerId", "thumbhash", "type", @@ -13685,8 +14344,10 @@ "type": "string" }, "defaultStorageQuota": { + "format": "int64", "minimum": 0, - "type": "number" + "nullable": true, + "type": "integer" }, "enabled": { "type": "boolean" @@ -14174,6 +14835,7 @@ "TimeBucketAssetResponseDto": { "properties": { "city": { + "description": "Array of city names extracted from EXIF GPS data", "items": { "nullable": true, "type": "string" @@ -14181,6 +14843,7 @@ "type": "array" }, "country": { + "description": "Array of country names extracted from EXIF GPS data", "items": { "nullable": true, "type": "string" @@ -14188,56 +14851,72 @@ "type": "array" }, "duration": { + "description": "Array of video durations in HH:MM:SS format (null for images)", "items": { "nullable": true, "type": "string" }, "type": "array" }, + "fileCreatedAt": { + "description": "Array of file creation timestamps in UTC (ISO 8601 format, without timezone)", + "items": { + "type": "string" + }, + "type": "array" + }, "id": { + "description": "Array of asset IDs in the time bucket", "items": { "type": "string" }, "type": "array" }, "isFavorite": { + "description": "Array indicating whether each asset is favorited", "items": { "type": "boolean" }, "type": "array" }, "isImage": { + "description": "Array indicating whether each asset is an image (false for videos)", "items": { "type": "boolean" }, "type": "array" }, "isTrashed": { + "description": "Array indicating whether each asset is in the trash", "items": { "type": "boolean" }, "type": "array" }, "livePhotoVideoId": { + "description": "Array of live photo video asset IDs (null for non-live photos)", "items": { "nullable": true, "type": "string" }, "type": "array" }, - "localDateTime": { + "localOffsetHours": { + "description": "Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.", "items": { - "type": "string" + "type": "number" }, "type": "array" }, "ownerId": { + "description": "Array of owner IDs for each asset", "items": { "type": "string" }, "type": "array" }, "projectionType": { + "description": "Array of projection types for 360° content (e.g., \"EQUIRECTANGULAR\", \"CUBEFACE\", \"CYLINDRICAL\")", "items": { "nullable": true, "type": "string" @@ -14245,13 +14924,14 @@ "type": "array" }, "ratio": { + "description": "Array of aspect ratios (width/height) for each asset", "items": { "type": "number" }, "type": "array" }, "stack": { - "description": "(stack ID, stack asset count) tuple", + "description": "Array of stack information as [stackId, assetCount] tuples (null for non-stacked assets)", "items": { "items": { "type": "string" @@ -14264,6 +14944,7 @@ "type": "array" }, "thumbhash": { + "description": "Array of BlurHash strings for generating asset previews (base64 encoded)", "items": { "nullable": true, "type": "string" @@ -14271,6 +14952,7 @@ "type": "array" }, "visibility": { + "description": "Array of visibility statuses for each asset (e.g., ARCHIVE, TIMELINE, HIDDEN, LOCKED)", "items": { "$ref": "#/components/schemas/AssetVisibility" }, @@ -14281,12 +14963,13 @@ "city", "country", "duration", + "fileCreatedAt", "id", "isFavorite", "isImage", "isTrashed", "livePhotoVideoId", - "localDateTime", + "localOffsetHours", "ownerId", "projectionType", "ratio", @@ -14298,9 +14981,13 @@ "TimeBucketsResponseDto": { "properties": { "count": { + "description": "Number of assets in this time bucket", + "example": 42, "type": "integer" }, "timeBucket": { + "description": "Time bucket identifier in YYYY-MM-DD format representing the start of the time period", + "example": "2024-01-01", "type": "string" } }, @@ -14520,6 +15207,9 @@ "format": "email", "type": "string" }, + "isAdmin": { + "type": "boolean" + }, "name": { "type": "string" }, @@ -14670,6 +15360,9 @@ "format": "email", "type": "string" }, + "isAdmin": { + "type": "boolean" + }, "name": { "type": "string" }, @@ -14734,6 +15427,12 @@ }, "UserPreferencesResponseDto": { "properties": { + "albums": { + "$ref": "#/components/schemas/AlbumsResponse" + }, + "cast": { + "$ref": "#/components/schemas/CastResponse" + }, "download": { "$ref": "#/components/schemas/DownloadResponse" }, @@ -14763,6 +15462,8 @@ } }, "required": [ + "albums", + "cast", "download", "emailNotifications", "folders", @@ -14777,9 +15478,15 @@ }, "UserPreferencesUpdateDto": { "properties": { + "albums": { + "$ref": "#/components/schemas/AlbumsUpdate" + }, "avatar": { "$ref": "#/components/schemas/AvatarUpdate" }, + "cast": { + "$ref": "#/components/schemas/CastUpdate" + }, "download": { "$ref": "#/components/schemas/DownloadUpdate" }, @@ -14939,6 +15646,23 @@ }, "type": "object" }, + "VersionCheckStateResponseDto": { + "properties": { + "checkedAt": { + "nullable": true, + "type": "string" + }, + "releaseVersion": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "checkedAt", + "releaseVersion" + ], + "type": "object" + }, "VideoCodec": { "enum": [ "h264", diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index f122f6c4..9b2f65a1 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -57,6 +57,7 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddTransient(); builder.Services.AddTransient>(srv => account => ActivatorUtilities.CreateInstance(srv, account)); builder.Services.AddSingleton(); From 5fb39c894e6a617415ada1db86af797ecd359858 Mon Sep 17 00:00:00 2001 From: Jonathan Gilbert Date: Mon, 30 Jun 2025 17:41:14 -0400 Subject: [PATCH 3/7] Updates, need to fix tests --- .../ImmichFrame.Core.Tests.csproj | 1 + .../Logic/Pool/FixtureHelpers.cs | 10 +++ .../Preload/AlbumAssetsPreloadPoolTests.cs | 6 +- .../Preload/FavoriteAssetsPreloadPoolTests.cs | 45 +++++++------ .../Preload/MemoryAssetsPreloadPoolTests.cs | 6 +- .../Pool/Preload/PersonAssetsPoolTests.cs | 6 +- .../Pool/Preload/PreloadedAssetsPoolTests.cs | 6 +- .../Pool/Remote/AllAssetsRemotePoolTests.cs | 46 +++++++------- ImmichFrame.Core/Helpers/CacheExtensions.cs | 54 ++++++++++++++++ .../Helpers/{ApiCache.cs => IApiCache.cs} | 24 +++---- ImmichFrame.Core/Logic/AssetPoolFactory.cs | 14 +++-- .../Logic/Pool/CircuitBreakerPool.cs | 63 +++++++++++++++++++ .../Pool/Preload/AlbumAssetsPreloadPool.cs | 2 +- .../Pool/Preload/FavoriteAssetsPreloadPool.cs | 2 +- .../Pool/Preload/MemoryAssetsPreloadPool.cs | 2 +- .../Pool/Preload/PeopleAssetsPreloadPool.cs | 2 +- .../Logic/Pool/Preload/PreloadedAssetsPool.cs | 2 +- .../Logic/Pool/Remote/AllAssetsRemotePool.cs | 37 ++++++----- .../Pool/Remote/FavoriteAssetsRemotePool.cs | 6 +- .../Logic/PooledImmichFrameLogic.cs | 2 +- .../Services/IcalCalendarService.cs | 2 +- .../Services/OpenWeatherMapService.cs | 2 +- ImmichFrame.WebApi/Settings.Development.json | 53 ++++++++++++++++ 23 files changed, 288 insertions(+), 105 deletions(-) create mode 100644 ImmichFrame.Core/Helpers/CacheExtensions.cs rename ImmichFrame.Core/Helpers/{ApiCache.cs => IApiCache.cs} (66%) create mode 100644 ImmichFrame.Core/Logic/Pool/CircuitBreakerPool.cs create mode 100644 ImmichFrame.WebApi/Settings.Development.json diff --git a/ImmichFrame.Core.Tests/ImmichFrame.Core.Tests.csproj b/ImmichFrame.Core.Tests/ImmichFrame.Core.Tests.csproj index 3b8fa20d..85235b2a 100644 --- a/ImmichFrame.Core.Tests/ImmichFrame.Core.Tests.csproj +++ b/ImmichFrame.Core.Tests/ImmichFrame.Core.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs b/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs index 4c4f6ff2..64218c16 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs @@ -12,4 +12,14 @@ public static ILogger TestLogger() }); return loggerFactory.CreateLogger(); } + + public class ForgetfulCountingCache : IApiCache + { + public int Count = 0; + public Task GetOrAddAsync(string key, Func> factory) + { + Count++; + return factory(); + } + } } \ No newline at end of file diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs index c1c20150..3fcbd335 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs @@ -15,12 +15,12 @@ namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; [TestFixture] public class AlbumAssetsPreloadPoolTests { - private Mock _mockApiCache; + private Mock _mockApiCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; private TestableAlbumAssetsPool _albumAssetsPool; - private class TestableAlbumAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + private class TestableAlbumAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : AlbumAssetsPreloadPool(apiCache, immichApi, accountSettings) { // Expose LoadAssets for testing @@ -30,7 +30,7 @@ private class TestableAlbumAssetsPool(ApiCache apiCache, ImmichApi immichApi, IA [SetUp] public void Setup() { - _mockApiCache = new Mock(TimeSpan.MaxValue); + _mockApiCache = new Mock(TimeSpan.MaxValue); _mockImmichApi = new Mock("", null); _mockAccountSettings = new Mock(); _albumAssetsPool = new TestableAlbumAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs index 7d8c9a4c..6407983b 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs @@ -15,14 +15,14 @@ namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; [TestFixture] public class FavoriteAssetsPreloadPoolTests { - private Mock _mockApiCache; + private Mock _mockApiCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; // Though not directly used by LoadAssets here private TestableFavoriteAssetsPool _favoriteAssetsPool; private class TestableFavoriteAssetsPool : FavoriteAssetsPreloadPool { - public TestableFavoriteAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + public TestableFavoriteAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : base(apiCache, immichApi, accountSettings) { } public Task> TestLoadAssets(CancellationToken ct = default) @@ -34,7 +34,7 @@ public Task> TestLoadAssets(CancellationToken ct = [SetUp] public void Setup() { - _mockApiCache = new Mock(null); + _mockApiCache = new Mock(null); _mockImmichApi = new Mock(null, null); _mockAccountSettings = new Mock(); _favoriteAssetsPool = new TestableFavoriteAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); @@ -52,9 +52,28 @@ public async Task LoadAssets_CallsSearchAssetsAsync_WithFavoriteTrue_AndPaginate var assetsPage1 = Enumerable.Range(0, batchSize).Select(i => CreateAsset($"fav_p1_{i}")).ToList(); var assetsPage2 = Enumerable.Range(0, 50).Select(i => CreateAsset($"fav_p2_{i}")).ToList(); - _mockImmichApi.SetupSequence(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny())) + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => + dto.IsFavorite == true && + dto.Type == AssetTypeEnum.IMAGE && + dto.WithExif == true && + dto.WithPeople == true && + dto.Page == 1 && + dto.Size == batchSize), It.IsAny())) .ReturnsAsync(CreateSearchResult(assetsPage1, batchSize)) // Page 1, total indicates more might be available - .ReturnsAsync(CreateSearchResult(assetsPage2, 50)); // Page 2, total indicates this is the last page + .Verifiable("Requests first page"); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => + dto.IsFavorite == true && + dto.Type == AssetTypeEnum.IMAGE && + dto.WithExif == true && + dto.WithPeople == true && + dto.Page == 2 && + dto.Size == batchSize), It.IsAny())) + .ReturnsAsync(CreateSearchResult(assetsPage2, 50)) // Page 2, total indicates this is the last page + .Verifiable("Requests second page"); + // Act var result = (await _favoriteAssetsPool.TestLoadAssets()).ToList(); @@ -64,21 +83,7 @@ public async Task LoadAssets_CallsSearchAssetsAsync_WithFavoriteTrue_AndPaginate Assert.That(result.Any(a => a.Id == "fav_p1_0")); Assert.That(result.Any(a => a.Id == "fav_p2_49")); - _mockImmichApi.Verify(api => api.SearchAssetsAsync( - It.Is(dto => - dto.IsFavorite == true && - dto.Type == AssetTypeEnum.IMAGE && - dto.WithExif == true && - dto.WithPeople == true && - dto.Page == 1 && dto.Size == batchSize), - It.IsAny()), Times.Once); - - _mockImmichApi.Verify(api => api.SearchAssetsAsync( - It.Is(dto => - dto.IsFavorite == true && - dto.Type == AssetTypeEnum.IMAGE && - dto.Page == 2 && dto.Size == batchSize), - It.IsAny()), Times.Once); + _mockImmichApi.VerifyAll(); } [Test] diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs index bb56ac75..521b80f5 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs @@ -15,7 +15,7 @@ namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; [TestFixture] public class MemoryAssetsPreloadPoolTests { - private Mock _mockApiCache; + private Mock _mockApiCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; private MemoryAssetsPreloadPool _memoryAssetsPool; @@ -23,7 +23,7 @@ public class MemoryAssetsPreloadPoolTests [SetUp] public void Setup() { - _mockApiCache = new Mock(null); // Base constructor requires ILogger and IOptions, pass null for simplicity in mock + _mockApiCache = new Mock(null); // Base constructor requires ILogger and IOptions, pass null for simplicity in mock _mockImmichApi = new Mock(null, null); // Base constructor requires ILogger, IHttpClientFactory, IOptions, pass null _mockAccountSettings = new Mock(); @@ -156,7 +156,7 @@ public async Task LoadAssets_CorrectlyFormatsDescription_YearsAgo() .ReturnsAsync(memories); // Reset and re-setup cache mock for each iteration to ensure factory is called - _mockApiCache = new Mock(null); + _mockApiCache = new Mock(null); _mockApiCache.Setup(c => c.GetOrAddAsync>(It.IsAny(), It.IsAny>>>())) .Returns>>>(async (key, factory) => await factory()); _memoryAssetsPool = new MemoryAssetsPreloadPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs index 5063d5da..b33e4ab3 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs @@ -15,14 +15,14 @@ namespace ImmichFrame.Core.Tests.Logic.Pool; [TestFixture] public class PersonAssetsPreloadPoolTests // Renamed from PeopleAssetsPoolTests to match class name { - private Mock _mockApiCache; + private Mock _mockApiCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; private TestablePersonAssetsPool _personAssetsPool; private class TestablePersonAssetsPool : PersonAssetsPreloadPool { - public TestablePersonAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + public TestablePersonAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : base(apiCache, immichApi, accountSettings) { } public Task> TestLoadAssets(CancellationToken ct = default) @@ -34,7 +34,7 @@ public Task> TestLoadAssets(CancellationToken ct = [SetUp] public void Setup() { - _mockApiCache = new Mock(null); + _mockApiCache = new Mock(null); _mockImmichApi = new Mock(null, null); _mockAccountSettings = new Mock(); _personAssetsPool = new TestablePersonAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs index 47fe503a..54c3d39d 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs @@ -15,7 +15,7 @@ namespace ImmichFrame.Core.Tests.Logic.Pool; [TestFixture] public class PreloadedAssetsPoolTests { - private Mock _mockApiCache; + private Mock _mockApiCache; private Mock _mockImmichApi; // Dependency for constructor, may not be used directly in base class tests private Mock _mockAccountSettings; private TestablePreloadedAssetsPool _testPool; @@ -25,7 +25,7 @@ private class TestablePreloadedAssetsPool : PreloadedAssetsPool { public Func>> LoadAssetsFunc { get; set; } - public TestablePreloadedAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + public TestablePreloadedAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : base(apiCache, immichApi, accountSettings) { } @@ -39,7 +39,7 @@ protected override Task> LoadAssets(CancellationTo [SetUp] public void Setup() { - _mockApiCache = new Mock(null); // ILogger, IOptions + _mockApiCache = new Mock(null); // ILogger, IOptions _mockImmichApi = new Mock(null, null); // ILogger, IHttpClientFactory, IOptions _mockAccountSettings = new Mock(); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs index 8dd8959f..28c642ec 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs @@ -2,20 +2,14 @@ using Moq; using ImmichFrame.Core.Api; using ImmichFrame.Core.Interfaces; -using ImmichFrame.Core.Logic.Pool; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using System.Threading; -using ImmichFrame.Core.Logic.Pool.Preload; +using ImmichFrame.Core.Logic.Pool.Remote; -namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; +namespace ImmichFrame.Core.Tests.Logic.Pool.Remote; [TestFixture] public class AllAssetsRemotePoolTests { - private Mock _mockApiCache; + private FixtureHelpers.ForgetfulCountingCache _fakeCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; private AllAssetsRemotePool _allAssetsPool; @@ -23,10 +17,10 @@ public class AllAssetsRemotePoolTests [SetUp] public void Setup() { - _mockApiCache = new Mock(null); + _fakeCache = new FixtureHelpers.ForgetfulCountingCache(); _mockImmichApi = new Mock(null, null); _mockAccountSettings = new Mock(); - _allAssetsPool = new AllAssetsRemotePool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + _allAssetsPool = new AllAssetsRemotePool(_fakeCache, _mockImmichApi.Object, _mockAccountSettings.Object, FixtureHelpers.TestLogger()); // Default account settings _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); @@ -35,13 +29,6 @@ public void Setup() _mockAccountSettings.SetupGet(s => s.ImagesFromDays).Returns((int?)null); _mockAccountSettings.SetupGet(s => s.Rating).Returns((int?)null); _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List()); - - // Default ApiCache setup - _mockApiCache.Setup(c => c.GetOrAddAsync( - It.IsAny(), - It.IsAny>>() // For GetAssetCount - )) - .Returns>>(async (key, factory) => await factory()); } private List CreateSampleAssets(int count, string idPrefix = "asset") @@ -50,21 +37,38 @@ private List CreateSampleAssets(int count, string idPrefix = " .Select(i => new AssetResponseDto { Id = $"{idPrefix}{i}", Type = AssetTypeEnum.IMAGE }) .ToList(); } - + [Test] public async Task GetAssetCount_CallsApiAndCache() + { + // Arrange + var stats = new SearchStatisticsResponseDto { Total = 100 }; + _mockImmichApi.Setup(api => api.SearchAssetStatisticsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(stats); + + // Act + var count = await _allAssetsPool.GetAssetCount(); + + // Assert + Assert.That(count, Is.EqualTo(100)); + _mockImmichApi.Verify(api => api.SearchAssetStatisticsAsync(new StatisticsSearchDto { Type = AssetTypeEnum.IMAGE }, It.IsAny()), Times.Once); + Assert.That(_fakeCache.Count, Is.EqualTo(1)); + } + + + [Test] + public async Task GetAssetCount_CallsApiAndFallsBack() { // Arrange var stats = new AssetStatsResponseDto { Images = 100 }; _mockImmichApi.Setup(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny())).ReturnsAsync(stats); - + // Act var count = await _allAssetsPool.GetAssetCount(); // Assert Assert.That(count, Is.EqualTo(100)); _mockImmichApi.Verify(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny()), Times.Once); - _mockApiCache.Verify(cache => cache.GetOrAddAsync(nameof(AllAssetsRemotePool), It.IsAny>>()), Times.Once); + Assert.That(_fakeCache.Count, Is.EqualTo(1)); } [Test] diff --git a/ImmichFrame.Core/Helpers/CacheExtensions.cs b/ImmichFrame.Core/Helpers/CacheExtensions.cs new file mode 100644 index 00000000..e258bfe6 --- /dev/null +++ b/ImmichFrame.Core/Helpers/CacheExtensions.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace ImmichFrame.Core.Helpers; + + +static class CacheExtensions +{ + public static async Task GetOrAddAsync(this IMemoryCache cache, string key, TimeSpan absoluteExpiry, Func> factory) + { + var rv = cache.Get(key); + + if (rv == null) + { + rv = await factory(); + cache.Set(key, rv, absoluteExpiry); + } + + return rv; + } +} + +public class ApiCache1 : IDisposable +{ + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly MemoryCacheEntryOptions _cacheOptions; + + public ApiCache1(TimeSpan cacheDuration) + { + _cacheOptions = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(cacheDuration); + } + + public T? GetAsync(string key) => _cache.Get(key); + + public virtual async Task GetOrAddAsync(string key, Func> factory) + { + var rv = GetAsync(key); + + if (rv == null) + { + rv = await factory(); + _cache.Set(key, rv); + } + + return rv; + } + + public T Add(string key, T value) => _cache.Set(key, value, _cacheOptions); + + public void Dispose() + { + _cache.Dispose(); + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Helpers/ApiCache.cs b/ImmichFrame.Core/Helpers/IApiCache.cs similarity index 66% rename from ImmichFrame.Core/Helpers/ApiCache.cs rename to ImmichFrame.Core/Helpers/IApiCache.cs index 5ad92264..8859d03b 100644 --- a/ImmichFrame.Core/Helpers/ApiCache.cs +++ b/ImmichFrame.Core/Helpers/IApiCache.cs @@ -1,4 +1,9 @@ -public class ApiCache : IDisposable +public interface IApiCache +{ + Task GetOrAddAsync(string key, Func> factory); +} + +public class ApiCache : IApiCache, IDisposable { private readonly TimeSpan _cacheDuration; private readonly Dictionary _cache = new(); @@ -8,22 +13,7 @@ public ApiCache(TimeSpan cacheDuration) _cacheDuration = cacheDuration; } - public async Task GetAsync(string key) - { - if (_cache.TryGetValue(key, out var entry)) - { - if (DateTime.UtcNow - entry.Timestamp < _cacheDuration) - { - return (T)entry.Data; - } - - Invalidate(key); // Cache expired - } - - return default; - } - - public virtual async Task GetOrAddAsync(string key, Func> factory) + public async Task GetOrAddAsync(string key, Func> factory) { if (_cache.TryGetValue(key, out var entry)) { diff --git a/ImmichFrame.Core/Logic/AssetPoolFactory.cs b/ImmichFrame.Core/Logic/AssetPoolFactory.cs index 405e5b0c..ba557657 100644 --- a/ImmichFrame.Core/Logic/AssetPoolFactory.cs +++ b/ImmichFrame.Core/Logic/AssetPoolFactory.cs @@ -9,23 +9,27 @@ namespace ImmichFrame.Core.Logic; public interface IAssetPoolFactory { - IAssetPool BuildPool(IAccountSettings accountSettings, ApiCache apiCache, ImmichApi immichApi); + IAssetPool BuildPool(IAccountSettings accountSettings, IApiCache apiCache, ImmichApi immichApi); } public class AssetPoolFactory(ILoggerFactory loggerFactory) : IAssetPoolFactory { - public IAssetPool BuildPool(IAccountSettings accountSettings, ApiCache apiCache, ImmichApi immichApi) + public IAssetPool BuildPool(IAccountSettings accountSettings, IApiCache apiCache, ImmichApi immichApi) { if (!accountSettings.ShowFavorites && !accountSettings.ShowMemories && !accountSettings.Albums.Any() && !accountSettings.People.Any()) { - return new AllAssetsRemotePool(apiCache, immichApi, accountSettings); + return new AllAssetsRemotePool(apiCache, immichApi, accountSettings, loggerFactory.CreateLogger()); } var pools = new List(); if (accountSettings.ShowFavorites) - pools.Add(new FavoriteAssetsRemotePool(loggerFactory.CreateLogger(), immichApi, - new FavoriteAssetsPreloadPool(apiCache, immichApi, accountSettings))); + pools.Add( + new CircuitBreakerPool( + new FavoriteAssetsRemotePool(loggerFactory.CreateLogger(), immichApi), + new FavoriteAssetsPreloadPool(apiCache, immichApi, accountSettings), + loggerFactory.CreateLogger() + )); if (accountSettings.ShowMemories) pools.Add(new MemoryAssetsPreloadPool(apiCache, immichApi, accountSettings)); diff --git a/ImmichFrame.Core/Logic/Pool/CircuitBreakerPool.cs b/ImmichFrame.Core/Logic/Pool/CircuitBreakerPool.cs new file mode 100644 index 00000000..d3b1f2e5 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/CircuitBreakerPool.cs @@ -0,0 +1,63 @@ +using ImmichFrame.Core.Api; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.Core.Logic.Pool; + +public class CircuitBreakerPool( + IAssetPool primary, + IAssetPool secondary, + ILogger logger) : + BaseCircuitBreakerPool(primary, secondary, logger) +{ +} + +public abstract class BaseCircuitBreakerPool( + IAssetPool primary, + IAssetPool secondary, + ILogger logger +) : BaseCircuitBreaker(logger), IAssetPool + where T : BaseCircuitBreakerPool +{ + public Task GetAssetCount(CancellationToken ct = default) + => DoCall( + () => primary.GetAssetCount(ct), + () => secondary.GetAssetCount(ct)); + + public Task> GetAssets(int requested, CancellationToken ct = default) + => DoCall( + () => primary.GetAssets(requested, ct), + () => secondary.GetAssets(requested, ct)); +} + +public class BaseCircuitBreaker(ILogger logger) +{ + private DateTime _brokenUntil = DateTime.MinValue; + + private static readonly TimeSpan BreakerTimeout = TimeSpan.FromDays(7); + + protected TOut DoCall(Func primaryFn, Func secondaryFn) + { + if (!IsBroken) + { + try + { + return primaryFn(); + } + catch (Exception e) + { + logger.LogWarning(e, "Failure when calling primary; breaking circuit and using fallback"); + Break(); + } + } + + return secondaryFn(); + } + + public bool IsBroken + { + get => _brokenUntil > DateTime.UtcNow; + } + + public void Break() => _brokenUntil = DateTime.UtcNow.Add(BreakerTimeout); +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/Preload/AlbumAssetsPreloadPool.cs b/ImmichFrame.Core/Logic/Pool/Preload/AlbumAssetsPreloadPool.cs index 870ad5bc..ed9db20a 100644 --- a/ImmichFrame.Core/Logic/Pool/Preload/AlbumAssetsPreloadPool.cs +++ b/ImmichFrame.Core/Logic/Pool/Preload/AlbumAssetsPreloadPool.cs @@ -4,7 +4,7 @@ namespace ImmichFrame.Core.Logic.Pool.Preload; -public class AlbumAssetsPreloadPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) +public class AlbumAssetsPreloadPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) { protected override async Task> LoadAssets(CancellationToken ct = default) { diff --git a/ImmichFrame.Core/Logic/Pool/Preload/FavoriteAssetsPreloadPool.cs b/ImmichFrame.Core/Logic/Pool/Preload/FavoriteAssetsPreloadPool.cs index 35a0f71c..cdfa7090 100644 --- a/ImmichFrame.Core/Logic/Pool/Preload/FavoriteAssetsPreloadPool.cs +++ b/ImmichFrame.Core/Logic/Pool/Preload/FavoriteAssetsPreloadPool.cs @@ -3,7 +3,7 @@ namespace ImmichFrame.Core.Logic.Pool.Preload; -public class FavoriteAssetsPreloadPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) +public class FavoriteAssetsPreloadPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) { protected override Task> LoadAssets(CancellationToken ct = default) => LoadAssetsFromMetadataSearch(new MetadataSearchDto diff --git a/ImmichFrame.Core/Logic/Pool/Preload/MemoryAssetsPreloadPool.cs b/ImmichFrame.Core/Logic/Pool/Preload/MemoryAssetsPreloadPool.cs index a905419d..388273ef 100644 --- a/ImmichFrame.Core/Logic/Pool/Preload/MemoryAssetsPreloadPool.cs +++ b/ImmichFrame.Core/Logic/Pool/Preload/MemoryAssetsPreloadPool.cs @@ -3,7 +3,7 @@ namespace ImmichFrame.Core.Logic.Pool.Preload; -public class MemoryAssetsPreloadPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) +public class MemoryAssetsPreloadPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) { protected override async Task> LoadAssets(CancellationToken ct = default) { diff --git a/ImmichFrame.Core/Logic/Pool/Preload/PeopleAssetsPreloadPool.cs b/ImmichFrame.Core/Logic/Pool/Preload/PeopleAssetsPreloadPool.cs index 9d8a7aa9..14ad6be8 100644 --- a/ImmichFrame.Core/Logic/Pool/Preload/PeopleAssetsPreloadPool.cs +++ b/ImmichFrame.Core/Logic/Pool/Preload/PeopleAssetsPreloadPool.cs @@ -3,7 +3,7 @@ namespace ImmichFrame.Core.Logic.Pool.Preload; -public class PersonAssetsPreloadPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) +public class PersonAssetsPreloadPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : PreloadedAssetsPool(apiCache, immichApi, accountSettings) { protected override async Task> LoadAssets(CancellationToken ct = default) { diff --git a/ImmichFrame.Core/Logic/Pool/Preload/PreloadedAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/Preload/PreloadedAssetsPool.cs index fbe433e5..c001b605 100644 --- a/ImmichFrame.Core/Logic/Pool/Preload/PreloadedAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/Preload/PreloadedAssetsPool.cs @@ -3,7 +3,7 @@ namespace ImmichFrame.Core.Logic.Pool.Preload; -public abstract class PreloadedAssetsPool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool +public abstract class PreloadedAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool { private readonly Random _random = new(); diff --git a/ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs b/ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs index 31ac2d87..fb2d1ffa 100644 --- a/ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs +++ b/ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs @@ -1,16 +1,23 @@ using ImmichFrame.Core.Api; using ImmichFrame.Core.Interfaces; +using Microsoft.Extensions.Logging; -namespace ImmichFrame.Core.Logic.Pool.Preload; +namespace ImmichFrame.Core.Logic.Pool.Remote; -public class AllAssetsRemotePool(ApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool +public class AllAssetsRemotePool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings, ILogger logger) : BaseCircuitBreaker(logger), IAssetPool { - public async Task GetAssetCount(CancellationToken ct = default) - { - //Retrieve total images count (unfiltered); will update to query filtered stats from Immich - return (await apiCache.GetOrAddAsync(nameof(AllAssetsRemotePool), + public virtual Task GetAssetCount(CancellationToken ct = default) + => DoCall( + () => GetFilteredAssetCount(ct), + () => GetTotalAssetCount(ct)); + + private async Task GetFilteredAssetCount(CancellationToken ct = default) + => (await apiCache.GetOrAddAsync($"{nameof(AllAssetsRemotePool)}:filtered", + () => immichApi.SearchAssetStatisticsAsync(new StatisticsSearchDto { Type = AssetTypeEnum.IMAGE }, ct))).Total; + + private async Task GetTotalAssetCount(CancellationToken ct = default) + => (await apiCache.GetOrAddAsync($"{nameof(AllAssetsRemotePool)}:total", () => immichApi.GetAssetStatisticsAsync(null, false, null, ct))).Images; - } public async Task> GetAssets(int requested, CancellationToken ct = default) { @@ -19,23 +26,16 @@ public async Task> GetAssets(int requested, Cancel Size = requested, Type = AssetTypeEnum.IMAGE, WithExif = true, - WithPeople = true + WithPeople = true, + Visibility = accountSettings.ShowArchived ? AssetVisibility.Archive : AssetVisibility.Timeline }; - if (accountSettings.ShowArchived) - { - searchDto.Visibility = AssetVisibility.Archive; - } - else - { - searchDto.Visibility = AssetVisibility.Timeline; - } - var takenBefore = accountSettings.ImagesUntilDate.HasValue ? accountSettings.ImagesUntilDate : null; if (takenBefore.HasValue) { searchDto.TakenBefore = takenBefore; } + var takenAfter = accountSettings.ImagesFromDate.HasValue ? accountSettings.ImagesFromDate : accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-accountSettings.ImagesFromDays.Value) : null; if (takenAfter.HasValue) @@ -71,8 +71,7 @@ private async Task> GetExcludedAlbumAssets(Cancell excludedAlbumAssets.AddRange(albumInfo.Assets); } - + return excludedAlbumAssets; } - } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs b/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs index c611ae0a..abccbc1a 100644 --- a/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs +++ b/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs @@ -3,7 +3,7 @@ namespace ImmichFrame.Core.Logic.Pool.Remote; -public class FavoriteAssetsRemotePool(ILogger _logger, ImmichApi _immichApi, IAssetPool fallbackPool) : IAssetPool +public class FavoriteAssetsRemotePool(ILogger _logger, ImmichApi _immichApi) : IAssetPool { public async Task GetAssetCount(CancellationToken ct = default) { @@ -17,7 +17,7 @@ public async Task GetAssetCount(CancellationToken ct = default) catch (Exception e) { _logger.LogError(e, $"Failed to get asset count, falling back to preload [{e.Message}]"); - return await fallbackPool.GetAssetCount(ct); + throw; } } @@ -34,7 +34,7 @@ public async Task> GetAssets(int requested, Cancel catch (Exception e) { _logger.LogError(e, $"Failed to get assets, falling back to preload [{e.Message}]"); - return await fallbackPool.GetAssets(requested, ct); + throw; } } } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 2c7ad219..674716b8 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -10,7 +10,7 @@ namespace ImmichFrame.Core.Logic; public class PooledImmichFrameLogic : IImmichFrameLogic { private readonly IGeneralSettings _generalSettings; - private readonly ApiCache _apiCache; + private readonly IApiCache _apiCache; private readonly IAssetPool _pool; private readonly ImmichApi _immichApi; private readonly string _downloadLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ImageCache"); diff --git a/ImmichFrame.Core/Services/IcalCalendarService.cs b/ImmichFrame.Core/Services/IcalCalendarService.cs index 63c708a1..3705b79c 100644 --- a/ImmichFrame.Core/Services/IcalCalendarService.cs +++ b/ImmichFrame.Core/Services/IcalCalendarService.cs @@ -5,7 +5,7 @@ public class IcalCalendarService : ICalendarService { private readonly IGeneralSettings _serverSettings; - private readonly ApiCache _appointmentCache = new(TimeSpan.FromMinutes(15)); + private readonly IApiCache _appointmentCache = new ApiCache(TimeSpan.FromMinutes(15)); public IcalCalendarService(IGeneralSettings serverSettings) { diff --git a/ImmichFrame.Core/Services/OpenWeatherMapService.cs b/ImmichFrame.Core/Services/OpenWeatherMapService.cs index b1efd914..0a14488d 100644 --- a/ImmichFrame.Core/Services/OpenWeatherMapService.cs +++ b/ImmichFrame.Core/Services/OpenWeatherMapService.cs @@ -4,7 +4,7 @@ public class OpenWeatherMapService : IWeatherService { private readonly IGeneralSettings _settings; - private readonly ApiCache _weatherCache = new(TimeSpan.FromMinutes(5)); + private readonly IApiCache _weatherCache = new ApiCache(TimeSpan.FromMinutes(5)); public OpenWeatherMapService(IGeneralSettings settings) { _settings = settings; diff --git a/ImmichFrame.WebApi/Settings.Development.json b/ImmichFrame.WebApi/Settings.Development.json new file mode 100644 index 00000000..cd435ae2 --- /dev/null +++ b/ImmichFrame.WebApi/Settings.Development.json @@ -0,0 +1,53 @@ +{ + "General": { + "AuthenticationSecret": null, + "DownloadImages": false, + "RenewImagesDuration": 30, + "Webcalendars": [], + "RefreshAlbumPeopleInterval": 12, + "PhotoDateFormat": "MM/dd/yyyy", + "ImageLocationFormat": "City,State,Country", + "WeatherApiKey": "", + "UnitSystem": "imperial", + "WeatherLatLong": "40.730610,-73.935242", + "Webhook": null, + "Language": "en", + "Margin": "0,0,0,0", + "Interval": 45, + "TransitionDuration": 2, + "ShowClock": true, + "ClockFormat": "hh:mm", + "ShowProgressBar": true, + "ShowPhotoDate": true, + "ShowImageDesc": true, + "ShowPeopleDesc": true, + "ShowAlbumName": true, + "ShowImageLocation": true, + "PrimaryColor": "#f5deb3", + "SecondaryColor": "#000000", + "Style": "none", + "BaseFontSize": "17px", + "ShowWeatherDescription": true, + "UnattendedMode": true, + "ImageZoom": true, + "ImagePan": false, + "ImageFill": false, + "Layout": "splitview" + }, + "Accounts": [ + { + "ImmichServerUrl": "http://192.168.50.111:2238", + "ApiKey": "", + "ImagesFromDate": null, + "ShowMemories": false, + "ShowFavorites": false, + "ShowArchived": false, + "ImagesFromDays": null, + "ImagesUntilDate": "2020-01-02", + "Rating": null, + "Albums": [], + "ExcludedAlbums": [], + "People": [] + } + ] +} \ No newline at end of file From c6688f2ebb4474f8ff71dbfd24a13a8895847272 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 22:43:42 +0000 Subject: [PATCH 4/7] Fix: Correct Moq setups, circuit breaker logic, and test assertions - Standardized ImmichApi mock instantiation across test suites using constructor with dummy arguments (`new Mock("http://dummy-url.com", new HttpClient())`) to resolve proxy creation issues. - Corrected IApiCache mock instantiation to `new Mock()`. - Refined Moq `Verify` calls to use property-based matching (`It.Is(predicate)`) instead of specific object instances for DTOs, fixing verification failures in `AllAssetsRemotePoolTests`. - Made `BaseCircuitBreaker.DoCall` async-aware to correctly handle exceptions from asynchronous primary operations, allowing the circuit breaker fallback to be properly tested and trigger. Updated consuming classes accordingly. - Adjusted cache count assertion in `AllAssetsRemotePoolTests.GetAssetCount_CallsApiAndFallsBack` to reflect expected interactions after the circuit breaker fix. - Updated Moq setup/verify in `PersonAssetsPreloadPoolTests.LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates` to use a detailed predicate helper for `MetadataSearchDto`. These changes resolve a large number of test failures. One test (`LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates`) remains failing due to a Moq verification mismatch that requires further investigation. --- .../Logic/Pool/AggregatingAssetPoolTests.cs | 2 +- .../Preload/AlbumAssetsPreloadPoolTests.cs | 5 ++- .../Preload/FavoriteAssetsPreloadPoolTests.cs | 5 ++- .../Preload/MemoryAssetsPreloadPoolTests.cs | 12 +++-- .../Pool/Preload/PersonAssetsPoolTests.cs | 44 +++++++++++++++---- .../Pool/Preload/PreloadedAssetsPoolTests.cs | 5 ++- .../Pool/Remote/AllAssetsRemotePoolTests.cs | 17 ++++--- .../Logic/Pool/CircuitBreakerPool.cs | 29 +++++++++--- ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs | 20 ++++++++- .../Logic/Pool/Remote/AllAssetsRemotePool.cs | 4 +- 10 files changed, 112 insertions(+), 31 deletions(-) diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs index c72d7e12..f772297d 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs @@ -10,7 +10,7 @@ public class AggregatingAssetPoolTests { private Mock _mockPool1; private Mock _mockPool2; - private MultiAssetPool _aggregatingPool; + private AggregatingAssetPool _aggregatingPool; // Changed type here private List _assetPools; [SetUp] diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs index 3fcbd335..f639f0a4 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/AlbumAssetsPreloadPoolTests.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using System.Net.Http; // Added for HttpClient using ImmichFrame.Core.Logic.Pool.Preload; namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; @@ -30,8 +31,8 @@ private class TestableAlbumAssetsPool(IApiCache apiCache, ImmichApi immichApi, I [SetUp] public void Setup() { - _mockApiCache = new Mock(TimeSpan.MaxValue); - _mockImmichApi = new Mock("", null); + _mockApiCache = new Mock(); + _mockImmichApi = new Mock("http://dummy-url.com", new HttpClient()); _mockAccountSettings = new Mock(); _albumAssetsPool = new TestableAlbumAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs index 6407983b..995b3292 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/FavoriteAssetsPreloadPoolTests.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using System.Net.Http; // Added for HttpClient using ImmichFrame.Core.Logic.Pool.Preload; namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; @@ -34,8 +35,8 @@ public Task> TestLoadAssets(CancellationToken ct = [SetUp] public void Setup() { - _mockApiCache = new Mock(null); - _mockImmichApi = new Mock(null, null); + _mockApiCache = new Mock(); + _mockImmichApi = new Mock("http://dummy-url.com", new HttpClient()); _mockAccountSettings = new Mock(); _favoriteAssetsPool = new TestableFavoriteAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); } diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs index 521b80f5..9026a88f 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/MemoryAssetsPreloadPoolTests.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using System.Net.Http; // Added for HttpClient using ImmichFrame.Core.Logic.Pool.Preload; namespace ImmichFrame.Core.Tests.Logic.Pool.Preload; @@ -23,8 +24,8 @@ public class MemoryAssetsPreloadPoolTests [SetUp] public void Setup() { - _mockApiCache = new Mock(null); // Base constructor requires ILogger and IOptions, pass null for simplicity in mock - _mockImmichApi = new Mock(null, null); // Base constructor requires ILogger, IHttpClientFactory, IOptions, pass null + _mockApiCache = new Mock(); // Base constructor requires ILogger and IOptions, pass null for simplicity in mock + _mockImmichApi = new Mock("http://dummy-url.com", new HttpClient()); // Base constructor requires ILogger, IHttpClientFactory, IOptions, pass null _mockAccountSettings = new Mock(); _memoryAssetsPool = new MemoryAssetsPreloadPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); @@ -156,7 +157,12 @@ public async Task LoadAssets_CorrectlyFormatsDescription_YearsAgo() .ReturnsAsync(memories); // Reset and re-setup cache mock for each iteration to ensure factory is called - _mockApiCache = new Mock(null); + _mockApiCache = new Mock(); + // _mockImmichApi is already set up in the main Setup method, no need to re-mock it here unless its behavior needs to change per iteration. + // If _mockImmichApi needs fresh instances or different setups per loop, it should be: + // _mockImmichApi = new Mock("http://dummy-url.com", new HttpClient()); + // And then specific setups for SearchMemoriesAsync would follow if they differ per tc. + // For now, assuming the existing _mockImmichApi.Setup in the loop is sufficient. _mockApiCache.Setup(c => c.GetOrAddAsync>(It.IsAny(), It.IsAny>>>())) .Returns>>>(async (key, factory) => await factory()); _memoryAssetsPool = new MemoryAssetsPreloadPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs index b33e4ab3..7e0231c9 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using System.Net.Http; // Added for HttpClient using ImmichFrame.Core.Logic.Pool.Preload; namespace ImmichFrame.Core.Tests.Logic.Pool; @@ -34,8 +35,8 @@ public Task> TestLoadAssets(CancellationToken ct = [SetUp] public void Setup() { - _mockApiCache = new Mock(null); - _mockImmichApi = new Mock(null, null); + _mockApiCache = new Mock(); + _mockImmichApi = new Mock("http://dummy-url.com", new HttpClient()); _mockAccountSettings = new Mock(); _personAssetsPool = new TestablePersonAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); @@ -46,6 +47,21 @@ public void Setup() private SearchResponseDto CreateSearchResult(List assets, int total) => new SearchResponseDto { Assets = new SearchAssetResponseDto { Items = assets, Total = total } }; + private bool VerifyMetadataSearchDto(MetadataSearchDto actualDto, Guid expectedPersonId, double expectedPage, int expectedSize) + { + if (actualDto == null) return false; + bool personIdsMatch = actualDto.PersonIds != null && + actualDto.PersonIds.Count == 1 && + actualDto.PersonIds.First() == expectedPersonId; + bool pageMatch = actualDto.Page == expectedPage; + bool typeMatch = actualDto.Type == AssetTypeEnum.IMAGE; // From PeopleAssetsPreloadPool + bool withExifMatch = actualDto.WithExif == true; // From PeopleAssetsPreloadPool + bool withPeopleMatch = actualDto.WithPeople == true; // From PeopleAssetsPreloadPool + bool sizeMatch = actualDto.Size == expectedSize; // From LoadAssetsFromMetadataSearch + + return personIdsMatch && pageMatch && typeMatch && withExifMatch && withPeopleMatch && sizeMatch; + } + [Test] public async Task LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates() { @@ -60,13 +76,19 @@ public async Task LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates() var p2AssetsPage1 = Enumerable.Range(0, 20).Select(i => CreateAsset($"p2_p1_{i}")).ToList(); // Person 1 - Page 1 - _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 1), It.IsAny())) + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => VerifyMetadataSearchDto(d, person1Id, 1.0, batchSize)), + It.IsAny())) .ReturnsAsync(CreateSearchResult(p1AssetsPage1, batchSize)); // Person 1 - Page 2 - _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 2), It.IsAny())) + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => VerifyMetadataSearchDto(d, person1Id, 2.0, batchSize)), + It.IsAny())) .ReturnsAsync(CreateSearchResult(p1AssetsPage2, 30)); // Person 2 - Page 1 - _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id) && d.Page == 1), It.IsAny())) + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => VerifyMetadataSearchDto(d, person2Id, 1.0, batchSize)), + It.IsAny())) .ReturnsAsync(CreateSearchResult(p2AssetsPage1, 20)); // Act @@ -78,9 +100,15 @@ public async Task LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates() Assert.That(result.Any(a => a.Id == "p1_p2_29")); Assert.That(result.Any(a => a.Id == "p2_p1_19")); - _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 1), It.IsAny()), Times.Once); - _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person1Id) && d.Page == 2), It.IsAny()), Times.Once); - _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.PersonIds.Contains(person2Id) && d.Page == 1), It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.Is(d => VerifyMetadataSearchDto(d, person1Id, 1.0, batchSize)), + It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.Is(d => VerifyMetadataSearchDto(d, person1Id, 2.0, batchSize)), + It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.Is(d => VerifyMetadataSearchDto(d, person2Id, 1.0, batchSize)), + It.IsAny()), Times.Once); } [Test] diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs index 54c3d39d..8507992f 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PreloadedAssetsPoolTests.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading.Tasks; using System.Threading; +using System.Net.Http; // Added for HttpClient using ImmichFrame.Core.Logic.Pool.Preload; namespace ImmichFrame.Core.Tests.Logic.Pool; @@ -39,8 +40,8 @@ protected override Task> LoadAssets(CancellationTo [SetUp] public void Setup() { - _mockApiCache = new Mock(null); // ILogger, IOptions - _mockImmichApi = new Mock(null, null); // ILogger, IHttpClientFactory, IOptions + _mockApiCache = new Mock(); // ILogger, IOptions + _mockImmichApi = new Mock("http://dummy-url.com", new HttpClient()); // ILogger, IHttpClientFactory, IOptions _mockAccountSettings = new Mock(); _testPool = new TestablePreloadedAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs index 28c642ec..c1826f6b 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Remote/AllAssetsRemotePoolTests.cs @@ -3,6 +3,7 @@ using ImmichFrame.Core.Api; using ImmichFrame.Core.Interfaces; using ImmichFrame.Core.Logic.Pool.Remote; +using System.Net.Http; // Added for HttpClient namespace ImmichFrame.Core.Tests.Logic.Pool.Remote; @@ -18,7 +19,7 @@ public class AllAssetsRemotePoolTests public void Setup() { _fakeCache = new FixtureHelpers.ForgetfulCountingCache(); - _mockImmichApi = new Mock(null, null); + _mockImmichApi = new Mock("http://dummy-url.com", new HttpClient()); _mockAccountSettings = new Mock(); _allAssetsPool = new AllAssetsRemotePool(_fakeCache, _mockImmichApi.Object, _mockAccountSettings.Object, FixtureHelpers.TestLogger()); @@ -50,7 +51,9 @@ public async Task GetAssetCount_CallsApiAndCache() // Assert Assert.That(count, Is.EqualTo(100)); - _mockImmichApi.Verify(api => api.SearchAssetStatisticsAsync(new StatisticsSearchDto { Type = AssetTypeEnum.IMAGE }, It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetStatisticsAsync( + It.Is(dto => dto.Type == AssetTypeEnum.IMAGE), + It.IsAny()), Times.Once); Assert.That(_fakeCache.Count, Is.EqualTo(1)); } @@ -59,8 +62,12 @@ public async Task GetAssetCount_CallsApiAndCache() public async Task GetAssetCount_CallsApiAndFallsBack() { // Arrange - var stats = new AssetStatsResponseDto { Images = 100 }; - _mockImmichApi.Setup(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny())).ReturnsAsync(stats); + // Setup the primary call to throw an exception, forcing the fallback + _mockImmichApi.Setup(api => api.SearchAssetStatisticsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Simulated API failure for primary call")); + + var fallbackStats = new AssetStatsResponseDto { Images = 100 }; + _mockImmichApi.Setup(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny())).ReturnsAsync(fallbackStats); // Act var count = await _allAssetsPool.GetAssetCount(); @@ -68,7 +75,7 @@ public async Task GetAssetCount_CallsApiAndFallsBack() // Assert Assert.That(count, Is.EqualTo(100)); _mockImmichApi.Verify(api => api.GetAssetStatisticsAsync(null, false, null, It.IsAny()), Times.Once); - Assert.That(_fakeCache.Count, Is.EqualTo(1)); + Assert.That(_fakeCache.Count, Is.EqualTo(2), "Cache count should be 2: one for the failed primary attempt, one for the successful fallback."); } [Test] diff --git a/ImmichFrame.Core/Logic/Pool/CircuitBreakerPool.cs b/ImmichFrame.Core/Logic/Pool/CircuitBreakerPool.cs index d3b1f2e5..2301249f 100644 --- a/ImmichFrame.Core/Logic/Pool/CircuitBreakerPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CircuitBreakerPool.cs @@ -19,13 +19,13 @@ ILogger logger ) : BaseCircuitBreaker(logger), IAssetPool where T : BaseCircuitBreakerPool { - public Task GetAssetCount(CancellationToken ct = default) - => DoCall( + public async Task GetAssetCount(CancellationToken ct = default) + => await DoCall( () => primary.GetAssetCount(ct), () => secondary.GetAssetCount(ct)); - public Task> GetAssets(int requested, CancellationToken ct = default) - => DoCall( + public async Task> GetAssets(int requested, CancellationToken ct = default) + => await DoCall( () => primary.GetAssets(requested, ct), () => secondary.GetAssets(requested, ct)); } @@ -36,6 +36,26 @@ public class BaseCircuitBreaker(ILogger logger) private static readonly TimeSpan BreakerTimeout = TimeSpan.FromDays(7); + // Made DoCall async to correctly handle exceptions from async primaryFn + protected async Task DoCall(Func> primaryFnAsync, Func> secondaryFnAsync) + { + if (!IsBroken) + { + try + { + return await primaryFnAsync(); + } + catch (Exception e) + { + logger.LogWarning(e, "Failure when calling primary; breaking circuit and using fallback"); + Break(); + } + } + + return await secondaryFnAsync(); + } + + // Overload for synchronous functions, if still needed elsewhere, though current usage seems async protected TOut DoCall(Func primaryFn, Func secondaryFn) { if (!IsBroken) @@ -50,7 +70,6 @@ protected TOut DoCall(Func primaryFn, Func secondaryFn) Break(); } } - return secondaryFn(); } diff --git a/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs b/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs index 84e9bff9..cf336282 100644 --- a/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs +++ b/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs @@ -13,7 +13,25 @@ public override async Task GetAssetCount(CancellationToken ct = default) protected override async Task GetNextAsset(CancellationToken ct) { - var pool = await delegates.ChooseOne(async @delegate=> await @delegate.GetAssetCount(ct)); + if (!delegates.Any()) + { + return null; + } + + var assetCounts = await Task.WhenAll(delegates.Select(async d => await d.GetAssetCount(ct))); + if (assetCounts.Sum() == 0) + { + return null; + } + + var pool = await delegates.ChooseOne(async @delegate => await @delegate.GetAssetCount(ct)); + // It's possible that the chosen pool.GetAssetCount(ct) returns 0 if it was just exhausted + // by another concurrent request. Or if ChooseOne selects a pool that has 0 assets + // while other pools still have assets (e.g. if random index falls into its zero range). + // The original ChooseOne logic could throw if totalCount is 0. + // The new checks above should prevent ChooseOne from being called with totalCount = 0. + // However, the selected 'pool' itself might still have 0 assets. + // In that case, pool.GetAssets(1, ct) should return an empty list, and FirstOrDefault() will be null. return (await pool.GetAssets(1, ct)).FirstOrDefault(); } } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs b/ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs index fb2d1ffa..aedca273 100644 --- a/ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs +++ b/ImmichFrame.Core/Logic/Pool/Remote/AllAssetsRemotePool.cs @@ -6,8 +6,8 @@ namespace ImmichFrame.Core.Logic.Pool.Remote; public class AllAssetsRemotePool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings, ILogger logger) : BaseCircuitBreaker(logger), IAssetPool { - public virtual Task GetAssetCount(CancellationToken ct = default) - => DoCall( + public virtual async Task GetAssetCount(CancellationToken ct = default) + => await DoCall( () => GetFilteredAssetCount(ct), () => GetTotalAssetCount(ct)); From 60fee82df6939853ed29bf6b30e98b3f3103e5ea Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 00:09:42 +0000 Subject: [PATCH 5/7] Fix: Address final test failures and circuit breaker logic - Corrected Moq predicate in `PersonAssetsPreloadPoolTests` with a detailed helper method `VerifyMetadataSearchDto` including explicit HasValue checks for nullable properties. (Note: This test still fails, Moq verification issue persists). - Adjusted assertion in `AllAssetsRemotePoolTests.GetAssetCount_CallsApiAndFallsBack` for `_fakeCache.Count` to be 2, as the circuit breaker fix correctly logs fallback and the cache interacts twice. This resolves all but one test failure. The remaining failure in `PersonAssetsPreloadPoolTests.LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates` is a persistent Moq verification issue. --- .../Logic/Pool/Preload/PersonAssetsPoolTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs index 7e0231c9..7af6068b 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs @@ -53,11 +53,12 @@ private bool VerifyMetadataSearchDto(MetadataSearchDto actualDto, Guid expectedP bool personIdsMatch = actualDto.PersonIds != null && actualDto.PersonIds.Count == 1 && actualDto.PersonIds.First() == expectedPersonId; - bool pageMatch = actualDto.Page == expectedPage; + // Explicitly check HasValue for nullable types before comparing Value + bool pageMatch = actualDto.Page.HasValue && actualDto.Page.Value == expectedPage; bool typeMatch = actualDto.Type == AssetTypeEnum.IMAGE; // From PeopleAssetsPreloadPool bool withExifMatch = actualDto.WithExif == true; // From PeopleAssetsPreloadPool bool withPeopleMatch = actualDto.WithPeople == true; // From PeopleAssetsPreloadPool - bool sizeMatch = actualDto.Size == expectedSize; // From LoadAssetsFromMetadataSearch + bool sizeMatch = actualDto.Size.HasValue && actualDto.Size.Value == expectedSize; // From LoadAssetsFromMetadataSearch return personIdsMatch && pageMatch && typeMatch && withExifMatch && withPeopleMatch && sizeMatch; } From 0092ab4e436f3b1cbbb6d70f50c2206415b779eb Mon Sep 17 00:00:00 2001 From: Jonathan Gilbert Date: Mon, 30 Jun 2025 21:32:57 -0400 Subject: [PATCH 6/7] fix: PersonAssetsPoolTests --- .../Pool/Preload/PersonAssetsPoolTests.cs | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs index 7af6068b..d4647618 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/Preload/PersonAssetsPoolTests.cs @@ -57,8 +57,8 @@ private bool VerifyMetadataSearchDto(MetadataSearchDto actualDto, Guid expectedP bool pageMatch = actualDto.Page.HasValue && actualDto.Page.Value == expectedPage; bool typeMatch = actualDto.Type == AssetTypeEnum.IMAGE; // From PeopleAssetsPreloadPool bool withExifMatch = actualDto.WithExif == true; // From PeopleAssetsPreloadPool - bool withPeopleMatch = actualDto.WithPeople == true; // From PeopleAssetsPreloadPool bool sizeMatch = actualDto.Size.HasValue && actualDto.Size.Value == expectedSize; // From LoadAssetsFromMetadataSearch + bool withPeopleMatch = actualDto.WithPeople == true; // From PeopleAssetsPreloadPool return personIdsMatch && pageMatch && typeMatch && withExifMatch && withPeopleMatch && sizeMatch; } @@ -80,17 +80,20 @@ public async Task LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates() _mockImmichApi.Setup(api => api.SearchAssetsAsync( It.Is(d => VerifyMetadataSearchDto(d, person1Id, 1.0, batchSize)), It.IsAny())) - .ReturnsAsync(CreateSearchResult(p1AssetsPage1, batchSize)); + .ReturnsAsync(CreateSearchResult(p1AssetsPage1, batchSize)) + .Verifiable(); // Person 1 - Page 2 _mockImmichApi.Setup(api => api.SearchAssetsAsync( It.Is(d => VerifyMetadataSearchDto(d, person1Id, 2.0, batchSize)), It.IsAny())) - .ReturnsAsync(CreateSearchResult(p1AssetsPage2, 30)); + .ReturnsAsync(CreateSearchResult(p1AssetsPage2, 30)) + .Verifiable(); // Person 2 - Page 1 _mockImmichApi.Setup(api => api.SearchAssetsAsync( It.Is(d => VerifyMetadataSearchDto(d, person2Id, 1.0, batchSize)), It.IsAny())) - .ReturnsAsync(CreateSearchResult(p2AssetsPage1, 20)); + .ReturnsAsync(CreateSearchResult(p2AssetsPage1, 20)) + .Verifiable(); // Act var result = (await _personAssetsPool.TestLoadAssets()).ToList(); @@ -101,15 +104,7 @@ public async Task LoadAssets_CallsSearchAssetsForEachPerson_AndPaginates() Assert.That(result.Any(a => a.Id == "p1_p2_29")); Assert.That(result.Any(a => a.Id == "p2_p1_19")); - _mockImmichApi.Verify(api => api.SearchAssetsAsync( - It.Is(d => VerifyMetadataSearchDto(d, person1Id, 1.0, batchSize)), - It.IsAny()), Times.Once); - _mockImmichApi.Verify(api => api.SearchAssetsAsync( - It.Is(d => VerifyMetadataSearchDto(d, person1Id, 2.0, batchSize)), - It.IsAny()), Times.Once); - _mockImmichApi.Verify(api => api.SearchAssetsAsync( - It.Is(d => VerifyMetadataSearchDto(d, person2Id, 1.0, batchSize)), - It.IsAny()), Times.Once); + _mockImmichApi.VerifyAll(); } [Test] From f6292b6a9be8fce6f864a6784e88e34356f8b33e Mon Sep 17 00:00:00 2001 From: Jonathan Gilbert Date: Wed, 2 Jul 2025 17:12:23 -0400 Subject: [PATCH 7/7] Feat: Remove preloading assets for all but memories --- .../Logic/Pool/FixtureHelpers.cs | 2 +- ImmichFrame.Core/Helpers/IApiCache.cs | 8 +- ImmichFrame.Core/Logic/AssetPoolFactory.cs | 17 ++-- .../Logic/Pool/Filter/IAssetFilter.cs | 6 ++ .../Pool/Filter/OnlyFavoritesAssetFilter.cs | 6 ++ .../Pool/Remote/FavoriteAssetsRemotePool.cs | 36 +------- .../Pool/Remote/FilteredAssetsRemotePool.cs | 83 +++++++++++++++++++ .../Pool/Remote/SearchBasedRemotePool.cs | 49 +++++++++++ .../Pool/Remote/SingleAlbumRemotePool.cs | 11 +++ .../Pool/Remote/SinglePersonRemotePool.cs | 10 +++ 10 files changed, 185 insertions(+), 43 deletions(-) create mode 100644 ImmichFrame.Core/Logic/Pool/Filter/IAssetFilter.cs create mode 100644 ImmichFrame.Core/Logic/Pool/Filter/OnlyFavoritesAssetFilter.cs create mode 100644 ImmichFrame.Core/Logic/Pool/Remote/FilteredAssetsRemotePool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/Remote/SearchBasedRemotePool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/Remote/SingleAlbumRemotePool.cs create mode 100644 ImmichFrame.Core/Logic/Pool/Remote/SinglePersonRemotePool.cs diff --git a/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs b/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs index 64218c16..f0d227ba 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/FixtureHelpers.cs @@ -16,7 +16,7 @@ public static ILogger TestLogger() public class ForgetfulCountingCache : IApiCache { public int Count = 0; - public Task GetOrAddAsync(string key, Func> factory) + public Task GetOrAddAsync(object key, Func> factory) { Count++; return factory(); diff --git a/ImmichFrame.Core/Helpers/IApiCache.cs b/ImmichFrame.Core/Helpers/IApiCache.cs index 8859d03b..a2fc211d 100644 --- a/ImmichFrame.Core/Helpers/IApiCache.cs +++ b/ImmichFrame.Core/Helpers/IApiCache.cs @@ -1,19 +1,19 @@ public interface IApiCache { - Task GetOrAddAsync(string key, Func> factory); + Task GetOrAddAsync(object key, Func> factory); } public class ApiCache : IApiCache, IDisposable { private readonly TimeSpan _cacheDuration; - private readonly Dictionary _cache = new(); + private readonly Dictionary _cache = new(); public ApiCache(TimeSpan cacheDuration) { _cacheDuration = cacheDuration; } - public async Task GetOrAddAsync(string key, Func> factory) + public async Task GetOrAddAsync(object key, Func> factory) { if (_cache.TryGetValue(key, out var entry)) { @@ -33,7 +33,7 @@ public async Task GetOrAddAsync(string key, Func> factory) return data; } - public void Invalidate(string key) + public void Invalidate(object key) { _cache.Remove(key); } diff --git a/ImmichFrame.Core/Logic/AssetPoolFactory.cs b/ImmichFrame.Core/Logic/AssetPoolFactory.cs index ba557657..112731c7 100644 --- a/ImmichFrame.Core/Logic/AssetPoolFactory.cs +++ b/ImmichFrame.Core/Logic/AssetPoolFactory.cs @@ -23,6 +23,18 @@ public IAssetPool BuildPool(IAccountSettings accountSettings, IApiCache apiCache var pools = new List(); + if (accountSettings.Albums.Any() || accountSettings.People.Any()) + pools.Add( + new CircuitBreakerPool( + new FilteredAssetsRemotePool(apiCache, immichApi, accountSettings, loggerFactory.CreateLogger()), + new MultiAssetPool(new List + { + new AlbumAssetsPreloadPool(apiCache, immichApi, accountSettings), + new PersonAssetsPreloadPool(apiCache, immichApi, accountSettings) + }), + loggerFactory.CreateLogger() + )); + if (accountSettings.ShowFavorites) pools.Add( new CircuitBreakerPool( @@ -34,11 +46,6 @@ public IAssetPool BuildPool(IAccountSettings accountSettings, IApiCache apiCache if (accountSettings.ShowMemories) pools.Add(new MemoryAssetsPreloadPool(apiCache, immichApi, accountSettings)); - if (accountSettings.Albums.Any()) - pools.Add(new AlbumAssetsPreloadPool(apiCache, immichApi, accountSettings)); - - if (accountSettings.People.Any()) - pools.Add(new PersonAssetsPreloadPool(apiCache, immichApi, accountSettings)); return new MultiAssetPool(pools); } diff --git a/ImmichFrame.Core/Logic/Pool/Filter/IAssetFilter.cs b/ImmichFrame.Core/Logic/Pool/Filter/IAssetFilter.cs new file mode 100644 index 00000000..78c2d600 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/Filter/IAssetFilter.cs @@ -0,0 +1,6 @@ +namespace ImmichFrame.Core.Logic.Pool.Filter; + +public interface IAssetFilter +{ + +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/Filter/OnlyFavoritesAssetFilter.cs b/ImmichFrame.Core/Logic/Pool/Filter/OnlyFavoritesAssetFilter.cs new file mode 100644 index 00000000..5b13f92b --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/Filter/OnlyFavoritesAssetFilter.cs @@ -0,0 +1,6 @@ +namespace ImmichFrame.Core.Logic.Pool.Filter; + +public class OnlyFavoritesAssetFilter +{ + +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs b/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs index abccbc1a..5a0ce3a6 100644 --- a/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs +++ b/ImmichFrame.Core/Logic/Pool/Remote/FavoriteAssetsRemotePool.cs @@ -3,38 +3,8 @@ namespace ImmichFrame.Core.Logic.Pool.Remote; -public class FavoriteAssetsRemotePool(ILogger _logger, ImmichApi _immichApi) : IAssetPool +public class FavoriteAssetsRemotePool(ILogger logger, ImmichApi immichApi) : SearchBasedRemotePool(immichApi, logger) { - public async Task GetAssetCount(CancellationToken ct = default) - { - try - { - return (await _immichApi.SearchAssetStatisticsAsync(new StatisticsSearchDto - { - IsFavorite = true, - }, ct)).Total; - } - catch (Exception e) - { - _logger.LogError(e, $"Failed to get asset count, falling back to preload [{e.Message}]"); - throw; - } - } - - public async Task> GetAssets(int requested, CancellationToken ct = default) - { - try - { - return (await _immichApi.SearchAssetsAsync(new MetadataSearchDto - { - Size = requested, - IsFavorite = true, - }, ct)).Assets.Items; - } - catch (Exception e) - { - _logger.LogError(e, $"Failed to get assets, falling back to preload [{e.Message}]"); - throw; - } - } + protected override void ConfigureAssetQuery(MetadataSearchDto dto) => dto.IsFavorite = true; + protected override void ConfigureCountQuery(StatisticsSearchDto dto) => dto.IsFavorite = true; } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/Remote/FilteredAssetsRemotePool.cs b/ImmichFrame.Core/Logic/Pool/Remote/FilteredAssetsRemotePool.cs new file mode 100644 index 00000000..da111d2f --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/Remote/FilteredAssetsRemotePool.cs @@ -0,0 +1,83 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.Core.Logic.Pool.Remote; + +public class FilteredAssetsRemotePool( + IApiCache apiCache, + ImmichApi immichApi, + IAccountSettings accountSettings, + ILogger logger) : BaseCircuitBreaker(logger), IAssetPool +{ + private readonly object _filteredCacheKey = new(); + private readonly object _totalCacheKey = new(); + + public virtual async Task GetAssetCount(CancellationToken ct = default) + => await DoCall( + () => GetFilteredAssetCount(ct), + () => GetTotalAssetCount(ct)); + + private async Task GetFilteredAssetCount(CancellationToken ct = default) + => (await apiCache.GetOrAddAsync(_filteredCacheKey, + () => immichApi.SearchAssetStatisticsAsync(BuildCountQuery(), ct))).Total; + + private async Task GetTotalAssetCount(CancellationToken ct = default) + => (await apiCache.GetOrAddAsync(_totalCacheKey, + () => immichApi.GetAssetStatisticsAsync(null, false, null, ct))).Images; + + private RandomSearchDto BuildAssetQuery(int requested) => new() + { + Size = requested, + Type = AssetTypeEnum.IMAGE, + AlbumIds = accountSettings.Albums, + PersonIds = accountSettings.People, + IsFavorite = accountSettings.ShowFavorites, + WithExif = true, + WithPeople = true, + Visibility = accountSettings.ShowArchived ? AssetVisibility.Archive : AssetVisibility.Timeline, + TakenBefore = accountSettings.ImagesUntilDate, + Rating = accountSettings.Rating, + TakenAfter = accountSettings.ImagesFromDate ?? (accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-accountSettings.ImagesFromDays.Value) : null) + }; + + private StatisticsSearchDto BuildCountQuery() => new() + { + Type = AssetTypeEnum.IMAGE, + AlbumIds = accountSettings.Albums, + PersonIds = accountSettings.People, + IsFavorite = accountSettings.ShowFavorites, + Visibility = accountSettings.ShowArchived ? AssetVisibility.Archive : AssetVisibility.Timeline, + TakenBefore = accountSettings.ImagesUntilDate, + Rating = accountSettings.Rating, + TakenAfter = accountSettings.ImagesFromDate ?? (accountSettings.ImagesFromDays.HasValue ? DateTime.Today.AddDays(-accountSettings.ImagesFromDays.Value) : null) + }; + + public async Task> GetAssets(int requested, CancellationToken ct = default) + { + var assets = await immichApi.SearchRandomAsync(BuildAssetQuery(requested), ct); + + if (accountSettings.ExcludedAlbums.Count == 0) return assets; + + var excludedAssetList = await GetExcludedAlbumAssets(ct); + var excludedAssetSet = excludedAssetList.Select(x => x.Id).ToHashSet(); + assets = assets.Where(x => !excludedAssetSet.Contains(x.Id)).ToList(); + + return assets; + } + + + private async Task> GetExcludedAlbumAssets(CancellationToken ct = default) + { + var excludedAlbumAssets = new List(); + + foreach (var albumId in accountSettings.ExcludedAlbums) + { + var albumInfo = await immichApi.GetAlbumInfoAsync(albumId, null, null, ct); + + excludedAlbumAssets.AddRange(albumInfo.Assets); + } + + return excludedAlbumAssets; + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/Remote/SearchBasedRemotePool.cs b/ImmichFrame.Core/Logic/Pool/Remote/SearchBasedRemotePool.cs new file mode 100644 index 00000000..3a997a52 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/Remote/SearchBasedRemotePool.cs @@ -0,0 +1,49 @@ +using ImmichFrame.Core.Api; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.Core.Logic.Pool.Remote; + +public abstract class SearchBasedRemotePool(ImmichApi immichApi, ILogger logger) : IAssetPool where T : SearchBasedRemotePool +{ + public async Task GetAssetCount(CancellationToken ct = default) + { + try + { + var query = new StatisticsSearchDto + { + }; + + ConfigureCountQuery(query); + + return (await immichApi.SearchAssetStatisticsAsync(query, ct)).Total; + } + catch (Exception e) + { + logger.LogError(e, $"Failed to get asset count, falling back to preload [{e.Message}]"); + throw; + } + } + + public async Task> GetAssets(int requested, CancellationToken ct = default) + { + try + { + var query = new MetadataSearchDto + { + Size = requested + }; + + ConfigureAssetQuery(query); + + return (await immichApi.SearchAssetsAsync(query, ct)).Assets.Items; + } + catch (Exception e) + { + logger.LogError(e, $"Failed to get assets, falling back to preload [{e.Message}]"); + throw; + } + } + + protected abstract void ConfigureCountQuery(StatisticsSearchDto dto); + protected abstract void ConfigureAssetQuery(MetadataSearchDto dto); +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/Remote/SingleAlbumRemotePool.cs b/ImmichFrame.Core/Logic/Pool/Remote/SingleAlbumRemotePool.cs new file mode 100644 index 00000000..169aa4b7 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/Remote/SingleAlbumRemotePool.cs @@ -0,0 +1,11 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Helpers; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.Core.Logic.Pool.Remote; + +public class SingleAlbumRemotePool(Guid albumId, ILogger logger, ImmichApi immichApi) : SearchBasedRemotePool(immichApi, logger) +{ + protected override void ConfigureCountQuery(StatisticsSearchDto dto) => dto.AlbumIds = new List { albumId }; + protected override void ConfigureAssetQuery(MetadataSearchDto dto) => dto.AlbumIds = new List { albumId }; +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/Remote/SinglePersonRemotePool.cs b/ImmichFrame.Core/Logic/Pool/Remote/SinglePersonRemotePool.cs new file mode 100644 index 00000000..850f71ee --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/Remote/SinglePersonRemotePool.cs @@ -0,0 +1,10 @@ +using ImmichFrame.Core.Api; +using Microsoft.Extensions.Logging; + +namespace ImmichFrame.Core.Logic.Pool.Remote; + +public class SinglePersonRemotePool(Guid personId, ILogger logger, ImmichApi immichApi) : SearchBasedRemotePool(immichApi, logger) +{ + protected override void ConfigureCountQuery(StatisticsSearchDto dto) => dto.PersonIds = new List { personId }; + protected override void ConfigureAssetQuery(MetadataSearchDto dto) => dto.PersonIds = new List { personId }; +} \ No newline at end of file