From 53bbe91dc0b0c302783f3fa7af0c5270b95f2ff3 Mon Sep 17 00:00:00 2001 From: Matthew Livingstone Date: Thu, 5 Feb 2026 16:40:27 -0500 Subject: [PATCH 1/7] Add exhaustive shuffle support for albums --- .../Logic/Pool/AggregatingAssetPoolTests.cs | 38 +++++----- .../Logic/Pool/AlbumAssetsPoolTests.cs | 15 ++-- .../Logic/Pool/AllAssetsPoolTests.cs | 15 ++-- .../Logic/Pool/CachingApiAssetsPoolTests.cs | 27 ++++--- .../Logic/Pool/MemoryAssetsPoolTests.cs | 17 +++-- .../Logic/Pool/MultiAssetPoolTests.cs | 70 ++++++++++--------- .../Logic/Pool/QueuingAssetPoolTests.cs | 50 +++++++------ ImmichFrame.Core/Api/AssetListResponseDto.cs | 8 +++ .../Interfaces/IImmichFrameLogic.cs | 8 +-- .../Interfaces/IRequestContext.cs | 7 ++ .../Interfaces/IServerSettings.cs | 1 + .../TotalAccountImagesSelectionStrategy.cs | 16 ++--- .../Logic/MultiImmichFrameLogicDelegate.cs | 9 +-- .../Logic/Pool/AggregatingAssetPool.cs | 10 +-- .../Logic/Pool/AlbumAssetsPool.cs | 5 +- ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs | 4 +- .../Logic/Pool/CachingApiAssetsPool.cs | 22 +++++- ImmichFrame.Core/Logic/Pool/IAssetPool.cs | 10 +-- ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs | 13 ++-- .../Logic/Pool/QueuingAssetPool.cs | 12 ++-- .../Logic/PooledImmichFrameLogic.cs | 8 +-- .../Services/RequestContextService.cs | 9 +++ .../Resources/TestV1.json | 1 + .../Resources/TestV2.json | 1 + ImmichFrame.WebApi.Tests/Resources/TestV2.yml | 1 + .../Controllers/AssetController.cs | 17 +++-- .../Helpers/Config/ServerSettingsV1.cs | 3 + .../Helpers/RequestContextMiddleware.cs | 28 ++++++++ .../Models/ClientSettingsDto.cs | 4 +- ImmichFrame.WebApi/Models/ServerSettings.cs | 1 + ImmichFrame.WebApi/Program.cs | 6 ++ .../lib/components/home-page/home-page.svelte | 10 ++- immichFrame.Web/src/lib/immichFrameApi.ts | 14 ++-- 33 files changed, 305 insertions(+), 155 deletions(-) create mode 100644 ImmichFrame.Core/Api/AssetListResponseDto.cs create mode 100644 ImmichFrame.Core/Interfaces/IRequestContext.cs create mode 100644 ImmichFrame.Core/Services/RequestContextService.cs create mode 100644 ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs index c72d7e12..468189c7 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AggregatingAssetPoolTests.cs @@ -2,6 +2,7 @@ using NUnit.Framework; using ImmichFrame.Core.Api; // For AssetResponseDto using ImmichFrame.Core.Logic.Pool; // For AggregatingAssetPool and IAssetPool (non-generic) +using ImmichFrame.Core.Interfaces; namespace ImmichFrame.Core.Tests.Logic.Pool { @@ -12,6 +13,7 @@ public class AggregatingAssetPoolTests private Mock _mockPool2; private MultiAssetPool _aggregatingPool; private List _assetPools; + private Mock _mockRequestContext; [SetUp] public void Setup() @@ -19,6 +21,10 @@ public void Setup() _mockPool1 = new Mock(); _mockPool2 = new Mock(); _assetPools = new List(); + _mockRequestContext = new Mock(); + // Default RequestContext + _mockRequestContext.Setup(x => x.AssetOffset).Returns(0); + // AggregatingAssetPool takes IEnumerable in constructor } @@ -55,7 +61,7 @@ public async Task GetAssetCount_MultiplePools_ReturnsSumOfCounts() public async Task GetAssets_RequestZeroAssets_ReturnsEmptyCollection() { _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool - var result = await _aggregatingPool.GetAssets(0, CancellationToken.None); + var result = await _aggregatingPool.GetAssets(0, _mockRequestContext.Object, CancellationToken.None); Assert.That(result, Is.Empty); } @@ -69,7 +75,7 @@ public async Task GetAssets_TotalLessThanRequested_ReturnsAllAvailableAssets() _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())) .ReturnsAsync(() => (long)pool1AvailableAssets.Count); - _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) // AggregatingAssetPool.GetNextAsset calls GetAssets(1,...) + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, It.IsAny())) // AggregatingAssetPool.GetNextAsset calls GetAssets(1,...) .ReturnsAsync(() => pool1AvailableAssets.Any() ? new List { pool1AvailableAssets.Dequeue() } : new List()); @@ -77,7 +83,7 @@ public async Task GetAssets_TotalLessThanRequested_ReturnsAllAvailableAssets() _assetPools.Add(_mockPool1.Object); _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool - var result = (await _aggregatingPool.GetAssets(5, CancellationToken.None)).ToList(); + var result = (await _aggregatingPool.GetAssets(5, _mockRequestContext.Object, 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); @@ -90,13 +96,13 @@ public async Task GetAssets_TotalMoreThanRequested_AggregatesAssetsFromPools() 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())) + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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())) + _mockPool2.Setup(p => p.GetAssets(1, _mockRequestContext.Object, It.IsAny())) .ReturnsAsync(() => pool2Queue.Any() ? new List { pool2Queue.Dequeue() } : new List()); _assetPools.Add(_mockPool1.Object); @@ -104,7 +110,7 @@ public async Task GetAssets_TotalMoreThanRequested_AggregatesAssetsFromPools() _aggregatingPool = new MultiAssetPool(_assetPools); // Use MultiAssetPool // Request 3 assets. Pool1 has 2, Pool2 has 1. - var result = (await _aggregatingPool.GetAssets(3, CancellationToken.None)).ToList(); + var result = (await _aggregatingPool.GetAssets(3, _mockRequestContext.Object, 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)); @@ -121,7 +127,7 @@ public async Task GetAssets_PoolReturnsFewerAssetsThanCountSuggests_HandlesGrace // 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())) + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, It.IsAny())) .ReturnsAsync(() => { if (pool1AvailableAssets.Any()) @@ -138,7 +144,7 @@ public async Task GetAssets_PoolReturnsFewerAssetsThanCountSuggests_HandlesGrace var originalPool2Assets = new List { p2a1 }; _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())) .ReturnsAsync(() => (long)pool2AvailableAssets.Count); - _mockPool2.Setup(p => p.GetAssets(1, It.IsAny())) + _mockPool2.Setup(p => p.GetAssets(1, _mockRequestContext.Object, It.IsAny())) .ReturnsAsync(() => pool2AvailableAssets.Any() ? new List { pool2AvailableAssets.Dequeue() } : new List()); _assetPools.Add(_mockPool1.Object); @@ -150,7 +156,7 @@ public async Task GetAssets_PoolReturnsFewerAssetsThanCountSuggests_HandlesGrace // 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(); + var result = (await _aggregatingPool.GetAssets(5, _mockRequestContext.Object, CancellationToken.None)).ToList(); Assert.That(result.Count, Is.EqualTo(2)); var expectedTotalAssets = originalPool1Assets.Concat(originalPool2Assets).ToList(); @@ -168,12 +174,12 @@ public async Task GetNextAssetBehavior_RetrievesAllAssets() 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())) + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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())) + _mockPool2.Setup(p => p.GetAssets(1, _mockRequestContext.Object, It.IsAny())) .ReturnsAsync(() => q2.Any() ? new List { q2.Dequeue() } : new List()); _assetPools.Add(_mockPool1.Object); @@ -182,7 +188,7 @@ public async Task GetNextAssetBehavior_RetrievesAllAssets() var retrievedAssets = new List(); AssetResponseDto currentAsset; - while ((currentAsset = (await _aggregatingPool.GetAssets(1, CancellationToken.None)).FirstOrDefault()) != null) + while ((currentAsset = (await _aggregatingPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None)).FirstOrDefault()) != null) { retrievedAssets.Add(currentAsset); } @@ -191,18 +197,18 @@ public async Task GetNextAssetBehavior_RetrievesAllAssets() 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 + Assert.That((await _aggregatingPool.GetAssets(1, _mockRequestContext.Object, 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()); + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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); + Assert.That((await _aggregatingPool.GetAssets(1, _mockRequestContext.Object, 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 index c26039bf..4546974e 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs @@ -13,6 +13,7 @@ public class AlbumAssetsPoolTests private Mock _mockImmichApi; private Mock _mockAccountSettings; private AlbumAssetsPool _albumAssetsPool; + private Mock _mockRequestContext; [SetUp] public void Setup() @@ -29,6 +30,10 @@ public void Setup() _mockAccountSettings = new Mock(); _albumAssetsPool = new AlbumAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + _mockRequestContext = new Mock(); + // Default RequestContext + _mockRequestContext.Setup(x => x.AssetOffset).Returns(0); + _mockAccountSettings.SetupGet(s => s.Albums).Returns(new List()); _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List()); } @@ -56,7 +61,7 @@ public async Task LoadAssets_ReturnsAssetsPresentIIncludedNotExcludedAlbums() .ReturnsAsync(new AlbumResponseDto { Assets = new List { assetB, assetC } }); // Act - var result = (await _albumAssetsPool.GetAssets(25)).ToList(); + var result = (await _albumAssetsPool.GetAssets(25, _mockRequestContext.Object)).ToList(); // Assert Assert.That(result.Count, Is.EqualTo(2)); @@ -75,7 +80,7 @@ public async Task LoadAssets_NoIncludedAlbums_ReturnsEmpty() .ReturnsAsync(new AlbumResponseDto { Assets = new List { CreateAsset("excluded_only") } }); - var result = (await _albumAssetsPool.GetAssets(25)).ToList(); + var result = (await _albumAssetsPool.GetAssets(25, _mockRequestContext.Object)).ToList(); Assert.That(result, Is.Empty); } @@ -89,7 +94,7 @@ public async Task LoadAssets_NoExcludedAlbums_ReturnsAlbums() _mockImmichApi.Setup(api => api.GetAlbumInfoAsync(album1Id, null, null, It.IsAny())) .ReturnsAsync(new AlbumResponseDto { Assets = new List { CreateAsset("A") } }); - var result = (await _albumAssetsPool.GetAssets(25)).ToList(); + var result = (await _albumAssetsPool.GetAssets(25, _mockRequestContext.Object)).ToList(); Assert.That(result.Count, Is.EqualTo(1)); Assert.That(result.Any(a => a.Id == "A")); } @@ -99,7 +104,7 @@ public async Task LoadAssets_NullAlbums_ReturnsEmpty() { _mockAccountSettings.SetupGet(s => s.Albums).Returns((List)null); - var result = (await _albumAssetsPool.GetAssets(25)).ToList(); + var result = (await _albumAssetsPool.GetAssets(25, _mockRequestContext.Object)).ToList(); Assert.That(result, Is.Empty); // the absence of an error, whereas before a null pointer exception would be thrown, indicates success. @@ -110,7 +115,7 @@ public async Task LoadAssets_NullExcludedAlbums_Succeeds() { _mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns((List)null); - var result = (await _albumAssetsPool.GetAssets(25)).ToList(); + var result = (await _albumAssetsPool.GetAssets(25, _mockRequestContext.Object)).ToList(); Assert.That(result, Is.Empty); // the absence of an error, whereas before a null pointer exception would be thrown, indicates success. diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs index c660b45b..d1795fa2 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs @@ -12,6 +12,7 @@ public class AllAssetsPoolTests private Mock _mockApiCache; private Mock _mockImmichApi; private Mock _mockAccountSettings; + private Mock _mockRequestContext; private AllAssetsPool _allAssetsPool; [SetUp] @@ -20,6 +21,7 @@ public void Setup() _mockApiCache = new Mock(); _mockImmichApi = new Mock(null, null); _mockAccountSettings = new Mock(); + _mockRequestContext = new Mock(); _allAssetsPool = new AllAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); // Default account settings @@ -43,6 +45,9 @@ public void Setup() It.IsAny>>>() )) .Returns>>>(async (key, factory) => await factory()); + + // Default RequestContext + _mockRequestContext.Setup(x => x.AssetOffset).Returns(0); } private List CreateSampleAssets(int count, string idPrefix, AssetTypeEnum type, int? rating = null) @@ -111,7 +116,7 @@ public async Task GetAssets_CallsSearchRandomAsync_WithCorrectParameters_OnlyIma .ReturnsAsync(returnedAssets.Where(a => a.Type == AssetTypeEnum.IMAGE).ToList()); // Act - var assets = await _allAssetsPool.GetAssets(requestedImageCount); + var assets = await _allAssetsPool.GetAssets(requestedImageCount, _mockRequestContext.Object); // Assert Assert.That(assets.Count(), Is.EqualTo(requestedImageCount)); @@ -142,7 +147,7 @@ public async Task GetAssets_CallsSearchRandomAsync_WithCorrectParameters_ImagesA .ReturnsAsync(returnedAssets.ToList()); // Act - var assets = await _allAssetsPool.GetAssets(requestedImageCount + requestedVideoCount); + var assets = await _allAssetsPool.GetAssets(requestedImageCount + requestedVideoCount, _mockRequestContext.Object); // Assert Assert.That(assets.Count(), Is.EqualTo(requestedImageCount + requestedVideoCount)); @@ -165,7 +170,7 @@ public async Task GetAssets_AppliesDateFilters_FromDays() _mockImmichApi.Setup(api => api.SearchRandomAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); - await _allAssetsPool.GetAssets(5); + await _allAssetsPool.GetAssets(5, _mockRequestContext.Object); _mockImmichApi.Verify(api => api.SearchRandomAsync( It.Is(dto => dto.TakenAfter.HasValue && dto.TakenAfter.Value.Date == expectedFromDate.Date), @@ -189,7 +194,7 @@ public async Task GetAssets_ExcludesAssetsFromExcludedAlbums() .ReturnsAsync(new AlbumResponseDto { Assets = new List { excludedAsset }, AssetCount = 1 }); // Act - var result = (await _allAssetsPool.GetAssets(4)).ToList(); + var result = (await _allAssetsPool.GetAssets(4, _mockRequestContext.Object)).ToList(); // Assert Assert.That(result.Count, Is.EqualTo(3)); @@ -210,7 +215,7 @@ public async Task GetAssets_NullExcludedAlbums_Succeeds() .ReturnsAsync(allAssets); // Act - var result = (await _allAssetsPool.GetAssets(5)).ToList(); + var result = (await _allAssetsPool.GetAssets(5, _mockRequestContext.Object)).ToList(); // Assert Assert.That(result.Count, Is.EqualTo(5)); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs index c98e4565..2f65ad34 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs @@ -12,6 +12,7 @@ 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 Mock _mockRequestContext; private TestableCachingApiAssetsPool _testPool; // Concrete implementation for testing the abstract class @@ -36,6 +37,7 @@ public void Setup() _mockApiCache = new Mock(); // ILogger, IOptions _mockImmichApi = new Mock(null, null); // ILogger, IHttpClientFactory, IOptions _mockAccountSettings = new Mock(); + _mockRequestContext = new Mock(); _testPool = new TestableCachingApiAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); @@ -52,6 +54,9 @@ public void Setup() _mockAccountSettings.SetupGet(s => s.ImagesUntilDate).Returns((DateTime?)null); _mockAccountSettings.SetupGet(s => s.ImagesFromDays).Returns((int?)null); _mockAccountSettings.SetupGet(s => s.Rating).Returns((int?)null); + + // Default RequestContext + _mockRequestContext.Setup(x => x.AssetOffset).Returns(0); } private List CreateSampleAssets() @@ -108,7 +113,7 @@ public async Task GetAssets_ReturnsRequestedNumberOfAssets() _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); // Asset "3" included // Act - var result = (await _testPool.GetAssets(2)).ToList(); + var result = (await _testPool.GetAssets(2, _mockRequestContext.Object)).ToList(); // Assert Assert.That(result.Count, Is.EqualTo(2)); @@ -125,7 +130,7 @@ public async Task GetAssets_ReturnsAllAvailableIfLessThanRequested() _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); // Act - var result = (await _testPool.GetAssets(5)).ToList(); // Request 5, but only 3 available after filtering + var result = (await _testPool.GetAssets(5, _mockRequestContext.Object)).ToList(); // Request 5, but only 3 available after filtering // Assert Assert.That(result.Count, Is.EqualTo(3)); @@ -141,7 +146,7 @@ public async Task GetAssets_ReturnsAllAvailableIfLessThanRequested_WithVideos() _mockAccountSettings.SetupGet(s => s.ShowVideos).Returns(true); // Act - var result = (await _testPool.GetAssets(5)).ToList(); // Request 5, but only 3 available after filtering + var result = (await _testPool.GetAssets(5, _mockRequestContext.Object)).ToList(); // Request 5, but only 3 available after filtering // Assert Assert.That(result.Count, Is.EqualTo(4)); @@ -179,7 +184,7 @@ public async Task AllAssets_UsesCache_LoadAssetsCalledOnce() // 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 + await _testPool.GetAssets(1, _mockRequestContext.Object); // Third call, should use cache // Assert Assert.That(loadAssetsCallCount, Is.EqualTo(1), "LoadAssets should only be called once."); @@ -194,7 +199,7 @@ public async Task ApplyAccountFilters_FiltersArchived() _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); // Act - var result = (await _testPool.GetAssets(5)).ToList(); // Request more than available to get all filtered + var result = (await _testPool.GetAssets(5, _mockRequestContext.Object)).ToList(); // Request more than available to get all filtered // Assert Assert.That(result.Any(a => a.Id == "2"), Is.False); // Video asset filtered out by default @@ -212,7 +217,7 @@ public async Task ApplyAccountFilters_FiltersArchived_WithVideo() _mockAccountSettings.SetupGet(s => s.ShowVideos).Returns(true); // Act - var result = (await _testPool.GetAssets(5)).ToList(); // Request more than available to get all filtered + var result = (await _testPool.GetAssets(5, _mockRequestContext.Object)).ToList(); // Request more than available to get all filtered // Assert Assert.That(result.Any(a => a.Id == "3"), Is.False); @@ -230,7 +235,7 @@ public async Task ApplyAccountFilters_FiltersImagesUntilDate() _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(); + var result = (await _testPool.GetAssets(5, _mockRequestContext.Object)).ToList(); // Assert (all are images already by default) // Assets: 1 (10d), 3 (5d, archived), 4 (2d), 5 (1y) @@ -254,7 +259,7 @@ public async Task ApplyAccountFilters_FiltersImagesFromDate() _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); // Act - var result = (await _testPool.GetAssets(5)).ToList(); + var result = (await _testPool.GetAssets(5, _mockRequestContext.Object)).ToList(); // Assert // Assets: 1 (10d), 3 (5d, archived), 4 (2d), 5 (1y) @@ -278,7 +283,7 @@ public async Task ApplyAccountFilters_FiltersImagesFromDays() // Act - var result = (await _testPool.GetAssets(5)).ToList(); + var result = (await _testPool.GetAssets(5, _mockRequestContext.Object)).ToList(); // Assert // Assets: 1 (10d), 3 (5d, archived), 4 (2d), 5 (1y) @@ -301,7 +306,7 @@ public async Task ApplyAccountFilters_FiltersRating() // Act - var result = (await _testPool.GetAssets(5)).ToList(); + var result = (await _testPool.GetAssets(5, _mockRequestContext.Object)).ToList(); // Assert // Expected: Asset "1", "4" (both rating 5) @@ -324,7 +329,7 @@ public async Task ApplyAccountFilters_CombinedFilters() _mockAccountSettings.SetupGet(s => s.Rating).Returns(5); // Asset "1" (rating 5), Asset "4" (rating 5) // Act - var result = (await _testPool.GetAssets(5)).ToList(); + var result = (await _testPool.GetAssets(5, _mockRequestContext.Object)).ToList(); // Assert // Expected: Assets "1", "4" diff --git a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs index f6a6ed9f..2a1c07ad 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs @@ -17,14 +17,19 @@ public class MemoryAssetsPoolTests private Mock _mockImmichApi; private Mock _mockAccountSettings; private MemoryAssetsPool _memoryAssetsPool; + private Mock _mockRequestContext; [SetUp] public void Setup() { _mockImmichApi = new Mock(null, null); // Base constructor requires ILogger, IHttpClientFactory, IOptions, pass null _mockAccountSettings = new Mock(); + _mockRequestContext = new Mock(); _memoryAssetsPool = new MemoryAssetsPool(_mockImmichApi.Object, _mockAccountSettings.Object); + + // Default RequestContext + _mockRequestContext.Setup(x => x.AssetOffset).Returns(0); } private List CreateSampleAssets(int count, bool withExif, int yearCreated, AssetTypeEnum assetType) @@ -84,7 +89,7 @@ public async Task LoadAssets_CallsSearchMemoriesAsync() // 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. - await _memoryAssetsPool.GetAssets(1, CancellationToken.None); // This should trigger LoadAssets + await _memoryAssetsPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None); // This should trigger LoadAssets // Assert _mockImmichApi.Verify(x => x.SearchMemoriesAsync(It.IsAny(), null, null, null, It.IsAny()), Times.Once); @@ -104,7 +109,7 @@ public async Task LoadAssets_FetchesAssetInfo_WhenExifInfoIsNull() .ReturnsAsync(new AssetResponseDto { Id = assetId, ExifInfo = new ExifResponseDto { DateTimeOriginal = new DateTime(memoryYear, 1, 1) }, People = new List() }); // Act - var resultAsset = (await _memoryAssetsPool.GetAssets(1, CancellationToken.None)).First(); // Triggers LoadAssets + var resultAsset = (await _memoryAssetsPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None)).First(); // Triggers LoadAssets // Assert _mockImmichApi.Verify(x => x.GetAssetInfoAsync(new Guid(assetId), null, It.IsAny()), Times.Once); @@ -124,7 +129,7 @@ public async Task LoadAssets_DoesNotFetchAssetInfo_WhenExifInfoIsPresent() .ReturnsAsync(memories); // Act - var resultAsset = (await _memoryAssetsPool.GetAssets(1, CancellationToken.None)).First(); // Triggers LoadAssets + var resultAsset = (await _memoryAssetsPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None)).First(); // Triggers LoadAssets // Assert _mockImmichApi.Verify(x => x.GetAssetInfoAsync(It.IsAny(), null, It.IsAny()), Times.Never); @@ -156,7 +161,7 @@ public async Task LoadAssets_CorrectlyFormatsDescription_YearsAgo() // Act - var resultAsset = (await _memoryAssetsPool.GetAssets(1, CancellationToken.None)).First(); // Triggers LoadAssets + var resultAsset = (await _memoryAssetsPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None)).First(); // Triggers LoadAssets // Assert Assert.That(resultAsset.ExifInfo, Is.Not.Null); @@ -187,7 +192,7 @@ public async Task LoadAssets_AggregatesAssetsFromMultipleMemories() // 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. - var loadedAssets = await _memoryAssetsPool.GetAssets(memories.Count * assetsPerMemory, CancellationToken.None); // Trigger load + var loadedAssets = await _memoryAssetsPool.GetAssets(memories.Count * assetsPerMemory, _mockRequestContext.Object, CancellationToken.None); // Trigger load // Assert Assert.That(loadedAssets, Is.Not.Null); @@ -221,7 +226,7 @@ public async Task LoadAssets_AggregatesAssetsFromMultipleMemories_WithVideo() // 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. - var loadedAssets = await _memoryAssetsPool.GetAssets(memories.Count * assetsPerMemory, CancellationToken.None); // Trigger load + var loadedAssets = await _memoryAssetsPool.GetAssets(memories.Count * assetsPerMemory, _mockRequestContext.Object, CancellationToken.None); // Trigger load // Assert Assert.That(loadedAssets, Is.Not.Null); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/MultiAssetPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/MultiAssetPoolTests.cs index db2d7319..7400bb21 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/MultiAssetPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/MultiAssetPoolTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using ImmichFrame.Core.Api; using ImmichFrame.Core.Logic.Pool; +using ImmichFrame.Core.Interfaces; using NUnit.Framework.Constraints; namespace ImmichFrame.Core.Tests.Logic.Pool @@ -18,6 +19,7 @@ public class MultiAssetPoolTests private Mock _mockPool2; private Mock _mockPool3; private MultiAssetPool _multiPool; + private Mock _mockRequestContext; [SetUp] public void Setup() @@ -25,6 +27,10 @@ public void Setup() _mockPool1 = new Mock(); _mockPool2 = new Mock(); _mockPool3 = new Mock(); + _mockRequestContext = new Mock(); + + // Default RequestContext + _mockRequestContext.Setup(x => x.AssetOffset).Returns(0); } private AssetResponseDto CreateAsset(string id) => new AssetResponseDto { Id = id, OriginalPath = $"/path/{id}.jpg", Type = AssetTypeEnum.IMAGE, ExifInfo = new ExifResponseDto() }; @@ -57,7 +63,7 @@ public async Task GetAssetCount_MultiplePools_ReturnsSumOfCounts() public async Task GetAssets_RequestZeroAssets_ReturnsEmptyCollection() { _multiPool = new MultiAssetPool(new List { _mockPool1.Object }); - var result = await _multiPool.GetAssets(0, CancellationToken.None); + var result = await _multiPool.GetAssets(0, _mockRequestContext.Object, CancellationToken.None); Assert.That(result, Is.Empty); } @@ -72,14 +78,14 @@ public async Task GetAssets_TotalLessThanRequested_ReturnsAllAvailableAssets() _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())) .ReturnsAsync(() => (long)pool1AvailableAssets.Count); - _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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(); + var result = (await _multiPool.GetAssets(5, _mockRequestContext.Object, 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); @@ -91,18 +97,18 @@ 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())) + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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())) + _mockPool2.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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(); + var result = (await _multiPool.GetAssets(2, _mockRequestContext.Object, CancellationToken.None)).ToList(); Assert.That(result.Count, Is.EqualTo(2)); Assert.That(result.Contains(p1a1) && result.Contains(p2a1), Is.True); } @@ -118,7 +124,7 @@ public async Task GetAssets_PoolReturnsFewerAssetsThanCountSuggests_HandlesGrace var pool1ReportedCount = 5L; _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())) .ReturnsAsync(() => pool1ReportedCount); - _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, It.IsAny())) .ReturnsAsync(() => { if (pool1AvailableAssets.Any()) @@ -136,11 +142,11 @@ public async Task GetAssets_PoolReturnsFewerAssetsThanCountSuggests_HandlesGrace var originalPool2Assets = new List { p2a1 }; _mockPool2.Setup(p => p.GetAssetCount(It.IsAny())) .ReturnsAsync(() => (long)pool2AvailableAssets.Count); - _mockPool2.Setup(p => p.GetAssets(1, It.IsAny())) + _mockPool2.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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(); + var result = (await _multiPool.GetAssets(5, _mockRequestContext.Object, CancellationToken.None)).ToList(); Assert.That(result.Count, Is.EqualTo(3)); var expectedTotalAssets = originalPool1Assets.Concat(originalPool2Assets).ToList(); @@ -163,12 +169,12 @@ public async Task GetAssets_DifferentAssetCounts_RetrievesAssetsFromPools() _mockPool1.Setup(p => p.GetAssetCount(It.IsAny())) .ReturnsAsync(() => (long)pool1Queue.Count); - _mockPool1.Setup(p => p.GetAssets(1, It.IsAny())) + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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())) + _mockPool2.Setup(p => p.GetAssets(1, _mockRequestContext.Object, It.IsAny())) .ReturnsAsync(() => pool2Queue.Any() ? new List { pool2Queue.Dequeue() } : new List()); _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); @@ -176,7 +182,7 @@ public async Task GetAssets_DifferentAssetCounts_RetrievesAssetsFromPools() var retrievedAssets = new List(); for (int i = 0; i < 5; i++) { - var assetResultList = await _multiPool.GetAssets(1, CancellationToken.None); + var assetResultList = await _multiPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None); var assetResult = assetResultList.FirstOrDefault(); if (assetResult != null) { @@ -198,13 +204,13 @@ 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); + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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()); + _mockPool2.Setup(p => p.GetAssets(It.IsAny(), _mockRequestContext.Object, It.IsAny())).ReturnsAsync(new List()); _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); - var result = (await _multiPool.GetAssets(1, CancellationToken.None)).ToList(); + var result = (await _multiPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None)).ToList(); Assert.That(result.Count, Is.EqualTo(1)); Assert.That(result.First(), Is.SameAs(assets1.First())); @@ -217,7 +223,7 @@ public async Task GetNextAssetBehavior_NoAssetsInAnyPool_ReturnsNull() _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(); + var result = (await _multiPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None)).FirstOrDefault(); Assert.That(result, Is.Null); } @@ -230,25 +236,25 @@ public async Task GetNextAssetBehavior_RetrievesAllAssets() 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()); + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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()); + _mockPool2.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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(); + var asset1 = (await _multiPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None)).FirstOrDefault(); if (asset1 != null) results.Add(asset1); - var asset2 = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + var asset2 = (await _multiPool.GetAssets(1, _mockRequestContext.Object, 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(); + var asset3 = (await _multiPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None)).FirstOrDefault(); Assert.That(asset3, Is.Null); } @@ -258,20 +264,20 @@ 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()); + _mockPool1.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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()); + _mockPool2.Setup(p => p.GetAssets(It.IsAny(), _mockRequestContext.Object, It.IsAny())).ReturnsAsync(new List()); _multiPool = new MultiAssetPool(new List { _mockPool1.Object, _mockPool2.Object }); - var result = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + var result = (await _multiPool.GetAssets(1, _mockRequestContext.Object, 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()); + _mockPool1.Verify(p => p.GetAssets(1, _mockRequestContext.Object, It.IsAny()), Times.Once()); + _mockPool2.Verify(p => p.GetAssets(1, _mockRequestContext.Object, It.IsAny()), Times.Never()); - var nextResult = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + var nextResult = (await _multiPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None)).FirstOrDefault(); Assert.That(nextResult, Is.Null, "Pool1 exhausted, Pool2 zero count, should be null"); } @@ -285,19 +291,19 @@ public async Task GetNextAssetBehavior_PoolIsExhausted_SwitchesToNextAvailablePo _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()); + _mockPool2.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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()); + _mockPool3.Setup(p => p.GetAssets(1, _mockRequestContext.Object, 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(); + var result1 = (await _multiPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None)).FirstOrDefault(); Assert.That(result1, Is.EqualTo(assetP2A1).Or.EqualTo(assetP3A1)); - var result2 = (await _multiPool.GetAssets(1, CancellationToken.None)).FirstOrDefault(); + var result2 = (await _multiPool.GetAssets(1, _mockRequestContext.Object, 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/QueuingAssetPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/QueuingAssetPoolTests.cs index 134609f5..c8efb674 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/QueuingAssetPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/QueuingAssetPoolTests.cs @@ -2,6 +2,7 @@ using Moq; using ImmichFrame.Core.Api; using ImmichFrame.Core.Logic.Pool; +using ImmichFrame.Core.Interfaces; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -18,6 +19,7 @@ public class QueuingAssetPoolTests { private Mock _mockDelegatePool; private QueuingAssetPool _queuingAssetPool; + private Mock _mockRequestContext; private const int ReloadBatchSize = 50; // Matches const in QueuingAssetPool private const int ReloadThreshold = 10; // Matches const in QueuingAssetPool @@ -27,11 +29,15 @@ public void Setup() { var logger = FixtureHelpers.TestLogger(); _mockDelegatePool = new Mock(); + _mockRequestContext = 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); + + // Default RequestContext + _mockRequestContext.Setup(x => x.AssetOffset).Returns(0); } private List CreateSampleAssets(int count, string prefix = "asset") @@ -65,17 +71,17 @@ public async Task GetNextAsset_RetrievesFromInitiallyEmptyQueue_TriggersReload_R { // Arrange var assetToReturn = CreateSampleAssets(1, "initial_load").First(); - _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, It.IsAny())) + _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, _mockRequestContext.Object, It.IsAny())) .ReturnsAsync(new List { assetToReturn }) .Verifiable(); // Act - var result = await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None); + var result = await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None, _mockRequestContext.Object); // 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."); + _mockDelegatePool.Verify(dp => dp.GetAssets(ReloadBatchSize, _mockRequestContext.Object, It.IsAny()), Times.Once, "ReloadAssetsAsync should have been called as queue was empty."); } [Test] @@ -89,16 +95,16 @@ public async Task GetNextAsset_QueueAboveThreshold_DoesNotTriggerReload() await _queuingAssetPool.WriteToChannelForTesting(asset); } - _mockDelegatePool.Setup(dp => dp.GetAssets(It.IsAny(), It.IsAny())) + _mockDelegatePool.Setup(dp => dp.GetAssets(It.IsAny(), _mockRequestContext.Object, It.IsAny())) .ReturnsAsync(new List()); // Should not be called // Act - var result = await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None); + var result = await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None, _mockRequestContext.Object); // 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."); + _mockDelegatePool.Verify(dp => dp.GetAssets(It.IsAny(), _mockRequestContext.Object, It.IsAny()), Times.Never, "ReloadAssetsAsync should not be called when queue is above threshold."); } [Test] @@ -114,25 +120,25 @@ public async Task GetNextAsset_QueueDropsBelowThreshold_TriggersReload() } var newAssetsToLoad = CreateSampleAssets(5, "reloaded"); - _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, It.IsAny())) + _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, _mockRequestContext.Object, 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."); + await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None, _mockRequestContext.Object); + _mockDelegatePool.Verify(dp => dp.GetAssets(ReloadBatchSize, _mockRequestContext.Object, 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."); + await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None, _mockRequestContext.Object); + _mockDelegatePool.Verify(dp => dp.GetAssets(ReloadBatchSize, _mockRequestContext.Object, 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())) + _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, _mockRequestContext.Object, It.IsAny())) .ReturnsAsync(() => { // Simulate delay in fetching assets @@ -149,14 +155,14 @@ public async Task ReloadAssetsAsync_PreventsConcurrentReloads() // 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); + var task1 = _queuingAssetPool.GetAssets(1, _mockRequestContext.Object, CancellationToken.None); + var task2 = _queuingAssetPool.GetAssets(1, _mockRequestContext.Object, 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); + _mockDelegatePool.Verify(dp => dp.GetAssets(ReloadBatchSize, _mockRequestContext.Object, It.IsAny()), Times.Once); } [Test] @@ -167,7 +173,7 @@ public async Task GetNextAsset_HandlesOperationCanceledException_ReturnsNull() // Ensure queue is empty so ReadAsync is awaited // Act: Call GetNextAsset with a token that will be cancelled - var getAssetTask = _queuingAssetPool.GetNextAssetForTesting(cts.Token); + var getAssetTask = _queuingAssetPool.GetNextAssetForTesting(cts.Token, _mockRequestContext.Object); cts.Cancel(); // Cancel the operation var result = await getAssetTask; @@ -180,12 +186,12 @@ public async Task ReloadAssetsAsync_AddsFetchedAssetsToQueue() { // Arrange var assetsToLoad = CreateSampleAssets(5, "reloaded_assets"); - _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, It.IsAny())) + _mockDelegatePool.Setup(dp => dp.GetAssets(ReloadBatchSize, _mockRequestContext.Object, It.IsAny())) .ReturnsAsync(assetsToLoad); // Act // Trigger reload by reading from empty queue - await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None); + await _queuingAssetPool.GetNextAssetForTesting(CancellationToken.None, _mockRequestContext.Object); // Wait for the fire-and-forget ReloadAssetsAsync to potentially complete // This is tricky. A small delay might work for testing but isn't robust. @@ -203,7 +209,7 @@ public async Task ReloadAssetsAsync_AddsFetchedAssetsToQueue() using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); try { - var asset = await _queuingAssetPool.GetNextAssetForTesting(timeoutCts.Token); + var asset = await _queuingAssetPool.GetNextAssetForTesting(timeoutCts.Token, _mockRequestContext.Object); if (asset != null) retrievedAssets.Add(asset); else break; } @@ -225,11 +231,11 @@ public async Task ReloadAssetsAsync_AddsFetchedAssetsToQueue() public static class QueuingAssetPoolTestExtensions { // Expose GetNextAsset for testing (it's protected in AggregatingAssetPool) - public static Task GetNextAssetForTesting(this QueuingAssetPool pool, CancellationToken ct) + public static Task GetNextAssetForTesting(this QueuingAssetPool pool, CancellationToken ct, IRequestContext mockRequestContext) { // 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 }); + return (Task)methodInfo.Invoke(pool, new object[] { mockRequestContext, ct }); } // Helper to write to channel for test setup @@ -239,4 +245,4 @@ public static async Task WriteToChannelForTesting(this QueuingAssetPool pool, As var channel = (Channel)fieldInfo.GetValue(pool); await channel.Writer.WriteAsync(asset); } -} \ No newline at end of file +} diff --git a/ImmichFrame.Core/Api/AssetListResponseDto.cs b/ImmichFrame.Core/Api/AssetListResponseDto.cs new file mode 100644 index 00000000..df0c0101 --- /dev/null +++ b/ImmichFrame.Core/Api/AssetListResponseDto.cs @@ -0,0 +1,8 @@ +namespace ImmichFrame.Core.Api +{ + public partial class AssetListResponseDto + { + public int AssetOffset { get; set; } + public List? Assets { get; set; } + } +} diff --git a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs index 4ddfd5b3..c91dbf2b 100644 --- a/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs +++ b/ImmichFrame.Core/Interfaces/IImmichFrameLogic.cs @@ -5,8 +5,8 @@ namespace ImmichFrame.Core.Interfaces { public interface IImmichFrameLogic { - public Task GetNextAsset(); - public Task> GetAssets(); + public Task GetNextAsset(IRequestContext requestContext); + public Task> GetAssets(IRequestContext requestContext); public Task GetAssetInfoById(Guid assetId); public Task> GetAlbumInfoById(Guid assetId); public Task<(string fileName, string ContentType, Stream fileStream)> GetAsset(Guid id, AssetTypeEnum? assetType = null); @@ -23,8 +23,8 @@ public interface IAccountImmichFrameLogic : IImmichFrameLogic public interface IAccountSelectionStrategy { void Initialize(IList accounts); - Task<(IAccountImmichFrameLogic, AssetResponseDto)?> GetNextAsset(); - Task> GetAssets(); + Task<(IAccountImmichFrameLogic, AssetResponseDto)?> GetNextAsset(IRequestContext requestContext); + Task> GetAssets(IRequestContext requestContext); T ForAsset(Guid assetId, Func f); } } diff --git a/ImmichFrame.Core/Interfaces/IRequestContext.cs b/ImmichFrame.Core/Interfaces/IRequestContext.cs new file mode 100644 index 00000000..77379828 --- /dev/null +++ b/ImmichFrame.Core/Interfaces/IRequestContext.cs @@ -0,0 +1,7 @@ +namespace ImmichFrame.Core.Interfaces +{ + public interface IRequestContext + { + int AssetOffset { get; set; } + } +} diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index fea6c442..d774f5db 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -64,6 +64,7 @@ public interface IGeneralSettings public bool ImagePan { get; } public bool ImageFill { get; } public bool PlayAudio { get; } + public bool ExhaustiveAlbumShuffle { get; } public string Layout { get; } public string Language { get; } diff --git a/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs b/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs index 613f9c49..4cdbbc93 100644 --- a/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs +++ b/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs @@ -14,17 +14,17 @@ public void Initialize(IList accounts) _accounts = accounts; } - public async Task<(IAccountImmichFrameLogic, AssetResponseDto)?> GetNextAsset() + public async Task<(IAccountImmichFrameLogic, AssetResponseDto)?> GetNextAsset(IRequestContext requestContext) { var chosen = await _accounts.ChooseOne(logic => logic.GetTotalAssets()); - - var asset = await chosen.GetNextAsset(); + + var asset = await chosen.GetNextAsset(requestContext); if (asset != null) { await _tracker.RecordAssetLocation(chosen, asset.Id); return (chosen, asset); } - + _logger.LogDebug("No next asset found"); return null; } @@ -46,12 +46,12 @@ private Task GetTotalForAccount(IImmichFrameLogic account) return account.GetTotalAssets(); } - public async Task> GetAssets() + public async Task> GetAssets(IRequestContext requestContext) { var proportions = await GetProportions(_accounts); var maxAccount = proportions.Max(); var adjustedProportions = proportions.Select(x => x / maxAccount).ToList(); - var assetLists = _accounts.Select(account => account.GetAssets()).ToList(); + var assetLists = _accounts.Select(account => account.GetAssets(requestContext)).ToList(); var taskList = assetLists .Zip(_accounts, adjustedProportions) @@ -59,7 +59,7 @@ private Task GetTotalForAccount(IImmichFrameLogic account) { var (task, account, proportion) = tuple; var assets = (await task).ToList(); - _logger.LogDebug("Retrieved {total} asset(s) for account [{account}], will take {proportion}%", assets.Count(), account, proportion * 100); + _logger.LogDebug("Retrieved {total} asset(s) for account [{account}], will take {proportion}%", assets.Count, account, proportion * 100); return (account, assets.Shuffle().TakeProportional(proportion)); }); @@ -84,4 +84,4 @@ private Task GetTotalForAccount(IImmichFrameLogic account) public T ForAsset(Guid assetId, Func f) => _tracker.ForAsset(assetId.ToString(), f); -} \ No newline at end of file +} diff --git a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs index 3e0422cb..c166001f 100644 --- a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs +++ b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs @@ -24,14 +24,15 @@ public MultiImmichFrameLogicDelegate(IServerSettings serverSettings, keySelector: a => a, elementSelector: logicFactory ); + _accountSelectionStrategy.Initialize(_accountToDelegate.Values); } - public async Task GetNextAsset() => (await _accountSelectionStrategy.GetNextAsset())?.ToAsset(); + public async Task GetNextAsset(IRequestContext requestContext) => (await _accountSelectionStrategy.GetNextAsset(requestContext))?.ToAsset(); - public async Task> GetAssets() - => (await _accountSelectionStrategy.GetAssets()).Shuffle().Select(it => it.ToAsset()); + public async Task> GetAssets(IRequestContext requestContext) + => (await _accountSelectionStrategy.GetAssets(requestContext)).Shuffle().Select(it => it.ToAsset()); public Task GetAssetInfoById(Guid assetId) @@ -68,4 +69,4 @@ public static AssetResponseDto WithAccount(this AssetResponseDto asset, IAccount asset.ImmichServerUrl = account.AccountSettings.ImmichServerUrl; return asset; } -} \ No newline at end of file +} diff --git a/ImmichFrame.Core/Logic/Pool/AggregatingAssetPool.cs b/ImmichFrame.Core/Logic/Pool/AggregatingAssetPool.cs index 4a887eda..91b70276 100644 --- a/ImmichFrame.Core/Logic/Pool/AggregatingAssetPool.cs +++ b/ImmichFrame.Core/Logic/Pool/AggregatingAssetPool.cs @@ -1,14 +1,16 @@ using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + namespace ImmichFrame.Core.Logic.Pool; public abstract class AggregatingAssetPool : IAssetPool { public abstract Task GetAssetCount(CancellationToken ct = default); - protected abstract Task GetNextAsset(CancellationToken ct); + protected abstract Task GetNextAsset(IRequestContext requestContext, CancellationToken ct); - public Task> GetAssets(int requested, CancellationToken ct = default) + public Task> GetAssets(int requested, IRequestContext requestContext, CancellationToken ct = default) { - return IAssetPool.WaitAssets(requested, GetNextAsset, ct); + return IAssetPool.WaitAssets(requested, GetNextAsset, requestContext, ct); } -} \ No newline at end of file +} diff --git a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs index 6ba94ae4..6a6a6fe6 100644 --- a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs @@ -1,4 +1,5 @@ using ImmichFrame.Core.Api; +using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; namespace ImmichFrame.Core.Logic.Pool; @@ -19,6 +20,6 @@ protected override async Task> LoadAssets(Cancella } } - return albumAssets; + return albumAssets.Shuffle().ToList(); } -} \ No newline at end of file +} diff --git a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs index 84ddc217..88e786a4 100644 --- a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs @@ -20,7 +20,7 @@ public async Task GetAssetCount(CancellationToken ct = default) return stats.Images; } - public async Task> GetAssets(int requested, CancellationToken ct = default) + public async Task> GetAssets(int requested, IRequestContext requestContext, CancellationToken ct = default) { var searchDto = new RandomSearchDto { @@ -68,4 +68,4 @@ public async Task> GetAssets(int requested, Cancel return assets.ApplyAccountFilters(accountSettings, excludedAlbumAssets); } -} \ No newline at end of file +} diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs index 9dce8acf..7dca4117 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -13,9 +13,25 @@ public async Task GetAssetCount(CancellationToken ct = default) return (await AllAssets(ct)).Count(); } - public async Task> GetAssets(int requested, CancellationToken ct = default) + public async Task> GetAssets(int requested, IRequestContext requestContext, CancellationToken ct = default) { - return (await AllAssets(ct)).OrderBy(_ => _random.Next()).Take(requested); + var allAssets = await AllAssets(ct); + var totalCount = allAssets.Count(); + + if (requestContext.AssetOffset >= totalCount) + { + requestContext.AssetOffset = 0; + } + + var assetsToReturn = allAssets.Skip(requestContext.AssetOffset).Take(requested); + + requestContext.AssetOffset += requested; + if (requestContext.AssetOffset >= totalCount) + { + requestContext.AssetOffset = 0; + } + + return assetsToReturn; } private async Task> AllAssets(CancellationToken ct = default) @@ -26,4 +42,4 @@ private async Task> AllAssets(CancellationToken ct } protected abstract Task> LoadAssets(CancellationToken ct = default); -} \ No newline at end of file +} diff --git a/ImmichFrame.Core/Logic/Pool/IAssetPool.cs b/ImmichFrame.Core/Logic/Pool/IAssetPool.cs index 4abe0912..db0ab7c7 100644 --- a/ImmichFrame.Core/Logic/Pool/IAssetPool.cs +++ b/ImmichFrame.Core/Logic/Pool/IAssetPool.cs @@ -1,15 +1,17 @@ using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; namespace ImmichFrame.Core.Logic.Pool; public interface IAssetPool { Task GetAssetCount(CancellationToken ct = default); - Task> GetAssets(int requested, CancellationToken ct = default); + Task> GetAssets(int requested, IRequestContext requestContext, CancellationToken ct = default); protected static async Task> WaitAssets( int requested, - Func> supplier, + Func> supplier, + IRequestContext requestContext, CancellationToken? cancellationToken = null) { //allow up to one minute @@ -19,7 +21,7 @@ protected static async Task> WaitAssets( for (var i = 0; i < requested; i++) { - var asset = await supplier(ct); + var asset = await supplier(requestContext, ct); if (asset != null) { @@ -33,4 +35,4 @@ protected static async Task> WaitAssets( return itemsRead; } -} \ No newline at end of file +} diff --git a/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs b/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs index 936ac001..e1d1481d 100644 --- a/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs +++ b/ImmichFrame.Core/Logic/Pool/MultiAssetPool.cs @@ -1,5 +1,6 @@ using ImmichFrame.Core.Api; using ImmichFrame.Core.Helpers; +using ImmichFrame.Core.Interfaces; namespace ImmichFrame.Core.Logic.Pool; @@ -11,12 +12,12 @@ public override async Task GetAssetCount(CancellationToken ct = default) return (await Task.WhenAll(counts)).Sum(); } - protected override async Task GetNextAsset(CancellationToken ct) + protected override async Task GetNextAsset(IRequestContext requestContext, CancellationToken ct) { - var pool = await delegates.ChooseOne(async @delegate=> await @delegate.GetAssetCount(ct)); - + var pool = await delegates.ChooseOne(async @delegate => await @delegate.GetAssetCount(ct)); + if (pool == null) return null; - - return (await pool.GetAssets(1, ct)).FirstOrDefault(); + + return (await pool.GetAssets(1, requestContext, ct)).FirstOrDefault(); } -} \ No newline at end of file +} diff --git a/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs b/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs index eda5a260..6f0f0917 100644 --- a/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs +++ b/ImmichFrame.Core/Logic/Pool/QueuingAssetPool.cs @@ -1,5 +1,6 @@ using System.Threading.Channels; using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; using Microsoft.Extensions.Logging; namespace ImmichFrame.Core.Logic.Pool; @@ -14,15 +15,14 @@ public class QueuingAssetPool(ILogger _logger, IAssetPool @del public override Task GetAssetCount(CancellationToken ct = default) => @delegate.GetAssetCount(ct); - - protected override async Task GetNextAsset(CancellationToken ct) + protected override async Task GetNextAsset(IRequestContext requestContext, CancellationToken ct) { try { if (_assetQueue.Reader.Count <= RELOAD_THRESHOLD) { // Fire-and-forget, reloading assets in the background - _ = ReloadAssetsAsync(); + _ = ReloadAssetsAsync(requestContext); } return await _assetQueue.Reader.ReadAsync(ct); @@ -40,7 +40,7 @@ public class QueuingAssetPool(ILogger _logger, IAssetPool @del } } - private async Task ReloadAssetsAsync() + private async Task ReloadAssetsAsync(IRequestContext requestContext) { if (await _isReloadingAssets.WaitAsync(0)) { @@ -49,7 +49,7 @@ private async Task ReloadAssetsAsync() _logger.LogDebug("Reloading assets"); // TODO: apply account filters - QueuingAssetPool is currently not used anywhere - foreach (var asset in await @delegate.GetAssets(RELOAD_BATCH_SIZE)) + foreach (var asset in await @delegate.GetAssets(RELOAD_BATCH_SIZE, requestContext)) { await _assetQueue.Writer.WriteAsync(asset); } @@ -64,4 +64,4 @@ private async Task ReloadAssetsAsync() _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 index a13eead4..0763a657 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -64,14 +64,14 @@ private IAssetPool BuildPool(IAccountSettings accountSettings) return new MultiAssetPool(pools); } - public async Task GetNextAsset() + public async Task GetNextAsset(IRequestContext requestContext) { - return (await _pool.GetAssets(1)).FirstOrDefault(); + return (await _pool.GetAssets(1, requestContext)).FirstOrDefault(); } - public Task> GetAssets() + public Task> GetAssets(IRequestContext requestContext) { - return _pool.GetAssets(25); + return _pool.GetAssets(25, requestContext); } public Task GetAssetInfoById(Guid assetId) => _immichApi.GetAssetInfoAsync(assetId, null); diff --git a/ImmichFrame.Core/Services/RequestContextService.cs b/ImmichFrame.Core/Services/RequestContextService.cs new file mode 100644 index 00000000..8c515122 --- /dev/null +++ b/ImmichFrame.Core/Services/RequestContextService.cs @@ -0,0 +1,9 @@ +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Services +{ + public class RequestContext : IRequestContext + { + public int AssetOffset { get; set; } + } +} diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV1.json b/ImmichFrame.WebApi.Tests/Resources/TestV1.json index e6c49102..80e71712 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV1.json @@ -8,6 +8,7 @@ "ImagePan": true, "ImageFill": true, "PlayAudio": true, + "ExhaustiveAlbumShuffle": true, "Layout": "Layout_TEST", "DownloadImages": true, "ShowMemories": true, diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index 4d603dc9..c40b88f5 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -35,6 +35,7 @@ "ImagePan": true, "ImageFill": true, "PlayAudio": true, + "ExhaustiveAlbumShuffle": true, "Layout": "Layout_TEST" }, "Accounts": [ diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml index 47f45947..6338a8ba 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -34,6 +34,7 @@ General: ImagePan: true ImageFill: true PlayAudio: true + ExhaustiveAlbumShuffle: true Layout: Layout_TEST Accounts: - ImmichServerUrl: Account1.ImmichServerUrl_TEST diff --git a/ImmichFrame.WebApi/Controllers/AssetController.cs b/ImmichFrame.WebApi/Controllers/AssetController.cs index 989b1efc..0b756e2f 100644 --- a/ImmichFrame.WebApi/Controllers/AssetController.cs +++ b/ImmichFrame.WebApi/Controllers/AssetController.cs @@ -26,19 +26,28 @@ public class AssetController : ControllerBase private readonly IImmichFrameLogic _logic; private readonly IGeneralSettings _settings; - public AssetController(ILogger logger, IImmichFrameLogic logic, IGeneralSettings settings) + private readonly IRequestContext _requestContext; + + public AssetController(ILogger logger, IImmichFrameLogic logic, IGeneralSettings settings, IRequestContext requestContext) { _logger = logger; _logic = logic; _settings = settings; + _requestContext = requestContext; } [HttpGet(Name = "GetAssets")] - public async Task> GetAssets(string clientIdentifier = "") + public async Task GetAssets(string clientIdentifier = "", int assetOffset = 0) { var sanitizedClientIdentifier = clientIdentifier.SanitizeString(); _logger.LogDebug("Assets requested by '{sanitizedClientIdentifier}'", sanitizedClientIdentifier); - return (await _logic.GetAssets()).ToList(); + + AssetListResponseDto response = new AssetListResponseDto(); + + response.Assets = (await _logic.GetAssets(_requestContext)).ToList(); + response.AssetOffset = _requestContext.AssetOffset; + + return response; } [HttpGet("{id}/AssetInfo", Name = "GetAssetInfo")] @@ -94,7 +103,7 @@ public async Task GetRandomImageAndInfo(string clientIdentifier = const int maxAttempts = 10; for (int i = 0; i < maxAttempts; i++) { - var candidate = await _logic.GetNextAsset(); + var candidate = await _logic.GetNextAsset(_requestContext); if (candidate == null) break; if (candidate.Type == AssetTypeEnum.IMAGE) { diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 076f36da..066e5416 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -55,6 +55,7 @@ public class ServerSettingsV1 : IConfigSettable public bool ImagePan { get; set; } = false; public bool ImageFill { get; set; } = false; public bool PlayAudio { get; set; } = false; + public bool ExhaustiveAlbumShuffle { get; set; } = false; public string Layout { get; set; } = "splitview"; } @@ -86,6 +87,7 @@ class AccountSettingsV1Adapter(ServerSettingsV1 _delegate) : IAccountSettings public bool ShowArchived => _delegate.ShowArchived; public bool ShowVideos => _delegate.ShowVideos; public bool PlayAudio => _delegate.PlayAudio; + public bool ExhaustiveAlbumShuffle => _delegate.ExhaustiveAlbumShuffle; public int? ImagesFromDays => _delegate.ImagesFromDays; public DateTime? ImagesFromDate => _delegate.ImagesFromDate; public DateTime? ImagesUntilDate => _delegate.ImagesUntilDate; @@ -133,6 +135,7 @@ class GeneralSettingsV1Adapter(ServerSettingsV1 _delegate) : IGeneralSettings public bool ImagePan => _delegate.ImagePan; public bool ImageFill => _delegate.ImageFill; public bool PlayAudio => _delegate.PlayAudio; + public bool ExhaustiveAlbumShuffle => _delegate.ExhaustiveAlbumShuffle; public string Layout => _delegate.Layout; public string Language => _delegate.Language; diff --git a/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs b/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs new file mode 100644 index 00000000..8fb17032 --- /dev/null +++ b/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs @@ -0,0 +1,28 @@ +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.WebApi.Helpers +{ + public class RequestContextMiddleware + { + private readonly RequestDelegate _next; + + public RequestContextMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IRequestContext requestContext) + { + if (context.Request.Query.TryGetValue("assetOffset", out var assetOffset)) + { + string value = assetOffset.ToString(); + if (value != null && value.Length > 0) + { + requestContext.AssetOffset = int.Parse(value); + } + } + + await _next(context); + } + } +} diff --git a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs index ff0f9e75..0a69396d 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -30,6 +30,7 @@ public class ClientSettingsDto public bool ImagePan { get; set; } public bool ImageFill { get; set; } public bool PlayAudio { get; set; } + public bool ExhaustiveAlbumShuffle { get; set; } public string Layout { get; set; } public string Language { get; set; } @@ -62,8 +63,9 @@ public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSett dto.ImagePan = generalSettings.ImagePan; dto.ImageFill = generalSettings.ImageFill; dto.PlayAudio = generalSettings.PlayAudio; + dto.ExhaustiveAlbumShuffle = generalSettings.ExhaustiveAlbumShuffle; dto.Layout = generalSettings.Layout; dto.Language = generalSettings.Language; return dto; } -} \ No newline at end of file +} diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 74d0fb8e..04aa497c 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -63,6 +63,7 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable public bool ImagePan { get; set; } = false; public bool ImageFill { get; set; } = false; public bool PlayAudio { get; set; } = false; + public bool ExhaustiveAlbumShuffle { get; set; } = false; public string Layout { get; set; } = "splitview"; public int RenewImagesDuration { get; set; } = 30; public List Webcalendars { get; set; } = new(); diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index f2d68244..580e42cc 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -1,10 +1,12 @@ using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Services; using ImmichFrame.WebApi.Models; using Microsoft.AspNetCore.Authentication; using System.Reflection; using ImmichFrame.Core.Logic; using ImmichFrame.Core.Logic.AccountSelection; +using ImmichFrame.WebApi.Helpers; using ImmichFrame.WebApi.Helpers.Config; var builder = WebApplication.CreateBuilder(args); @@ -68,6 +70,9 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ builder.Services.AddSingleton(); +// Register Context Service +builder.Services.AddScoped(); + builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); @@ -104,6 +109,7 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ // app.UseHttpsRedirection(); app.UseMiddleware(); +app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index da7379d1..e84f8bb1 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -46,6 +46,7 @@ let infoVisible: boolean = $state(false); let authError: boolean = $state(false); let errorMessage: string = $state(''); + let assetOffset: number = 0; let assetsState: AssetsState = $state({ assets: [], error: false, @@ -129,7 +130,10 @@ async function loadAssets() { try { - let assetRequest = await api.getAssets(); + let assetRequest = await api.getAssets({ + clientIdentifier: undefined, + assetOffset: assetOffset + }); if (assetRequest.status != 200) { if (assetRequest.status == 401) { @@ -140,9 +144,11 @@ } error = false; - assetBacklog = assetRequest.data.filter( + assetBacklog = assetRequest.data.assets.filter( (asset) => isImageAsset(asset) || isVideoAsset(asset) ); + + assetOffset = assetRequest.data.assetOffset; } catch { error = true; } diff --git a/immichFrame.Web/src/lib/immichFrameApi.ts b/immichFrame.Web/src/lib/immichFrameApi.ts index e8dae934..7a28e457 100644 --- a/immichFrame.Web/src/lib/immichFrameApi.ts +++ b/immichFrame.Web/src/lib/immichFrameApi.ts @@ -102,6 +102,10 @@ export type TagResponseDto = { }; export type AssetTypeEnum = 0 | 1 | 2 | 3; export type AssetVisibility = 0 | 1 | 2 | 3; +export type AssetListResponseDto = { + assetOffset: number; + assets: AssetResponseDto[]; +}; export type AssetResponseDto = { immichServerUrl?: string | null; checksum: string; @@ -223,14 +227,16 @@ export type IWeather = { description?: string | null; iconId?: string | null; }; -export function getAssets({ clientIdentifier }: { - clientIdentifier?: string; +export function getAssets({ clientIdentifier, assetOffset }: { + clientIdentifier?: string; + assetOffset?: number; } = {}, opts?: Oazapfts.RequestOpts) { return oazapfts.fetchJson<{ status: 200; - data: AssetResponseDto[]; + data: AssetListResponseDto; }>(`/api/Asset${QS.query(QS.explode({ - clientIdentifier + clientIdentifier, + assetOffset }))}`, { ...opts }); From 59e69734fbdb60ad669ab0fe8dd3a94beae7f188 Mon Sep 17 00:00:00 2001 From: Matthew Livingstone Date: Thu, 5 Feb 2026 17:17:08 -0500 Subject: [PATCH 2/7] Fixes based on CodeRabbit feedback --- .../Logic/Pool/CachingApiAssetsPool.cs | 2 +- .../Controllers/AssetController.cs | 2 +- .../Helpers/RequestContextMiddleware.cs | 16 +++++++++++++++- .../lib/components/home-page/home-page.svelte | 2 +- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs index 7dca4117..05cb06fb 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -25,7 +25,7 @@ public async Task> GetAssets(int requested, IReque var assetsToReturn = allAssets.Skip(requestContext.AssetOffset).Take(requested); - requestContext.AssetOffset += requested; + requestContext.AssetOffset += assetsToReturn.Count(); if (requestContext.AssetOffset >= totalCount) { requestContext.AssetOffset = 0; diff --git a/ImmichFrame.WebApi/Controllers/AssetController.cs b/ImmichFrame.WebApi/Controllers/AssetController.cs index 0b756e2f..f1814e0b 100644 --- a/ImmichFrame.WebApi/Controllers/AssetController.cs +++ b/ImmichFrame.WebApi/Controllers/AssetController.cs @@ -37,7 +37,7 @@ public AssetController(ILogger logger, IImmichFrameLogic logic, } [HttpGet(Name = "GetAssets")] - public async Task GetAssets(string clientIdentifier = "", int assetOffset = 0) + public async Task GetAssets(string clientIdentifier = "") { var sanitizedClientIdentifier = clientIdentifier.SanitizeString(); _logger.LogDebug("Assets requested by '{sanitizedClientIdentifier}'", sanitizedClientIdentifier); diff --git a/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs b/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs index 8fb17032..3cc4cf80 100644 --- a/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs +++ b/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs @@ -16,9 +16,23 @@ public async Task InvokeAsync(HttpContext context, IRequestContext requestContex if (context.Request.Query.TryGetValue("assetOffset", out var assetOffset)) { string value = assetOffset.ToString(); + if (value != null && value.Length > 0) { - requestContext.AssetOffset = int.Parse(value); + int number; + bool success = int.TryParse(value, out number); + if (success) + { + requestContext.AssetOffset = number; + } + else + { + requestContext.AssetOffset = 0; + } + } + else + { + requestContext.AssetOffset = 0; } } diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index e84f8bb1..154bd354 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -131,7 +131,7 @@ async function loadAssets() { try { let assetRequest = await api.getAssets({ - clientIdentifier: undefined, + clientIdentifier: $clientIdentifierStore, assetOffset: assetOffset }); From 25c00c40ab1cbbdbe0eaa461893ee1bc4bb8d275 Mon Sep 17 00:00:00 2001 From: Matthew Livingstone Date: Fri, 6 Feb 2026 21:05:36 -0500 Subject: [PATCH 3/7] Return to shuffling/ordering on request, but each client will provide the random shuffle integer --- .../Interfaces/IRequestContext.cs | 1 + .../Logic/Pool/AlbumAssetsPool.cs | 5 ++-- .../Logic/Pool/CachingApiAssetsPool.cs | 4 +-- .../Services/RequestContextService.cs | 1 + .../Helpers/RequestContextMiddleware.cs | 27 +++++++++++++++++-- .../lib/components/home-page/home-page.svelte | 4 ++- immichFrame.Web/src/lib/immichFrameApi.ts | 6 +++-- 7 files changed, 37 insertions(+), 11 deletions(-) diff --git a/ImmichFrame.Core/Interfaces/IRequestContext.cs b/ImmichFrame.Core/Interfaces/IRequestContext.cs index 77379828..0c18124a 100644 --- a/ImmichFrame.Core/Interfaces/IRequestContext.cs +++ b/ImmichFrame.Core/Interfaces/IRequestContext.cs @@ -3,5 +3,6 @@ namespace ImmichFrame.Core.Interfaces public interface IRequestContext { int AssetOffset { get; set; } + int AssestShuffleRandom { get; set; } } } diff --git a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs index 6a6a6fe6..6ba94ae4 100644 --- a/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/AlbumAssetsPool.cs @@ -1,5 +1,4 @@ using ImmichFrame.Core.Api; -using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; namespace ImmichFrame.Core.Logic.Pool; @@ -20,6 +19,6 @@ protected override async Task> LoadAssets(Cancella } } - return albumAssets.Shuffle().ToList(); + return albumAssets; } -} +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs index 05cb06fb..4dcec4dd 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -6,8 +6,6 @@ namespace ImmichFrame.Core.Logic.Pool; public abstract class CachingApiAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : IAssetPool { - private readonly Random _random = new(); - public async Task GetAssetCount(CancellationToken ct = default) { return (await AllAssets(ct)).Count(); @@ -23,7 +21,7 @@ public async Task> GetAssets(int requested, IReque requestContext.AssetOffset = 0; } - var assetsToReturn = allAssets.Skip(requestContext.AssetOffset).Take(requested); + var assetsToReturn = allAssets.OrderBy(_ => requestContext.AssestShuffleRandom).Skip(requestContext.AssetOffset).Take(requested); requestContext.AssetOffset += assetsToReturn.Count(); if (requestContext.AssetOffset >= totalCount) diff --git a/ImmichFrame.Core/Services/RequestContextService.cs b/ImmichFrame.Core/Services/RequestContextService.cs index 8c515122..1cc8e2ae 100644 --- a/ImmichFrame.Core/Services/RequestContextService.cs +++ b/ImmichFrame.Core/Services/RequestContextService.cs @@ -5,5 +5,6 @@ namespace ImmichFrame.Core.Services public class RequestContext : IRequestContext { public int AssetOffset { get; set; } + public int AssestShuffleRandom { get; set; } } } diff --git a/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs b/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs index 3cc4cf80..a1e9e292 100644 --- a/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs +++ b/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs @@ -13,14 +13,14 @@ public RequestContextMiddleware(RequestDelegate next) public async Task InvokeAsync(HttpContext context, IRequestContext requestContext) { + // assetOffest if (context.Request.Query.TryGetValue("assetOffset", out var assetOffset)) { string value = assetOffset.ToString(); if (value != null && value.Length > 0) { - int number; - bool success = int.TryParse(value, out number); + bool success = int.TryParse(value, out int number); if (success) { requestContext.AssetOffset = number; @@ -36,6 +36,29 @@ public async Task InvokeAsync(HttpContext context, IRequestContext requestContex } } + // assestShuffleRandom + if (context.Request.Query.TryGetValue("assestShuffleRandom", out var assestShuffleRandom)) + { + string value = assestShuffleRandom.ToString(); + + if (value != null && value.Length > 0) + { + bool success = int.TryParse(value, out int number); + if (success) + { + requestContext.AssestShuffleRandom = number; + } + else + { + requestContext.AssestShuffleRandom = 0; + } + } + else + { + requestContext.AssestShuffleRandom = 0; + } + } + await _next(context); } } diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index 154bd354..e3a30fd5 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -47,6 +47,7 @@ let authError: boolean = $state(false); let errorMessage: string = $state(''); let assetOffset: number = 0; + let assestShuffleRandom: number = Math.floor(Math.random()); let assetsState: AssetsState = $state({ assets: [], error: false, @@ -132,7 +133,8 @@ try { let assetRequest = await api.getAssets({ clientIdentifier: $clientIdentifierStore, - assetOffset: assetOffset + assetOffset: assetOffset, + assestShuffleRandom: assestShuffleRandom }); if (assetRequest.status != 200) { diff --git a/immichFrame.Web/src/lib/immichFrameApi.ts b/immichFrame.Web/src/lib/immichFrameApi.ts index 7a28e457..7c49cec1 100644 --- a/immichFrame.Web/src/lib/immichFrameApi.ts +++ b/immichFrame.Web/src/lib/immichFrameApi.ts @@ -227,16 +227,18 @@ export type IWeather = { description?: string | null; iconId?: string | null; }; -export function getAssets({ clientIdentifier, assetOffset }: { +export function getAssets({ clientIdentifier, assetOffset, assestShuffleRandom }: { clientIdentifier?: string; assetOffset?: number; + assestShuffleRandom?: number; } = {}, opts?: Oazapfts.RequestOpts) { return oazapfts.fetchJson<{ status: 200; data: AssetListResponseDto; }>(`/api/Asset${QS.query(QS.explode({ clientIdentifier, - assetOffset + assetOffset, + assestShuffleRandom }))}`, { ...opts }); From 1ca78083973e64c4dddaba185b2f3c21ddac2274 Mon Sep 17 00:00:00 2001 From: Matthew Livingstone Date: Fri, 6 Feb 2026 21:24:37 -0500 Subject: [PATCH 4/7] Fix typo and correct ordering issues --- ImmichFrame.Core/Interfaces/IRequestContext.cs | 2 +- ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs | 3 ++- ImmichFrame.Core/Services/RequestContextService.cs | 2 +- .../Helpers/RequestContextMiddleware.cs | 12 ++++++------ .../src/lib/components/home-page/home-page.svelte | 4 ++-- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/ImmichFrame.Core/Interfaces/IRequestContext.cs b/ImmichFrame.Core/Interfaces/IRequestContext.cs index 0c18124a..a21dac50 100644 --- a/ImmichFrame.Core/Interfaces/IRequestContext.cs +++ b/ImmichFrame.Core/Interfaces/IRequestContext.cs @@ -3,6 +3,6 @@ namespace ImmichFrame.Core.Interfaces public interface IRequestContext { int AssetOffset { get; set; } - int AssestShuffleRandom { get; set; } + int AssetShuffleRandom { get; set; } } } diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs index 4dcec4dd..48b1d2b8 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -21,7 +21,8 @@ public async Task> GetAssets(int requested, IReque requestContext.AssetOffset = 0; } - var assetsToReturn = allAssets.OrderBy(_ => requestContext.AssestShuffleRandom).Skip(requestContext.AssetOffset).Take(requested); + var orderValue = new Random(requestContext.AssetShuffleRandom); + var assetsToReturn = allAssets.OrderBy(_ => orderValue.Next()).Skip(requestContext.AssetOffset).Take(requested); requestContext.AssetOffset += assetsToReturn.Count(); if (requestContext.AssetOffset >= totalCount) diff --git a/ImmichFrame.Core/Services/RequestContextService.cs b/ImmichFrame.Core/Services/RequestContextService.cs index 1cc8e2ae..2c577e25 100644 --- a/ImmichFrame.Core/Services/RequestContextService.cs +++ b/ImmichFrame.Core/Services/RequestContextService.cs @@ -5,6 +5,6 @@ namespace ImmichFrame.Core.Services public class RequestContext : IRequestContext { public int AssetOffset { get; set; } - public int AssestShuffleRandom { get; set; } + public int AssetShuffleRandom { get; set; } } } diff --git a/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs b/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs index a1e9e292..8c701707 100644 --- a/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs +++ b/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs @@ -36,26 +36,26 @@ public async Task InvokeAsync(HttpContext context, IRequestContext requestContex } } - // assestShuffleRandom - if (context.Request.Query.TryGetValue("assestShuffleRandom", out var assestShuffleRandom)) + // assetShuffleRandom + if (context.Request.Query.TryGetValue("assestShuffleRandom", out var assetShuffleRandom)) { - string value = assestShuffleRandom.ToString(); + string value = assetShuffleRandom.ToString(); if (value != null && value.Length > 0) { bool success = int.TryParse(value, out int number); if (success) { - requestContext.AssestShuffleRandom = number; + requestContext.AssetShuffleRandom = number; } else { - requestContext.AssestShuffleRandom = 0; + requestContext.AssetShuffleRandom = 0; } } else { - requestContext.AssestShuffleRandom = 0; + requestContext.AssetShuffleRandom = 0; } } diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index e3a30fd5..d9ea44e0 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -47,7 +47,7 @@ let authError: boolean = $state(false); let errorMessage: string = $state(''); let assetOffset: number = 0; - let assestShuffleRandom: number = Math.floor(Math.random()); + let assetShuffleRandom: number = Math.floor(Math.random() * 1000000); let assetsState: AssetsState = $state({ assets: [], error: false, @@ -134,7 +134,7 @@ let assetRequest = await api.getAssets({ clientIdentifier: $clientIdentifierStore, assetOffset: assetOffset, - assestShuffleRandom: assestShuffleRandom + assestShuffleRandom: assetShuffleRandom }); if (assetRequest.status != 200) { From b03e1422bc8973fc94628efa77b82a85778b4c2c Mon Sep 17 00:00:00 2001 From: Matthew Livingstone Date: Fri, 6 Feb 2026 21:33:16 -0500 Subject: [PATCH 5/7] Remove double enumeration --- ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs index 48b1d2b8..62a94e8a 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -22,9 +22,9 @@ public async Task> GetAssets(int requested, IReque } var orderValue = new Random(requestContext.AssetShuffleRandom); - var assetsToReturn = allAssets.OrderBy(_ => orderValue.Next()).Skip(requestContext.AssetOffset).Take(requested); + var assetsToReturn = allAssets.OrderBy(_ => orderValue.Next()).Skip(requestContext.AssetOffset).Take(requested).ToList(); - requestContext.AssetOffset += assetsToReturn.Count(); + requestContext.AssetOffset += assetsToReturn.Count; if (requestContext.AssetOffset >= totalCount) { requestContext.AssetOffset = 0; From 0dcf8b5922ab08280248a04d0a4a3ad7fba958cd Mon Sep 17 00:00:00 2001 From: Matthew Livingstone Date: Fri, 6 Feb 2026 21:46:22 -0500 Subject: [PATCH 6/7] Add negative offset guard --- ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs index 62a94e8a..4a508b57 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -16,7 +16,7 @@ public async Task> GetAssets(int requested, IReque var allAssets = await AllAssets(ct); var totalCount = allAssets.Count(); - if (requestContext.AssetOffset >= totalCount) + if (requestContext.AssetOffset >= totalCount || requestContext.AssetOffset < 0) { requestContext.AssetOffset = 0; } From cf2973d612f5b8b0033129d218a74fb2dd3ec40b Mon Sep 17 00:00:00 2001 From: Matthew Livingstone Date: Fri, 6 Feb 2026 21:54:06 -0500 Subject: [PATCH 7/7] Fix typo --- ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs b/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs index 8c701707..2e8f40fc 100644 --- a/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs +++ b/ImmichFrame.WebApi/Helpers/RequestContextMiddleware.cs @@ -13,7 +13,7 @@ public RequestContextMiddleware(RequestDelegate next) public async Task InvokeAsync(HttpContext context, IRequestContext requestContext) { - // assetOffest + // assetOffset if (context.Request.Query.TryGetValue("assetOffset", out var assetOffset)) { string value = assetOffset.ToString();