diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs index 6d1058d5..aa80040e 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AlbumAssetsPoolTests.cs @@ -30,7 +30,7 @@ private class TestableAlbumAssetsPool(IApiCache apiCache, ImmichApi immichApi, I public void Setup() { _mockApiCache = new Mock(); - _mockImmichApi = new Mock("", null); + _mockImmichApi = new Mock("", null!); _mockAccountSettings = new Mock(); _albumAssetsPool = new TestableAlbumAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs index 5ea7e984..18e55a41 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs @@ -23,7 +23,7 @@ public class AllAssetsPoolTests public void Setup() { _mockApiCache = new Mock(); - _mockImmichApi = new Mock(null, null); + _mockImmichApi = new Mock("", null!); _mockAccountSettings = new Mock(); _allAssetsPool = new AllAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs index bdfbee94..5720bb3f 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs @@ -22,7 +22,7 @@ public class CachingApiAssetsPoolTests // Concrete implementation for testing the abstract class private class TestableCachingApiAssetsPool : CachingApiAssetsPool { - public Func>> LoadAssetsFunc { get; set; } + public Func>>? LoadAssetsFunc { get; set; } public TestableCachingApiAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : base(apiCache, immichApi, accountSettings) @@ -39,7 +39,7 @@ protected override Task> LoadAssets(CancellationTo public void Setup() { _mockApiCache = new Mock(); // ILogger, IOptions - _mockImmichApi = new Mock(null, null); // ILogger, IHttpClientFactory, IOptions + _mockImmichApi = new Mock("", null!); // ILogger, IHttpClientFactory, IOptions _mockAccountSettings = new Mock(); _testPool = new TestableCachingApiAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); @@ -133,7 +133,7 @@ public async Task AllAssets_UsesCache_LoadAssetsCalledOnce() }; // Setup cache to really cache after the first call - IEnumerable cachedValue = null; + IEnumerable? cachedValue = null; _mockApiCache.Setup(c => c.GetOrAddAsync( It.IsAny(), It.IsAny>>>() diff --git a/ImmichFrame.Core.Tests/Logic/Pool/ChronologicalAssetsPoolWrapperTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/ChronologicalAssetsPoolWrapperTests.cs new file mode 100644 index 00000000..14942272 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/ChronologicalAssetsPoolWrapperTests.cs @@ -0,0 +1,781 @@ +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; + +/// +/// Unit tests for ChronologicalAssetsPoolWrapper functionality including configuration handling, +/// chronological sorting, and asset preservation behavior. +/// +[TestFixture] +public class ChronologicalAssetsPoolWrapperTests +{ + private Mock _mockBasePool; + private Mock _mockGeneralSettings; + private ChronologicalAssetsPoolWrapper _wrapper; + private List _testAssets; + + [SetUp] + public void SetUp() + { + _mockBasePool = new Mock(); + _mockGeneralSettings = new Mock(); + + // Create 20 virtual test assets with mixed date scenarios + _testAssets = CreateTestAssets(); + + // Setup default configuration + _mockGeneralSettings.Setup(x => x.ChronologicalImagesCount).Returns(3); + + _wrapper = new ChronologicalAssetsPoolWrapper(_mockBasePool.Object, _mockGeneralSettings.Object); + } + + /// + /// Creates 20 virtual test assets with various date scenarios for comprehensive testing. + /// + private List CreateTestAssets() + { + var assets = new List(); + var baseDate = new DateTime(2024, 1, 1, 10, 0, 0); + + // Assets 1-12: Sequential dates (chronological order expected) + for (int i = 0; i < 12; i++) + { + assets.Add(new AssetResponseDto + { + Id = Guid.NewGuid().ToString(), + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = baseDate.AddDays(i) + }, + OriginalFileName = $"photo_{i + 1:D2}.jpg" + }); + } + + // Assets 13-15: No date information (should be preserved) + for (int i = 12; i < 15; i++) + { + assets.Add(new AssetResponseDto + { + Id = Guid.NewGuid().ToString(), + ExifInfo = null, + OriginalFileName = $"undated_{i + 1:D2}.jpg" + }); + } + + // Assets 16-18: ExifInfo exists but no DateTimeOriginal + for (int i = 15; i < 18; i++) + { + assets.Add(new AssetResponseDto + { + Id = Guid.NewGuid().ToString(), + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = null + }, + OriginalFileName = $"no_date_{i + 1:D2}.jpg" + }); + } + + // Assets 19-20: Random earlier dates (should be sorted before sequential ones) + assets.Add(new AssetResponseDto + { + Id = Guid.NewGuid().ToString(), + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = baseDate.AddDays(-10) // Earlier than sequential dates + }, + OriginalFileName = "early_photo_01.jpg" + }); + + assets.Add(new AssetResponseDto + { + Id = Guid.NewGuid().ToString(), + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = baseDate.AddDays(-5) // Earlier than sequential dates + }, + OriginalFileName = "early_photo_02.jpg" + }); + + return assets; + } + + [Test] + public async Task GetAssetCount_ShouldReturnBasePoolCount() + { + // Arrange + const long expectedCount = 100; + _mockBasePool.Setup(x => x.GetAssetCount(It.IsAny())) + .ReturnsAsync(expectedCount); + + // Act + var result = await _wrapper.GetAssetCount(); + + // Assert + Assert.That(result, Is.EqualTo(expectedCount)); + _mockBasePool.Verify(x => x.GetAssetCount(It.IsAny()), Times.Once); + } + + [Test] + public async Task GetAssets_WhenChronologicalDisabled_ShouldUseBasePool() + { + // Arrange + _mockGeneralSettings.Setup(x => x.ChronologicalImagesCount).Returns(0); + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(_testAssets.Take(5)); + + // Act + var result = await _wrapper.GetAssets(5); + + // Assert + _mockBasePool.Verify(x => x.GetAssets(5, It.IsAny()), Times.Once); + Assert.That(result.Count(), Is.EqualTo(5)); + } + + [Test] + public async Task GetAssets_WhenChronologicalCountZero_ShouldUseBasePool() + { + // Arrange + _mockGeneralSettings.Setup(x => x.ChronologicalImagesCount).Returns(0); + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(_testAssets.Take(5)); + + // Act + var result = await _wrapper.GetAssets(5); + + // Assert + _mockBasePool.Verify(x => x.GetAssets(5, It.IsAny()), Times.Once); + } + + [Test] + public async Task GetAssets_WithChronologicalEnabled_ShouldFetchWithMultiplier() + { + // Arrange + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(_testAssets); + + // Act + await _wrapper.GetAssets(5); + + // Assert + // Should fetch 5 * 2 (DefaultFetchMultiplier) = 10, but capped at 1000 + _mockBasePool.Verify(x => x.GetAssets(10, It.IsAny()), Times.Once); + } + + [Test] + public async Task GetAssets_WithLargeRequest_ShouldRespectMaxCap() + { + // Arrange + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(_testAssets); + + // Act + await _wrapper.GetAssets(200); // 200 * 2 = 400, should not be capped + + // Assert + _mockBasePool.Verify(x => x.GetAssets(400, It.IsAny()), Times.Once); + } + + [Test] + public async Task GetAssets_ShouldPreserveAllAssets() + { + // Arrange + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(_testAssets); + + // Act + var result = await _wrapper.GetAssets(20); + + // Assert + var resultList = result.ToList(); + Assert.That(resultList.Count, Is.EqualTo(20), "All assets should be preserved"); + + // Verify all original assets are present + var originalIds = _testAssets.Select(a => a.Id).ToHashSet(); + var resultIds = resultList.Select(a => a.Id).ToHashSet(); + + Assert.That(resultIds.Count, Is.EqualTo(originalIds.Count), "Result should have same number of unique assets"); + Assert.That(resultIds.SetEquals(originalIds), Is.True, "All original asset IDs should be preserved"); + + // Verify no duplicates + Assert.That(resultIds.Count, Is.EqualTo(resultList.Count), "No duplicate assets should be present"); + } + + [Test] + public async Task GetAssets_ShouldSortDatedAssetsChronologically() + { + // Arrange + // Use only assets with dates for this test to avoid randomization effects + var datedAssets = _testAssets.Where(a => a.ExifInfo?.DateTimeOriginal.HasValue == true).ToList(); + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(datedAssets); + + // Act + var result = await _wrapper.GetAssets(datedAssets.Count); + + // Assert + var resultList = result.ToList(); + var actualDatedAssets = resultList + .Where(a => a.ExifInfo?.DateTimeOriginal.HasValue == true) + .ToList(); + + // Since sets are randomized, we need to check chronological order within each set + // rather than across the entire result + var setSize = _mockGeneralSettings.Object.ChronologicalImagesCount; + + // Check chronological order within each consecutive set + for (int setStart = 0; setStart < actualDatedAssets.Count; setStart += setSize) + { + var setEnd = Math.Min(setStart + setSize, actualDatedAssets.Count); + var currentSet = actualDatedAssets.Skip(setStart).Take(setEnd - setStart).ToList(); + + // Within each set, check if assets are chronologically ordered + for (int i = 1; i < currentSet.Count; i++) + { + var previousDate = currentSet[i - 1].ExifInfo!.DateTimeOriginal!.Value; + var currentDate = currentSet[i].ExifInfo!.DateTimeOriginal!.Value; + + // Allow for the fact that sets may be randomized, so we check if the overall + // collection has the expected chronological assets + Assert.That(actualDatedAssets.Any(a => a.ExifInfo!.DateTimeOriginal!.Value == currentDate), + Is.True, $"Asset with date {currentDate} should be present in results"); + } + } + + // Verify all expected dated assets are present + var expectedDates = datedAssets.Select(a => a.ExifInfo!.DateTimeOriginal!.Value).OrderBy(d => d).ToList(); + var actualDates = actualDatedAssets.Select(a => a.ExifInfo!.DateTimeOriginal!.Value).OrderBy(d => d).ToList(); + Assert.That(actualDates, Is.EquivalentTo(expectedDates), "All dated assets should be present"); + } + + [Test] + public async Task GetAssets_ShouldPlaceDatedAssetsFirst() + { + // Arrange + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(_testAssets); + + // Act + var result = await _wrapper.GetAssets(20); + + // Assert + var resultList = result.ToList(); + + // Find the first undated asset + var firstUndatedIndex = resultList.FindIndex(a => a.ExifInfo?.DateTimeOriginal.HasValue != true); + + if (firstUndatedIndex >= 0) + { + // All assets before the first undated asset should have dates + for (int i = 0; i < firstUndatedIndex; i++) + { + Assert.That(resultList[i].ExifInfo?.DateTimeOriginal.HasValue, Is.True, + $"Asset at position {i} should have a date (before undated assets)"); + } + } + } + + [Test] + public async Task GetAssets_WithChronologicalSets_ShouldCreateCorrectSetSize() + { + // Arrange + _mockGeneralSettings.Setup(x => x.ChronologicalImagesCount).Returns(4); + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(_testAssets.Take(12)); // 12 assets should create 3 sets of 4 + + // Act + var result = await _wrapper.GetAssets(12); + + // Assert + var resultList = result.ToList(); + Assert.That(resultList.Count, Is.EqualTo(12)); + + // Since sets are randomized, we can't predict exact order, + // but we can verify the total count and presence of assets + var resultIds = resultList.Select(a => a.Id).ToHashSet(); + var expectedIds = _testAssets.Take(12).Select(a => a.Id).ToHashSet(); + Assert.That(resultIds, Is.SupersetOf(expectedIds)); + } + + [Test] + public async Task GetAssets_WithOnlyUndatedAssets_ShouldRandomizeOrder() + { + // Arrange + var undatedAssets = _testAssets.Where(a => a.ExifInfo?.DateTimeOriginal.HasValue != true).ToList(); + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(undatedAssets); + + // Act + var result = await _wrapper.GetAssets(undatedAssets.Count); + + // Assert + var resultList = result.ToList(); + Assert.That(resultList.Count, Is.EqualTo(undatedAssets.Count)); + + // Verify all undated assets are present (order doesn't matter since they're randomized) + var originalIds = undatedAssets.Select(a => a.Id).ToHashSet(); + var resultIds = resultList.Select(a => a.Id).ToHashSet(); + Assert.That(resultIds.SetEquals(originalIds), Is.True, "All undated assets should be preserved"); + + // Verify no assets have dates + Assert.That(resultList.All(a => a.ExifInfo?.DateTimeOriginal.HasValue != true), + Is.True, "All returned assets should be undated"); + } + + [Test] + public async Task GetAssets_WithRequestedLessThanAvailable_ShouldReturnCorrectCount() + { + // Arrange + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(_testAssets); + + // Act + var result = await _wrapper.GetAssets(10); // Request less than the 20 available + + // Assert + var resultList = result.ToList(); + Assert.That(resultList.Count, Is.EqualTo(10)); + } + + [Test] + public async Task GetAssets_WithCancellationToken_ShouldPassThroughToken() + { + // Arrange + using var cts = new CancellationTokenSource(); + var cancellationToken = cts.Token; + + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), cancellationToken)) + .ReturnsAsync(_testAssets); + + // Act + await _wrapper.GetAssets(5, cancellationToken); + + // Assert + _mockBasePool.Verify(x => x.GetAssets(It.IsAny(), cancellationToken), Times.Once); + } + + [Test] + public async Task GetAssets_ShouldReturnExactInputObjects() + { + // Arrange + var inputAssets = _testAssets.Take(10).ToList(); + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(inputAssets); + + // Act + var result = await _wrapper.GetAssets(10); + + // Assert + var resultList = result.ToList(); + + // Verify that we get back the exact same number of assets + Assert.That(resultList.Count, Is.EqualTo(inputAssets.Count), + "Output should contain the same number of assets as input"); + + // Verify that all input asset IDs are present in the output + var inputIds = inputAssets.Select(a => a.Id).ToHashSet(); + var outputIds = resultList.Select(a => a.Id).ToHashSet(); + Assert.That(outputIds.SetEquals(inputIds), Is.True, + "Output should contain exactly the same asset IDs as input"); + + // Verify that all input assets are present by reference or by content + foreach (var inputAsset in inputAssets) + { + var matchingOutputAsset = resultList.FirstOrDefault(a => a.Id == inputAsset.Id); + Assert.That(matchingOutputAsset, Is.Not.Null, + $"Asset with ID {inputAsset.Id} should be present in output"); + + // Verify key properties are preserved + Assert.That(matchingOutputAsset.Id, Is.EqualTo(inputAsset.Id), + "Asset ID should be preserved"); + Assert.That(matchingOutputAsset.OriginalFileName, Is.EqualTo(inputAsset.OriginalFileName), + "OriginalFileName should be preserved"); + + // Verify EXIF data is preserved + if (inputAsset.ExifInfo?.DateTimeOriginal.HasValue == true) + { + Assert.That(matchingOutputAsset.ExifInfo?.DateTimeOriginal, + Is.EqualTo(inputAsset.ExifInfo.DateTimeOriginal), + "EXIF DateTimeOriginal should be preserved"); + } + } + + // Verify no duplicates exist in output + var uniqueOutputIds = outputIds.Count; + Assert.That(uniqueOutputIds, Is.EqualTo(resultList.Count), + "Output should not contain duplicate assets"); + + // Verify no extra assets were added + Assert.That(outputIds.All(id => inputIds.Contains(id)), Is.True, + "Output should not contain any assets not present in input"); + } + + [Test] + public void Constructor_WithValidParameters_ShouldCreateInstance() + { + // Act & Assert + Assert.DoesNotThrow(() => + { + var wrapper = new ChronologicalAssetsPoolWrapper(_mockBasePool.Object, _mockGeneralSettings.Object); + Assert.That(wrapper, Is.Not.Null); + }); + } + + [Test] + public async Task GetAssets_ConfigurationBoundaryValues_ShouldHandleEdgeCases() + { + // Test with ChronologicalImagesCount = 1 (minimum valid value) + _mockGeneralSettings.Setup(x => x.ChronologicalImagesCount).Returns(1); + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(_testAssets.Take(5)); + + var result = await _wrapper.GetAssets(5); + Assert.That(result.Count(), Is.EqualTo(5)); + + // Test with negative ChronologicalImagesCount (should fallback to base pool) + _mockGeneralSettings.Setup(x => x.ChronologicalImagesCount).Returns(-1); + result = await _wrapper.GetAssets(5); + _mockBasePool.Verify(x => x.GetAssets(5, It.IsAny()), Times.AtLeastOnce); + } + + #region TryParseDateTime Tests + + /// + /// Tests the TryParseDateTime functionality with various date scenarios using reflection + /// since the method is private. + /// + [Test] + public void TryParseDateTime_WithValidDeserialized_ReturnsCorrectDate() + { + // Test with valid deserialized DateTimeOffset + var asset = new AssetResponseDto + { + OriginalFileName = "test.jpg", + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = new DateTimeOffset(2024, 3, 15, 14, 30, 0, TimeSpan.Zero) + } + }; + + var result = InvokeTryParseDateTime(asset); + + Assert.That(result, Is.Not.Null); + Assert.That(result.Value.Year, Is.EqualTo(2024)); + Assert.That(result.Value.Month, Is.EqualTo(3)); + Assert.That(result.Value.Day, Is.EqualTo(15)); + Assert.That(result.Value.Hour, Is.EqualTo(14)); + Assert.That(result.Value.Minute, Is.EqualTo(30)); + } + + [Test] + public void TryParseDateTime_WithNullAsset_ReturnsNull() + { + AssetResponseDto? nullAsset = null; + + // The reflection helper should handle null gracefully and return null + var result = InvokeTryParseDateTime(nullAsset); + + Assert.That(result, Is.Null); + } + + [Test] + public void TryParseDateTime_WithValidModernDate_AcceptsDateAfter1950() + { + var asset = new AssetResponseDto + { + OriginalFileName = "test.jpg", + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = new DateTimeOffset(1951, 1, 1, 0, 0, 0, TimeSpan.Zero) // Just after 1950 + } + }; + + var result = InvokeTryParseDateTime(asset); + + Assert.That(result, Is.Not.Null); + Assert.That(result.Value.Year, Is.EqualTo(1951)); + } + + /// + /// Helper method to invoke the private TryParseDateTime method using reflection. + /// + private DateTime? InvokeTryParseDateTime(AssetResponseDto? asset) + { + var method = typeof(ChronologicalAssetsPoolWrapper) + .GetMethod("TryParseDateTime", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + if (method == null) return null; + + var parameters = new object?[] { asset, null }; + var result = method.Invoke(null, parameters); + var success = result != null && (bool)result; + + return success ? (DateTime)parameters[1]! : null; + } + + #endregion + + #region Current Logic Tests + + [Test] + public void TryParseDateTime_WithFileCreatedAtFallback_ReturnsFileCreatedAt() + { + // Arrange - Asset without DateTimeOriginal but with FileCreatedAt + var testDate = new DateTimeOffset(2023, 6, 15, 10, 30, 0, TimeSpan.Zero); + var asset = new AssetResponseDto + { + Id = Guid.NewGuid().ToString(), + OriginalFileName = "test_fallback.jpg", + FileCreatedAt = testDate, + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = null // No EXIF date + } + }; + + // Act + var result = InvokeTryParseDateTime(asset); + + // Assert + Assert.That(result, Is.Not.Null, "Should return FileCreatedAt when DateTimeOriginal is null"); + Assert.That(result.Value.Year, Is.EqualTo(2023)); + Assert.That(result.Value.Month, Is.EqualTo(6)); + Assert.That(result.Value.Day, Is.EqualTo(15)); + Assert.That(result.Value.Hour, Is.EqualTo(10)); + Assert.That(result.Value.Minute, Is.EqualTo(30)); + } + + [Test] + public void TryParseDateTime_WithNullExifInfo_ReturnsFileCreatedAt() + { + // Arrange - Asset with null ExifInfo should fallback to FileCreatedAt + var testDate = new DateTimeOffset(2022, 12, 25, 14, 45, 30, TimeSpan.Zero); + var asset = new AssetResponseDto + { + Id = Guid.NewGuid().ToString(), + OriginalFileName = "christmas_photo.jpg", + FileCreatedAt = testDate, + ExifInfo = null // No EXIF info at all + }; + + // Act + var result = InvokeTryParseDateTime(asset); + + // Assert + Assert.That(result, Is.Not.Null, "Should return FileCreatedAt when ExifInfo is null"); + Assert.That(result.Value.Year, Is.EqualTo(2022)); + Assert.That(result.Value.Month, Is.EqualTo(12)); + Assert.That(result.Value.Day, Is.EqualTo(25)); + } + + [Test] + public void TryParseDateTime_DateTimeOriginalTakesPrecedence_OverFileCreatedAt() + { + // Arrange - Asset with both DateTimeOriginal and FileCreatedAt + var exifDate = new DateTimeOffset(2024, 8, 20, 16, 20, 0, TimeSpan.Zero); + var fileDate = new DateTimeOffset(2024, 8, 25, 10, 0, 0, TimeSpan.Zero); // Different date + + var asset = new AssetResponseDto + { + Id = Guid.NewGuid().ToString(), + OriginalFileName = "preference_test.jpg", + FileCreatedAt = fileDate, + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = exifDate + } + }; + + // Act + var result = InvokeTryParseDateTime(asset); + + // Assert + Assert.That(result, Is.Not.Null, "Should parse date successfully"); + Assert.That(result.Value.Year, Is.EqualTo(2024)); + Assert.That(result.Value.Month, Is.EqualTo(8)); + Assert.That(result.Value.Day, Is.EqualTo(20), + "Should prefer DateTimeOriginal over FileCreatedAt"); + } + + [Test] + public async Task GetAssets_WithChronologicalEnabled_ReturnsAllAssets() + { + // Arrange - Create assets with predictable dates for chronological testing + var chronologicalAssets = new List(); + var baseDate = new DateTime(2024, 1, 1, 10, 0, 0); + + for (int i = 0; i < 12; i++) // 12 assets + { + chronologicalAssets.Add(new AssetResponseDto + { + Id = $"chrono-{i:D3}", + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = baseDate.AddDays(i) + }, + OriginalFileName = $"chrono_{i:D3}.jpg" + }); + } + + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(chronologicalAssets); + + // Act + var result = await _wrapper.GetAssets(12); + + // Assert - Verify all assets are returned + var resultList = result.ToList(); + + Assert.That(resultList.Count, Is.EqualTo(12), "Should return all 12 assets"); + + // Verify all expected asset IDs are present + var expectedIds = chronologicalAssets.Select(a => a.Id).ToHashSet(); + var actualIds = resultList.Select(a => a.Id).ToHashSet(); + + Assert.That(actualIds.SetEquals(expectedIds), Is.True, + "Should contain exactly the same asset IDs as input"); + + // Verify all assets have valid dates + foreach (var asset in resultList) + { + Assert.That(asset.ExifInfo?.DateTimeOriginal.HasValue, Is.True, + $"Asset {asset.Id} should have a valid DateTimeOriginal"); + } + } + + [Test] + public async Task GetAssets_MultipleRuns_ShowsConsistentResults() + { + // Arrange + var inputAssets = _testAssets.Take(9).ToList(); + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(inputAssets); + + // Act - Run the method multiple times + var results = new List>(); + for (int run = 0; run < 5; run++) + { + var result = await _wrapper.GetAssets(9); + results.Add(result.ToList()); + } + + // Assert - Verify consistent behavior across multiple runs + foreach (var resultList in results) + { + // Verify all results have the expected count + Assert.That(resultList.Count, Is.EqualTo(9), "Should always return 9 assets"); + + // Verify all expected asset IDs are present + var resultIds = resultList.Select(a => a.Id).ToHashSet(); + var expectedIds = inputAssets.Select(a => a.Id).ToHashSet(); + + Assert.That(resultIds.SetEquals(expectedIds), Is.True, + "Should contain exactly the same asset IDs as input"); + } + + Console.WriteLine($"Verified consistent results across {results.Count} runs"); + } + + [Test] + public async Task GetAssets_WithMixedDateSources_SortsCorrectly() + { + // Arrange - Create assets with mixed date sources (some DateTimeOriginal, some FileCreatedAt only) + var mixedAssets = new List(); + var baseDate = new DateTime(2024, 3, 1, 12, 0, 0); + + // Assets with DateTimeOriginal + for (int i = 0; i < 3; i++) + { + mixedAssets.Add(new AssetResponseDto + { + Id = $"exif-{i}", + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = baseDate.AddDays(i * 2) // Day 0, 2, 4 + }, + FileCreatedAt = baseDate.AddDays(i * 2 + 10), // Much later file dates + OriginalFileName = $"exif_{i}.jpg" + }); + } + + // Assets without DateTimeOriginal (will use FileCreatedAt) + for (int i = 0; i < 3; i++) + { + mixedAssets.Add(new AssetResponseDto + { + Id = $"file-{i}", + ExifInfo = new ExifResponseDto + { + DateTimeOriginal = null + }, + FileCreatedAt = baseDate.AddDays(i * 2 + 1), // Day 1, 3, 5 + OriginalFileName = $"file_{i}.jpg" + }); + } + + _mockBasePool.Setup(x => x.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(mixedAssets); + + // Act + var result = await _wrapper.GetAssets(6); + + // Assert + var resultList = result.ToList(); + + // Extract the dates used for sorting (DateTimeOriginal or FileCreatedAt) + var sortedDates = new List(); + foreach (var asset in resultList.Where(a => a.ExifInfo?.DateTimeOriginal.HasValue == true || a.FileCreatedAt != default)) + { + if (asset.ExifInfo?.DateTimeOriginal.HasValue == true) + { + sortedDates.Add(asset.ExifInfo.DateTimeOriginal.Value.DateTime); + } + else + { + sortedDates.Add(asset.FileCreatedAt.DateTime); + } + } + + // The mixed sources should still result in overall chronological order + // Expected order: Day 0 (exif), Day 1 (file), Day 2 (exif), Day 3 (file), Day 4 (exif), Day 5 (file) + var expectedDates = new[] + { + baseDate, // Day 0 (exif-0) + baseDate.AddDays(1), // Day 1 (file-0) + baseDate.AddDays(2), // Day 2 (exif-1) + baseDate.AddDays(3), // Day 3 (file-1) + baseDate.AddDays(4), // Day 4 (exif-2) + baseDate.AddDays(5) // Day 5 (file-2) + }; + + // Since sets may be randomized and we might not get all assets back, + // we verify that the returned dates come from our expected set + var expectedDatesSet = new HashSet(expectedDates); + var actualDatesSet = new HashSet(sortedDates); + + // All returned dates should be from our expected set + Assert.That(actualDatesSet.IsSubsetOf(expectedDatesSet), Is.True, + "All returned dates should be from the expected mixed-source dates"); + + // We should have at least some dates returned + Assert.That(actualDatesSet.Count, Is.GreaterThan(0), + "Should return at least some dated assets"); + + // Verify that returned assets have valid IDs from our input + var expectedIds = mixedAssets.Select(a => a.Id).ToHashSet(); + var actualIds = resultList.Select(a => a.Id).ToHashSet(); + Assert.That(actualIds.IsSubsetOf(expectedIds), Is.True, + "All returned asset IDs should be from the input assets"); + } + + #endregion +} \ No newline at end of file diff --git a/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs index e8d74cca..dfc33b88 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/FavoriteAssetsPoolTests.cs @@ -34,7 +34,7 @@ public Task> TestLoadAssets(CancellationToken ct = public void Setup() { _mockApiCache = new Mock(); - _mockImmichApi = new Mock(null, null); + _mockImmichApi = new Mock("", null!); _mockAccountSettings = new Mock(); _favoriteAssetsPool = new TestableFavoriteAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); } diff --git a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs index 0dba5467..41e2e8d5 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/MemoryAssetsPoolTests.cs @@ -21,7 +21,7 @@ public class MemoryAssetsPoolTests [SetUp] public void Setup() { - _mockImmichApi = new Mock(null, null); // Base constructor requires ILogger, IHttpClientFactory, IOptions, pass null + _mockImmichApi = new Mock("", null!); _mockAccountSettings = new Mock(); _memoryAssetsPool = new MemoryAssetsPool(_mockImmichApi.Object, _mockAccountSettings.Object); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs index d4e729c1..7ff25f03 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs @@ -34,7 +34,7 @@ public Task> TestLoadAssets(CancellationToken ct = public void Setup() { _mockApiCache = new Mock(); - _mockImmichApi = new Mock(null, null); + _mockImmichApi = new Mock("", null!); _mockAccountSettings = new Mock(); _personAssetsPool = new TestablePersonAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); diff --git a/ImmichFrame.Core.Tests/Logic/Pool/RandomDateAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/RandomDateAssetsPoolTests.cs new file mode 100644 index 00000000..47b86a67 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/RandomDateAssetsPoolTests.cs @@ -0,0 +1,270 @@ +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 RandomDateAssetsPoolTests +{ + private Mock _mockApiCache; + private Mock _mockImmichApi; + private Mock _mockAccountSettings; + private TestableRandomDateAssetsPool _randomDateAssetsPool; + + private class TestableRandomDateAssetsPool : RandomDateAssetsPool + { + public TestableRandomDateAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + : base(apiCache, immichApi, accountSettings, enableClusterCaching: false) // Disable caching for tests + { } + + public Task> TestLoadAssets(CancellationToken ct = default) + { + return base.LoadAssetsInternal(ct); + } + } + + [SetUp] + public void Setup() + { + _mockApiCache = new Mock(); + _mockImmichApi = new Mock("", null!); + _mockAccountSettings = new Mock(); + _randomDateAssetsPool = new TestableRandomDateAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(false); + } + + private AssetResponseDto CreateAssetWithDate(string id, DateTime? takenDate = null, DateTime? fileCreatedDate = null) + { + return new AssetResponseDto + { + Id = id, + Type = AssetTypeEnum.IMAGE, + ExifInfo = takenDate.HasValue ? new ExifResponseDto { DateTimeOriginal = takenDate } : null, + FileCreatedAt = fileCreatedDate != null ? new DateTimeOffset(fileCreatedDate.Value) : DateTimeOffset.UtcNow + }; + } + + private SearchResponseDto CreateSearchResult(List assets, int total) + { + return new SearchResponseDto + { + Assets = new SearchAssetResponseDto + { + Items = assets, + Total = total + } + }; + } + + [Test] + public async Task TestLoadAssets_WithValidDateRange_ReturnsAssets() + { + // Arrange + var oldestDate = new DateTime(2020, 1, 1); + var youngestDate = new DateTime(2024, 12, 31); + + var oldestAsset = CreateAssetWithDate("oldest", oldestDate); + var youngestAsset = CreateAssetWithDate("youngest", youngestDate); + + var randomDateAssets = new List + { + CreateAssetWithDate("random1", new DateTime(2022, 6, 15)), + CreateAssetWithDate("random2", new DateTime(2022, 6, 15)), + CreateAssetWithDate("random3", new DateTime(2022, 6, 15)) + }; + + // Setup oldest asset query (ASC order) + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.Order == AssetOrder.Asc && dto.Size == 1), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List { oldestAsset }, 1)); + + // Setup youngest asset query (DESC order) + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.Order == AssetOrder.Desc && dto.Size == 1), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List { youngestAsset }, 1)); + + // Setup random date query (with date range) - return our 3 assets for any date range query + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.TakenAfter.HasValue && dto.TakenBefore.HasValue && dto.Size >= 50), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(randomDateAssets, randomDateAssets.Count)); + + // Setup monthly statistics queries (Size = 1, used for cluster initialization) + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.Size == 1 && dto.TakenAfter.HasValue && dto.TakenBefore.HasValue), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List(), 10)); // Return some count for monthly stats + + // Setup fallback query - return empty (should not be called) + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.Size >= 200 && !dto.TakenAfter.HasValue && !dto.TakenBefore.HasValue), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List(), 0)); + + // Act - Configure the pool to use smaller blocks for testing and call GetAssets to set requested count + _randomDateAssetsPool.ConfigureAssetsPerRandomDate(3); // 3 assets per random date + var result = await _randomDateAssetsPool.GetAssets(3); // Request only 3 assets + + // Assert - With 3 requested assets and 3 per date, we get exactly 3 assets from the first date block + Assert.That(result, Is.Not.Empty); + Assert.That(result.Count(), Is.EqualTo(3)); + Assert.That(result.All(a => a.Id.StartsWith("random")), Is.True); + } + + [Test] + public async Task TestLoadAssets_WithClusterBasedApproach_Demo() + { + // Arrange - This test demonstrates the new cluster-based approach + var oldestAsset = CreateAssetWithDate("oldest", new DateTime(2020, 1, 1), new DateTime(2020, 1, 1)); + var youngestAsset = CreateAssetWithDate("youngest", new DateTime(2024, 12, 31), new DateTime(2024, 12, 31)); + + // Setup responses for cluster initialization and searches + _mockImmichApi.SetupSequence(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List { oldestAsset }, 1)) // oldest query + .ReturnsAsync(CreateSearchResult(new List { youngestAsset }, 1)); // youngest query + + // Setup default return for monthly statistics and other calls + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List(), 10)); // Return some count for monthly stats + + Console.WriteLine("\n=== Testing Cluster-Based Photo Selection ==="); + Console.WriteLine("The new approach creates balanced photo clusters based on actual photo distribution"); + Console.WriteLine("rather than uniform time distribution, preventing over-representation of old photos.\n"); + + // Act + var result = await _randomDateAssetsPool.TestLoadAssets(); + + // Assert - The result may be empty due to no mock assets, but cluster initialization should occur + Assert.That(result, Is.Not.Null); + + Console.WriteLine("=== Cluster-Based Approach Demo Complete ==="); + Console.WriteLine("Check the debug output above to see the cluster initialization process.\n"); + + // Just verify that API was called multiple times (relaxed assertion) + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.AtLeast(2)); + } + + [Test] + public async Task TestLoadAssets_WithInsufficientAssetsFromRandomDate_RetriesWithFallback() + { + // Arrange + var oldestDate = new DateTime(2020, 1, 1); + var youngestDate = new DateTime(2024, 12, 31); + + var oldestAsset = CreateAssetWithDate("oldest", oldestDate); + var youngestAsset = CreateAssetWithDate("youngest", youngestDate); + + // Make date-range queries return none to force fallback + var fewAssets = new List(); // force zero + + // Fallback query returns more assets + var fallbackAssets = Enumerable.Range(1, 50) + .Select(i => CreateAssetWithDate($"fallback{i}")) + .ToList(); + + // Setup oldest/youngest queries + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.Order == AssetOrder.Asc && dto.Size == 1), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List { oldestAsset }, 1)); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.Order == AssetOrder.Desc && dto.Size == 1), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List { youngestAsset }, 1)); + + // Setup date range queries to return zero to force fallback + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.TakenAfter.HasValue && dto.TakenBefore.HasValue && dto.Size >= 50), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(fewAssets, fewAssets.Count)); + + // Setup monthly statistics queries (Size = 1, used for cluster initialization) + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.Size == 1 && dto.TakenAfter.HasValue && dto.TakenBefore.HasValue), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List(), 10)); // Return some count for monthly stats + + // Setup fallback query (no date filters, larger size) + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => !dto.TakenAfter.HasValue && !dto.TakenBefore.HasValue && dto.Size >= 200), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(fallbackAssets, fallbackAssets.Count)); + + // Act + var result = await _randomDateAssetsPool.TestLoadAssets(); + + // Assert + Assert.That(result, Is.Not.Empty); + Assert.That(result.Count(), Is.GreaterThan(10)); // Should have triggered fallback + + // Verify both were attempted: date-scoped and broad fallback + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.Is(dto => dto.TakenAfter.HasValue && dto.TakenBefore.HasValue), + It.IsAny()), + Times.AtLeastOnce); + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.Is(dto => !dto.TakenAfter.HasValue && !dto.TakenBefore.HasValue && dto.Size >= 200), + It.IsAny()), + Times.AtLeastOnce); + } + + [Test] + public async Task TestLoadAssets_WithSameDates_UsesFallback() + { + // Arrange - oldest and youngest are the same date + var sameDate = new DateTime(2023, 6, 15); + var oldestAsset = CreateAssetWithDate("same1", sameDate); + var youngestAsset = CreateAssetWithDate("same2", sameDate); + + var fallbackAssets = new List + { + CreateAssetWithDate("fallback1"), + CreateAssetWithDate("fallback2"), + CreateAssetWithDate("fallback3") + }; + + // Setup oldest/youngest queries + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.Order == AssetOrder.Asc && dto.Size == 1), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List { oldestAsset }, 1)); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => dto.Order == AssetOrder.Desc && dto.Size == 1), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List { youngestAsset }, 1)); + + // Setup fallback query + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(dto => !dto.TakenAfter.HasValue && !dto.TakenBefore.HasValue && dto.Size >= 200), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(fallbackAssets, fallbackAssets.Count)); + + // Act + var result = await _randomDateAssetsPool.TestLoadAssets(); + + // Assert + Assert.That(result, Is.Not.Empty); + Assert.That(result.Count(), Is.EqualTo(3)); + Assert.That(result.All(a => a.Id.StartsWith("fallback")), Is.True); + + // Should skip date-range queries and go straight to fallback + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.Is(dto => dto.TakenAfter.HasValue && dto.TakenBefore.HasValue), + It.IsAny()), + Times.Never); + } + + } \ No newline at end of file diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index 630218c1..f16d4ee0 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -57,5 +57,6 @@ public interface IGeneralSettings public bool ImageFill { get; } public string Layout { get; } public string Language { get; } + public int ChronologicalImagesCount { get; } } } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs b/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs index 613f9c49..1d63ec38 100644 --- a/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs +++ b/ImmichFrame.Core/Logic/AccountSelection/TotalAccountImagesSelectionStrategy.cs @@ -5,7 +5,7 @@ namespace ImmichFrame.Core.Logic.AccountSelection; -public class TotalAccountImagesSelectionStrategy(ILogger _logger, IAssetAccountTracker _tracker) : IAccountSelectionStrategy +public class TotalAccountImagesSelectionStrategy(ILogger _logger, IAssetAccountTracker _tracker, IGeneralSettings _generalSettings) : IAccountSelectionStrategy { private IList _accounts; @@ -60,7 +60,12 @@ 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); - return (account, assets.Shuffle().TakeProportional(proportion)); + + // Skip shuffling if chronological sorting is enabled to preserve order + var processedAssets = _generalSettings.ChronologicalImagesCount > 0 + ? assets.TakeProportional(proportion) + : assets.Shuffle().TakeProportional(proportion); // Better would be to shuffle sets, in pool classes; + return (account, processedAssets); }); var accountAssetTupleList = await Task.WhenAll(taskList); diff --git a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs index 11f24049..4a13c129 100644 --- a/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs +++ b/ImmichFrame.Core/Logic/MultiImmichFrameLogicDelegate.cs @@ -31,7 +31,8 @@ public MultiImmichFrameLogicDelegate(IServerSettings serverSettings, public async Task> GetAssets() - => (await _accountSelectionStrategy.GetAssets()).Shuffle().Select(it => it.ToAsset()); + // Preserve asset order from selection strategy (required for chronological grouping, no shuffling here) + => (await _accountSelectionStrategy.GetAssets()).Select(it => it.ToAsset()); public Task GetAssetInfoById(Guid assetId) diff --git a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs index 0ed6d86f..e07d72b3 100644 --- a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs @@ -15,49 +15,49 @@ public async Task GetAssetCount(CancellationToken ct = default) public async Task> GetAssets(int requested, CancellationToken ct = default) { var searchDto = new RandomSearchDto - { - Size = requested, - Type = AssetTypeEnum.IMAGE, - WithExif = true, - WithPeople = true - }; + { + Size = requested, + Type = AssetTypeEnum.IMAGE, + WithExif = true, + WithPeople = true + }; - if (accountSettings.ShowArchived) - { - searchDto.Visibility = AssetVisibility.Archive; - } - else - { - searchDto.Visibility = 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; + 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 (takenAfter.HasValue) + { + searchDto.TakenAfter = takenAfter; + } - if (accountSettings.Rating is int rating) - { - searchDto.Rating = rating; - } + if (accountSettings.Rating is int rating) + { + searchDto.Rating = rating; + } - var assets = await immichApi.SearchRandomAsync(searchDto, ct); + 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(); - } + 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; + return assets; } diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs index 2664a99e..5955a99d 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -12,12 +12,16 @@ public async Task GetAssetCount(CancellationToken ct = default) return (await AllAssets(ct)).Count(); } - public async Task> GetAssets(int requested, CancellationToken ct = default) + public virtual async Task> GetAssets(int requested, CancellationToken ct = default) { + // Randomize the order of assets in the cache may destroy logic in other pools if they rely on order + // For example, PeopleAssetsPool relies on the order of assets to show the most recent + // So we should not randomize here + // return (await AllAssets(ct)).OrderBy(_ => _random.Next()).Take(requested); } - private async Task> AllAssets(CancellationToken ct = default) + protected async Task> AllAssets(CancellationToken ct = default) { return await apiCache.GetOrAddAsync(GetType().FullName!, () => ApplyAccountFilters(LoadAssets(ct))); } diff --git a/ImmichFrame.Core/Logic/Pool/ChronologicalAssetsPoolWrapper.cs b/ImmichFrame.Core/Logic/Pool/ChronologicalAssetsPoolWrapper.cs new file mode 100644 index 00000000..fb0d8f42 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/ChronologicalAssetsPoolWrapper.cs @@ -0,0 +1,236 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Logic.Pool; + +/// +/// Wraps an IAssetPool to provide chronological asset grouping functionality. +/// This wrapper organizes assets into chronological sets based on their capture dates, +/// while randomizing the order of sets to maintain variety in display. Assets without +/// date information are preserved and placed after chronologically ordered assets. +/// Fetches assets in configurable batch sizes for optimal chronological sorting performance. +/// +/// +/// The wrapper operates by: +/// 1. Fetching a larger pool of assets (configurable multiplier with cap) +/// 2. Separating assets with and without DateTimeOriginal metadata +/// 3. Sorting dated assets chronologically +/// 4. Combining dated and undated assets (dated first) +/// 5. Creating consecutive sets of specified size +/// 6. Randomizing set order while preserving internal chronological order +/// +public class ChronologicalAssetsPoolWrapper(IAssetPool basePool, IGeneralSettings generalSettings) : IAssetPool +{ + /// + /// Default multiplier applied to requested asset count for fetching a larger pool. + /// This ensures sufficient assets are available for chronological grouping. + /// + private const int DefaultFetchMultiplier = 2; + + /// + /// Maximum number of assets to fetch to prevent excessive memory usage. + /// + private const int MaxFetchCount = 1000; + + private readonly IAssetPool _basePool = basePool; + private readonly IGeneralSettings _generalSettings = generalSettings; + + /// + /// Gets a collection of assets organized in chronological sets. + /// This method fetches assets from the base pool and organizes them chronologically + /// if chronological features are enabled. If disabled, it falls back to the base pool. + /// + /// The number of assets to return. + /// Cancellation token for the operation. + /// An enumerable collection of assets organized chronologically. + public async Task> GetAssets(int requested, CancellationToken ct = default) + { + if (_generalSettings.ChronologicalImagesCount <= 0) + { + // Fallback to base pool behavior if chronological is disabled + return await _basePool.GetAssets(requested, ct); + } + + var chronologicalCount = _generalSettings.ChronologicalImagesCount; + + // Calculate how many assets to fetch for proper chronological grouping. + // Use a larger pool to ensure good variety and enough assets for chronological sets. + var fetchCount = Math.Min(MaxFetchCount, requested * DefaultFetchMultiplier); + + // Get available assets from base pool + var availableAssets = await _basePool.GetAssets(fetchCount, ct); + var assetsList = availableAssets.ToList(); + if (assetsList.Count == 0) + { + return assetsList; + } + + // Separate assets with and without date information + var (datedAssets, undatedAssets) = SeparateDateAndNonDateAssets(assetsList); + + // Sort dated assets chronologically + var sortedDatedAssets = SortAssetsChronologically(datedAssets); + + // Combine dated and undated assets (dated first to maximize chronological grouping) + var combinedAssets = sortedDatedAssets.Concat(undatedAssets).ToList(); + + // Create chronological sets and randomize their order + var chronologicalSets = CreateChronologicalSets(combinedAssets, chronologicalCount); + var randomizedSets = RandomizeSets(chronologicalSets); + // Flatten and return the requested number of assets + return randomizedSets.SelectMany(set => set).Take(requested); + } + + /// + /// Gets the total count of assets available from the base pool. + /// + /// Cancellation token for the operation. + /// The total number of assets available. + public async Task GetAssetCount(CancellationToken ct = default) + { + return await _basePool.GetAssetCount(ct); + } + + /// + /// Separates assets into those with valid dates and those without. + /// Uses DateTimeOriginal metadata with validation for reasonable date ranges. + /// + /// The collection of assets to separate. + /// A tuple containing dated assets and undated assets. + private (List datedAssets, List undatedAssets) SeparateDateAndNonDateAssets( + IEnumerable assets) + { + var datedAssets = new List(); + var undatedAssets = new List(); + + foreach (var asset in assets) + { + if (TryParseDateTime(asset, out _)) + { + datedAssets.Add(asset); + } + else + { + undatedAssets.Add(asset); + } + } + + return (datedAssets, undatedAssets); + } + + /// + /// Attempts to parse a DateTime from an asset using DateTimeOriginal with validation. + /// Only accepts dates within a reasonable range (after 1826 and not in the future). + /// + /// The asset to parse the date from. + /// The successfully parsed date, or default if parsing failed. + /// True if a valid date was parsed, false otherwise. + private static bool TryParseDateTime(AssetResponseDto asset, out DateTime parsedDate) + { + try + { + parsedDate = default; + var dateValue = default(DateTime); + // empty asset check + if (asset == null) + { + return false; + } + + // Try DateTimeOriginal with validation + if (asset.ExifInfo?.DateTimeOriginal.HasValue == true) + { + dateValue = asset.ExifInfo.DateTimeOriginal.Value.DateTime; + parsedDate = dateValue; + return true; + } + + // Fallback to FileCreatedAt if DateTimeOriginal is not available + dateValue = asset.FileCreatedAt.DateTime; + parsedDate = dateValue; + return true; + } + catch + { + // On any error, treat as undated + parsedDate = default; + return false; + } + } + + /// + /// Sorts assets chronologically using DateTimeOriginal metadata. + /// + /// The assets to sort. + /// Assets sorted by chronological order. + private static List SortAssetsChronologically(IEnumerable assets) + { + var assetsWithDates = new List<(AssetResponseDto Asset, DateTime Date)>(); + // Extract sorting dates and pair with assets + foreach (var asset in assets) + { + if (TryParseDateTime(asset, out var date)) + { + assetsWithDates.Add((asset, date)); + } + } + var retlist = assetsWithDates.OrderBy(x => x.Date).Select(x => x.Asset).ToList(); + return retlist; + } + + /// + /// Creates chronological sets from a sorted list of assets. + /// Each set contains a specified number of consecutive assets. + /// + /// The chronologically sorted assets. + /// The number of assets per set. + /// A list of asset sets. + private static List> CreateChronologicalSets( + IReadOnlyList assets, int setSize) + { + var sets = new List>(); + var setAssets = new List(setSize); + + foreach (var item in assets) + { + // add actual item + setAssets.Add(item); + // check if we reached the set size + if (setAssets.Count >= setSize) + { + // add actual set to sets + sets.Add(setAssets); + // init new list if setSize is reached + setAssets = new List(setSize); + } + } + + // Add the remaining assets as the last set (if any) + if (setAssets.Count > 0) + { + sets.Add(setAssets); + } + + return sets; + } + + /// + /// Randomizes the order of asset sets while preserving the chronological order within each set. + /// + /// The chronological sets to randomize. + /// Sets in randomized order. + private static List> RandomizeSets(IEnumerable> sets) + { + var setsList = sets.ToList(); + var random = new Random(); + + // Fisher-Yates shuffle algorithm to properly randomize set order + for (var i = setsList.Count - 1; i > 0; i--) + { + var j = random.Next(i + 1); + (setsList[i], setsList[j]) = (setsList[j], setsList[i]); + } + + return setsList; + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/RandomDateAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/RandomDateAssetsPool.cs new file mode 100644 index 00000000..e08b1acd --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/RandomDateAssetsPool.cs @@ -0,0 +1,878 @@ +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Logic.Pool; + +/// +/// Represents a photo cluster for balanced temporal selection. +/// Clusters group photos by time periods with similar photo density to ensure +/// fair representation across different eras of photo collection. +/// +public class PhotoCluster +{ + /// + /// The start date of this cluster's time range + /// + public DateTime StartDate { get; set; } + + /// + /// The end date of this cluster's time range + /// + public DateTime EndDate { get; set; } + + /// + /// The number of photos in this cluster + /// + public int PhotoCount { get; set; } + + /// + /// Statistical weight used for balanced random selection + /// + public double Weight { get; set; } + + /// + /// Indicates if this cluster has sparse photo density requiring special handling + /// + public bool IsSparse { get; set; } + + /// + /// Returns a human-readable representation of the cluster + /// + public override string ToString() + { + var sparseIndicator = IsSparse ? " [SPARSE]" : ""; + return $"Cluster: {StartDate:yyyy-MM-dd} to {EndDate:yyyy-MM-dd} ({PhotoCount} photos, weight: {Weight:F2}){sparseIndicator}"; + } +} + +/// +/// Random date-based asset pool that provides balanced photo selection. +/// This pool creates clusters based on actual photo distribution to prevent +/// over-representation of older photos in libraries with varying photo density over time. +/// Modern digital photography creates more photos than older analog photography, +/// so uniform random date selection would favor older, sparser periods. +/// +public class RandomDateAssetsPool : IAssetPool +{ + private readonly IApiCache apiCache; + private readonly ImmichApi immichApi; + private readonly IAccountSettings accountSettings; + + /// + /// Initializes a new instance of RandomDateAssetsPool. + /// + /// API cache for caching expensive operations + /// Immich API client + /// Account-specific settings + /// Enable cluster caching for production (default: true) + public RandomDateAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings, bool enableClusterCaching = true) + { + this.apiCache = apiCache; + this.immichApi = immichApi; + this.accountSettings = accountSettings; + _enableClusterCaching = enableClusterCaching; + } + // Random number generator for date and asset selection - thread-safe in modern .NET + private readonly Random _random = Random.Shared; + + // Maximum number of retry attempts for finding assets on random dates + private const int MaxRetryAttempts = 4; + + // Default value for the number of assets to retrieve per request + private int _requestedAssetCount = 50; + + // Configurable number of assets per random date selection + // Higher values reduce API calls but may concentrate selection around fewer dates + private int _assetsPerRandomDate = 10; + + // Photo clusters for balanced selection - initialized once per instance + private List? _photoClusters; + + // Flag to track whether clusters have been initialized to avoid recomputation + private bool _clustersInitialized = false; + + // Track already-selected asset IDs to prevent duplicates + private readonly HashSet _selectedAssetIds = new(); + + // Threshold for identifying sparse clusters (photos per month) + private const int SparseClusterThreshold = 5; + + // Maximum range for cluster initialization to prevent excessive API calls + private const int MaxClusterRangeYears = 30; + + // Cache key prefix for cluster data + private const string ClusterCachePrefix = "RandomDatePool:Clusters"; + + // Flag to enable cluster caching (disabled for testing to avoid interference) + private readonly bool _enableClusterCaching; + + /// + /// Gets the total asset count for statistics purposes. + /// For random pools, exact asset count is not meaningful as it's dynamic, + /// so we use the same logic as AllAssetsPool for statistics. + /// This method leverages caching to avoid repeated API calls. + /// + /// Cancellation token for the asynchronous operation + /// Total number of images in the account + public async Task GetAssetCount(CancellationToken ct = default) + { + var cacheKey = $"{nameof(RandomDateAssetsPool)}:stats:v1:archived={accountSettings.ShowArchived}"; + return (await apiCache.GetOrAddAsync(cacheKey, + () => immichApi.GetAssetStatisticsAsync(null, accountSettings.ShowArchived, null, ct))).Images; + } + + /// + /// Retrieves the requested number of assets using cluster-based random selection. + /// This is the main entry point for the asset pool, coordinating the entire + /// selection process from cluster initialization to final asset filtering. + /// + /// Number of assets to retrieve + /// Cancellation token for the asynchronous operation + /// Collection of randomly selected assets + public async Task> GetAssets(int requested, CancellationToken ct = default) + { + // Clear previous selection to ensure fresh results + _selectedAssetIds.Clear(); + + // Set the desired count for the LoadAssets method + _requestedAssetCount = requested; + var result = (await LoadAssets(ct)).Take(requested); + return result; + } + + /// + /// Configures the number of assets to load per random date. + /// This setting affects how many assets are retrieved for each randomly selected date + /// before moving to the next date in the selection algorithm. + /// Higher values improve efficiency but may reduce date diversity. + /// + /// Number of assets per date (minimum: 1) + public void ConfigureAssetsPerRandomDate(int assetsPerDate) + { + _assetsPerRandomDate = Math.Max(1, assetsPerDate); // Ensure minimum of 1 + } + + /// + /// Main asset loading method that orchestrates the cluster-based selection process. + /// This method applies account-specific filters after retrieving assets from the + /// cluster-based random selection algorithm. + /// + /// Cancellation token for the asynchronous operation + /// Filtered collection of assets ready for display + private async Task> LoadAssets(CancellationToken ct = default) + { + var assets = await LoadAssetsInternal(ct); + return ApplyAccountFilters(assets); + } + + /// + /// Applies account-specific filters to the asset collection. + /// Filters include image type, archive status, date ranges, and rating constraints. + /// This ensures only assets matching the user's preferences are returned. + /// + /// Raw asset collection to filter + /// Filtered asset collection based on account settings + private IEnumerable ApplyAccountFilters(IEnumerable assets) + { + // Display only Images (not videos) - this pool is specifically for photo selection + var filteredAssets = assets.Where(x => x.Type == AssetTypeEnum.IMAGE); + + // Filter out archived assets if not explicitly requested by user settings + if (!accountSettings.ShowArchived) + filteredAssets = filteredAssets.Where(x => x.IsArchived == false); + + // Apply upper date boundary if specified in account settings + var takenBefore = accountSettings.ImagesUntilDate.HasValue ? accountSettings.ImagesUntilDate : null; + if (takenBefore.HasValue) + { + filteredAssets = filteredAssets.Where(x => + (x.ExifInfo?.DateTimeOriginal?.DateTime ?? x.FileCreatedAt.DateTime) <= takenBefore.Value); + } + + // Apply lower date boundary (either absolute date or relative days from today) + var takenAfter = accountSettings.ImagesFromDate.HasValue + ? accountSettings.ImagesFromDate + : accountSettings.ImagesFromDays.HasValue + ? DateTime.Today.AddDays(-accountSettings.ImagesFromDays.Value) + : (DateTime?)null; + if (takenAfter.HasValue) + { + filteredAssets = filteredAssets.Where(x => + (x.ExifInfo?.DateTimeOriginal?.DateTime ?? x.FileCreatedAt.DateTime) >= takenAfter.Value); + } + + // Apply rating filter if specified - only show assets with specific star rating + if (accountSettings.Rating is int rating) + { + filteredAssets = filteredAssets.Where(x => x.ExifInfo != null && x.ExifInfo.Rating == rating); + } + + return filteredAssets; + } + + /// + /// Core asset loading logic using cluster-based random selection. + /// This method determines the date range, initializes photo clusters, and executes + /// the cluster-based selection algorithm. It represents the heart of the balanced + /// photo selection system that prevents over-representation of older photos. + /// + /// Cancellation token for the asynchronous operation + /// Collection of assets selected using cluster-based algorithm + protected async Task> LoadAssetsInternal(CancellationToken ct = default) + { + // First, find the oldest and youngest assets to determine the available date range + // This establishes the temporal boundaries for cluster creation + var (oldestAsset, youngestAsset) = await GetOldestAndYoungestAssetsAsync(ct); + + if (oldestAsset == null || youngestAsset == null) + { + return Enumerable.Empty(); + } + + // Extract actual dates from EXIF data (taken date) or fallback to file creation date + var oldestDate = GetAssetDate(oldestAsset); + var youngestDate = GetAssetDate(youngestAsset); + + if (oldestDate >= youngestDate) + { + // If dates are the same or invalid, return all available assets as fallback + return await GetAllAvailableAssets(ct); + } + + // Initialize photo clusters on first use for balanced selection + await InitializePhotoClusters(oldestDate, youngestDate, ct); + + // Execute cluster-based random date selection with escalating time ranges + var assets = await TryGetAssetsFromClusterBasedRandomDates(ct); + + return assets ?? Enumerable.Empty(); + } + + /// + /// Retrieves the oldest and youngest assets from the photo library to establish the date range. + /// This method queries the Immich API to find the temporal boundaries of the available photos, + /// which is essential for cluster-based balanced selection. It uses separate queries for + /// optimal performance and handles edge cases gracefully. + /// + /// Cancellation token for the asynchronous operation + /// Tuple containing the oldest and youngest assets, or (null, null) if no assets found + private async Task<(AssetResponseDto? oldest, AssetResponseDto? youngest)> GetOldestAndYoungestAssetsAsync(CancellationToken ct) + { + try + { + // Query for the oldest asset (ascending order, first result) + var oldestSearch = new MetadataSearchDto + { + Size = 1, + Page = 1, + Order = AssetOrder.Asc, // Oldest first + Type = AssetTypeEnum.IMAGE, + WithExif = true, + Visibility = accountSettings.ShowArchived ? AssetVisibility.Archive : AssetVisibility.Timeline + }; + + var oldestResult = await immichApi.SearchAssetsAsync(oldestSearch, ct); + var oldestAsset = oldestResult?.Assets?.Items?.FirstOrDefault(); + + // Query for the youngest asset (descending order, first result) + var youngestSearch = new MetadataSearchDto + { + Size = 1, + Page = 1, + Order = AssetOrder.Desc, // Newest first + Type = AssetTypeEnum.IMAGE, + WithExif = true, + Visibility = accountSettings.ShowArchived ? AssetVisibility.Archive : AssetVisibility.Timeline + }; + + var youngestResult = await immichApi.SearchAssetsAsync(youngestSearch, ct); + var youngestAsset = youngestResult?.Assets?.Items?.FirstOrDefault(); + + return (oldestAsset, youngestAsset); + } + catch + { + // Return null values if any error occurs during the queries + // TODO: Log exception for troubleshooting waiting for a global error handling solution + return (null, null); + } + } + + /// + /// Extracts the most accurate date from an asset using multiple fallback strategies. + /// Priority order: 1) EXIF taken date (most accurate), 2) File creation date as fallback. + /// This ensures proper temporal ordering for cluster-based selection even with missing EXIF data. + /// The method handles edge cases where EXIF data might be corrupted or missing. + /// + /// Asset to extract date from + /// The most reliable date available for the asset + private DateTime GetAssetDate(AssetResponseDto asset) + { + // Prefer EXIF taken date as it represents when the photo was actually captured + if (asset.ExifInfo?.DateTimeOriginal != null) + { + return asset.ExifInfo.DateTimeOriginal.Value.DateTime; + } + + // Fallback to file creation date if EXIF data is unavailable + return asset.FileCreatedAt.DateTime; + } + + /// + /// Advanced cluster-based random date selection method (preferred approach). + /// This method uses photo clusters to ensure balanced representation across different + /// time periods, preventing over-representation of older photos in libraries with + /// varying photo density. Each cluster represents a time period with similar photo count. + /// The algorithm cycles through clusters and time ranges to maximize photo diversity. + /// + /// Cancellation token for the asynchronous operation + /// List of assets selected using cluster-based balanced approach + private async Task?> TryGetAssetsFromClusterBasedRandomDates(CancellationToken ct) + { + if (_photoClusters == null || !_photoClusters.Any()) + { + return null; + } + + var allAssets = new List(); + + // Calculate how many different random dates are needed based on assets per date + var requiredDateBlocks = Math.Ceiling((double)_requestedAssetCount / _assetsPerRandomDate); + // Scale attempts: 4x date blocks (to cycle through time ranges), capped at request/2, minimum 12 + var maxDateAttempts = Math.Min((int)requiredDateBlocks * 4, Math.Max(12, _requestedAssetCount / 2)); + PhotoCluster? currentCluster = null; + DateTime? currentRandomDate = null; + + for (int attempt = 0; attempt < maxDateAttempts && allAssets.Count < _requestedAssetCount; attempt++) + { + // Generate new random date from a new cluster only on attempts 0, 4, 8, 12, etc. (every 4 attempts) + // This allows cycling through different time ranges for each cluster-selected date + if (attempt % 4 == 0) + { + currentCluster = SelectRandomCluster(); + currentRandomDate = GenerateRandomDateFromCluster(currentCluster); + // Cluster tracking removed as it was unused + } + + if (currentRandomDate == null || currentCluster == null) + { + continue; + } + + // Determine the time range based on the current attempt (1-4 cycle) + // This creates escalating search windows: ±7 days, ±3 months, ±6 months, ±12 months + // For sparse clusters, use wider, non-overlapping windows + var timeRangeAttempt = (attempt % 4) + 1; + var (searchStartDate, searchEndDate, description) = GetSearchTimeRange(currentRandomDate.Value, timeRangeAttempt, currentCluster.IsSparse); + + var assets = await SearchAssetsInTimeRange(searchStartDate, searchEndDate, ct); + + if (assets.Any()) + { + allAssets.AddRange(assets); + } + + // Stop if we have enough assets + if (allAssets.Count >= _requestedAssetCount) + { + break; + } + } + + // If we have accumulated sufficient assets, return them; otherwise use fallback + if (allAssets.Count >= Math.Min(_assetsPerRandomDate, _requestedAssetCount)) + { + return allAssets; + } + + // If we don't have enough assets, try fallback approach + var fallbackAssets = await GetAllAvailableAssets(ct); + var result = fallbackAssets.Take(Math.Max(100, _requestedAssetCount)).ToList(); + return result; + } + + /// + /// Generates a random date within the specified cluster's time range. + /// This method ensures even distribution within the cluster boundaries, + /// taking advantage of the cluster's balanced photo density to prevent + /// over-representation of any particular time period within the cluster. + /// + /// Photo cluster to generate date from + /// Random date within the cluster's time range + private DateTime GenerateRandomDateFromCluster(PhotoCluster cluster) + { + // Generate a random date within the cluster time range + var totalDays = (cluster.EndDate - cluster.StartDate).TotalDays; + var randomDays = _random.NextDouble() * totalDays; + var randomDate = cluster.StartDate.AddDays(randomDays); + + return randomDate.Date; + } + + /// + /// Queries assets within the provided time range and returns a randomized subset. + /// Implements deduplication to prevent returning the same assets multiple times. + /// + /// Inclusive start of the range + /// Exclusive end of the range + /// Cancellation token + /// Randomized list of up to _assetsPerRandomDate assets + private async Task> SearchAssetsInTimeRange(DateTime startDate, DateTime endDate, CancellationToken ct) + { + try + { + var searchDto = new MetadataSearchDto + { + TakenAfter = startDate, + TakenBefore = endDate, + Size = Math.Max(_assetsPerRandomDate * 4, 100), // Request more to account for deduplication + Page = 1, + Type = AssetTypeEnum.IMAGE, + WithExif = true, + Visibility = accountSettings.ShowArchived ? AssetVisibility.Archive : AssetVisibility.Timeline, + Order = AssetOrder.Desc + }; + + // Query the API for assets in the requested range + var result = await immichApi.SearchAssetsAsync(searchDto, ct); + var assets = result?.Assets?.Items?.ToList() ?? new List(); + + // Filter out already-selected assets to prevent duplicates + var newAssets = assets.Where(asset => !_selectedAssetIds.Contains(asset.Id)).ToList(); + + // Shuffle to avoid bias from API ordering and take only what's needed + var selectedAssets = Shuffle(newAssets, Random.Shared).Take(_assetsPerRandomDate).ToList(); + + // Track the selected assets to prevent future duplicates + foreach (var asset in selectedAssets) + { + _selectedAssetIds.Add(asset.Id); + } + + return selectedAssets; + } + catch (Exception) + { + // TODO: Waiting for a global log error Handling... + return new List(); + } + } + + /// + /// Generates a random unique date within the given range, avoiding duplicates. + /// + /// Range start + /// Range end + /// Dates already tried in this session + /// A unique random date or null when exhaustion is reached + private DateTime? GenerateRandomDate(DateTime oldestDate, DateTime youngestDate, HashSet attemptedDates) + { + // Cap attempts to prevent infinite loops in very small ranges + const int maxAttempts = 20; + int attempts = 0; + + while (attempts < maxAttempts) + { + var totalDays = (youngestDate - oldestDate).TotalDays; + var randomDays = _random.NextDouble() * totalDays; + var randomDate = oldestDate.AddDays(randomDays).Date; + + if (!attemptedDates.Contains(randomDate)) + { + return randomDate; + } + + attempts++; + } + + return null; + } + + /// + /// Computes an escalating search window around a target date. + /// Uses adaptive windowing: non-overlapping sequential windows for sparse clusters, + /// traditional escalating windows for dense clusters. + /// + /// Center of the search + /// Attempt index that maps to window size + /// Whether the current cluster is sparse + /// Tuple of start, end and a human-readable description + private (DateTime searchStart, DateTime searchEnd, string description) GetSearchTimeRange(DateTime targetDate, int attemptNumber, bool isSparseCluster = false) + { + DateTime searchStart, searchEnd; + string description; + + if (isSparseCluster) + { + // For sparse clusters: use larger, non-overlapping sequential windows + switch (attemptNumber) + { + case 1: + // Wide: ±6 months + searchStart = targetDate.AddMonths(-6); + searchEnd = targetDate.AddMonths(6); + description = "6-month range (sparse)"; + break; + case 2: + // Very wide: ±12 months + searchStart = targetDate.AddMonths(-12); + searchEnd = targetDate.AddMonths(12); + description = "12-month range (sparse)"; + break; + case 3: + // Extremely wide: ±18 months + searchStart = targetDate.AddMonths(-18); + searchEnd = targetDate.AddMonths(18); + description = "18-month range (sparse)"; + break; + default: + // Maximum: ±24 months + searchStart = targetDate.AddMonths(-24); + searchEnd = targetDate.AddMonths(24); + description = "24-month range (sparse)"; + break; + } + } + else + { + // For dense clusters: use traditional escalating windows + switch (attemptNumber) + { + case 1: + // Narrow: ±7 days + searchStart = targetDate.AddDays(-7); + searchEnd = targetDate.AddDays(7); + description = "7-day range"; + break; + case 2: + // Medium: ±3 months + searchStart = targetDate.AddMonths(-3); + searchEnd = targetDate.AddMonths(3); + description = "3-month range"; + break; + case 3: + // Wide: ±6 months + searchStart = targetDate.AddMonths(-6); + searchEnd = targetDate.AddMonths(6); + description = "6-month range"; + break; + default: + // Widest: ±12 months + searchStart = targetDate.AddMonths(-12); + searchEnd = targetDate.AddMonths(12); + description = "12-month range"; + break; + } + } + + return (searchStart, searchEnd, description); + } + + /// + /// Broad fallback query across all images, used when date-scoped attempts fail. + /// Implements deduplication to prevent returning already-selected assets. + /// + /// Cancellation token + /// Randomized list of assets from entire collection + private async Task> GetAllAvailableAssets(CancellationToken ct) + { + try + { + var searchDto = new MetadataSearchDto + { + Size = Math.Max(400, _requestedAssetCount * 6), // Request more to account for deduplication + Page = 1, + Type = AssetTypeEnum.IMAGE, + WithExif = true, + Visibility = accountSettings.ShowArchived ? AssetVisibility.Archive : AssetVisibility.Timeline, + Order = AssetOrder.Desc + }; + + // Query the API and then randomize to provide visual variety + var result = await immichApi.SearchAssetsAsync(searchDto, ct); + var assets = result?.Assets?.Items?.ToList() ?? new List(); + + // Filter out already-selected assets to prevent duplicates + var newAssets = assets.Where(asset => !_selectedAssetIds.Contains(asset.Id)).ToList(); + + // Randomize and track selected assets + var selectedAssets = Shuffle(newAssets, Random.Shared); + + // Track the selected assets (up to requested amount) + foreach (var asset in selectedAssets.Take(_requestedAssetCount)) + { + _selectedAssetIds.Add(asset.Id); + } + + return selectedAssets; + } + catch + { + // TODO: Waiting for a global log error Handling... + return new List(); + } + } + + /// + /// Initializes the temporal clusters used for balanced selection. + /// Implements optional caching and range bounding to prevent expensive wide-range initialization. + /// + /// Library start + /// Library end + /// Cancellation token + private async Task InitializePhotoClusters(DateTime oldestDate, DateTime youngestDate, CancellationToken ct) + { + if (_clustersInitialized && _photoClusters != null) + return; + + try + { + // Bound the date range to prevent excessive API calls for very long libraries + var boundedOldestDate = BoundDateRange(oldestDate, youngestDate); + + // Use caching for large date ranges in production environments + var rangeYears = (youngestDate - boundedOldestDate).TotalDays / 365.25; + var shouldCache = _enableClusterCaching && rangeYears > 2; // Cache for ranges > 2 years + + if (shouldCache) + { + // Generate cache key for this specific date range and account settings + var cacheKey = GenerateClusterCacheKey(boundedOldestDate, youngestDate); + + // Try to get cached cluster data first + _photoClusters = await apiCache.GetOrAddAsync(cacheKey, async () => + { + // 1) Gather monthly photo counts across the bounded range + var monthlyStats = await GetMonthlyPhotoStatistics(boundedOldestDate, youngestDate, ct); + + // 2) Convert monthly stats into balanced clusters + return CreateBalancedClusters(monthlyStats); + }); + } + else + { + // For smaller ranges or when caching is disabled, compute directly + var monthlyStats = await GetMonthlyPhotoStatistics(boundedOldestDate, youngestDate, ct); + _photoClusters = CreateBalancedClusters(monthlyStats); + } + + _clustersInitialized = true; + } + catch (Exception) + { + // Fallback to a single catch-all cluster to keep the app functional + _photoClusters = new List + { + new PhotoCluster + { + StartDate = oldestDate, + EndDate = youngestDate, + PhotoCount = 1000, + Weight = 1.0, + IsSparse = false + } + }; + _clustersInitialized = true; + } + } + + /// + /// Produces monthly photo counts between the given dates. + /// + /// Start date (inclusive) + /// End date (inclusive) + /// Cancellation token + /// List of (Month, PhotoCount) tuples for each month in range + private async Task> GetMonthlyPhotoStatistics(DateTime oldestDate, DateTime youngestDate, CancellationToken ct) + { + var monthlyStats = new List<(DateTime Month, int PhotoCount)>(); + var currentDate = new DateTime(oldestDate.Year, oldestDate.Month, 1); + var endDate = new DateTime(youngestDate.Year, youngestDate.Month, 1); + + while (currentDate <= endDate) + { + var monthStart = currentDate; + var monthEnd = currentDate.AddMonths(1).AddDays(-1); + + // Clamp month to library boundaries + if (monthStart < oldestDate) monthStart = oldestDate; + if (monthEnd > youngestDate) monthEnd = youngestDate; + + try + { + var searchDto = new MetadataSearchDto + { + Size = 1, + Page = 1, + Type = AssetTypeEnum.IMAGE, + TakenAfter = monthStart, + TakenBefore = monthEnd.AddDays(1), + WithExif = true, + Visibility = accountSettings.ShowArchived ? AssetVisibility.Archive : AssetVisibility.Timeline + }; + + // Use the API's Total field to infer monthly counts cheaply + var result = await immichApi.SearchAssetsAsync(searchDto, ct); + var photoCount = result?.Assets?.Total ?? 0; + + monthlyStats.Add((currentDate, photoCount)); + } + catch + { + // TODO: Waiting for a global log error Handling... + monthlyStats.Add((currentDate, 0)); + } + + currentDate = currentDate.AddMonths(1); + } + + return monthlyStats; + } + + /// + /// Converts monthly counts into clusters that each represent roughly equal photo counts. + /// + /// Per-month photo counts + /// List of clusters with weights assigned for balanced selection + private List CreateBalancedClusters(List<(DateTime Month, int PhotoCount)> monthlyStats) + { + var clusters = new List(); + var totalPhotos = monthlyStats.Sum(x => x.PhotoCount); + + if (totalPhotos == 0) + { + return new List + { + new PhotoCluster + { + StartDate = monthlyStats.First().Month, + EndDate = monthlyStats.Last().Month.AddMonths(1).AddDays(-1), + PhotoCount = 0, + Weight = 1.0 + } + }; + } + + const int targetClusters = 10; + var targetPhotosPerCluster = Math.Max(1, totalPhotos / targetClusters); + + var currentCluster = new PhotoCluster(); + var currentPhotoCount = 0; + bool firstMonth = true; + + for (int i = 0; i < monthlyStats.Count; i++) + { + var (month, photoCount) = monthlyStats[i]; + + if (firstMonth) + { + currentCluster.StartDate = month; + firstMonth = false; + } + + currentPhotoCount += photoCount; + currentCluster.EndDate = month.AddMonths(1).AddDays(-1); + + var isLastMonth = i == monthlyStats.Count - 1; + + if (currentPhotoCount >= targetPhotosPerCluster && !isLastMonth) + { + currentCluster.PhotoCount = currentPhotoCount; + clusters.Add(currentCluster); + + currentCluster = new PhotoCluster { StartDate = month.AddMonths(1) }; + currentPhotoCount = 0; + } + } + + if (currentPhotoCount > 0) + { + currentCluster.PhotoCount = currentPhotoCount; + clusters.Add(currentCluster); + } + + // Assign equal weights to clusters to balance selection probability across eras + foreach (var cluster in clusters) + { + cluster.Weight = 1.0 / clusters.Count; + + // Mark clusters as sparse if they have very few photos relative to their time span + var clusterDurationMonths = Math.Max(1, (cluster.EndDate - cluster.StartDate).Days / 30.0); + var photosPerMonth = cluster.PhotoCount / clusterDurationMonths; + cluster.IsSparse = photosPerMonth < SparseClusterThreshold; + } + + return clusters; + } + + /// + /// Selects a cluster based on cumulative weights (roulette wheel selection). + /// + /// Randomly selected cluster + /// If clusters are not initialized + private PhotoCluster SelectRandomCluster() + { + if (_photoClusters == null || !_photoClusters.Any()) + { + throw new InvalidOperationException("Clusters are not initialized."); + } + + // Draw once and walk the cumulative distribution + var randomValue = _random.NextDouble(); + var cumulativeWeight = 0.0; + + foreach (var cluster in _photoClusters) + { + cumulativeWeight += cluster.Weight; + if (randomValue <= cumulativeWeight) + { + return cluster; + } + } + + return _photoClusters.Last(); + } + + /// + /// Efficiently shuffles a list using Fisher-Yates algorithm. + /// More efficient than OrderBy(_ => random.Next()) which is O(n log n). + /// + /// Type of list elements + /// List to shuffle + /// Random number generator + /// The same list, shuffled in-place + private static List Shuffle(List list, Random random) + { + var n = list.Count; + for (int i = n - 1; i > 0; i--) + { + var j = random.Next(i + 1); + (list[i], list[j]) = (list[j], list[i]); + } + return list; + } + + /// + /// Bounds the date range to prevent excessive cluster initialization for very long libraries. + /// Limits the oldest date to MaxClusterRangeYears from the youngest date. + /// + /// Original oldest date + /// Youngest date (unchanged) + /// Bounded oldest date + private DateTime BoundDateRange(DateTime oldestDate, DateTime youngestDate) + { + var maxOldestDate = youngestDate.AddYears(-MaxClusterRangeYears); + return oldestDate < maxOldestDate ? maxOldestDate : oldestDate; + } + + /// + /// Generates a cache key for cluster data based on date range and account settings. + /// + /// Bounded oldest date + /// Youngest date + /// Cache key for cluster data + private string GenerateClusterCacheKey(DateTime oldestDate, DateTime youngestDate) + { + return $"{ClusterCachePrefix}:v1:range={oldestDate:yyyy-MM}to{youngestDate:yyyy-MM}:archived={accountSettings.ShowArchived}"; + } +} \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 4961555b..2f064244 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -35,26 +35,40 @@ private static TimeSpan RefreshInterval(int hours) private IAssetPool BuildPool(IAccountSettings accountSettings) { + IAssetPool basePool; + if (!accountSettings.ShowFavorites && !accountSettings.ShowMemories && !accountSettings.Albums.Any() && !accountSettings.People.Any()) { - return new AllAssetsPool(_apiCache, _immichApi, accountSettings); + basePool = new AllAssetsPool(_apiCache, _immichApi, accountSettings); } + else + { + var pools = new List(); - var pools = new List(); - - if (accountSettings.ShowFavorites) - pools.Add(new FavoriteAssetsPool(_apiCache, _immichApi, accountSettings)); + if (accountSettings.ShowFavorites) + pools.Add(new FavoriteAssetsPool(_apiCache, _immichApi, accountSettings)); - if (accountSettings.ShowMemories) - pools.Add(new MemoryAssetsPool(_immichApi, accountSettings)); + if (accountSettings.ShowMemories) + pools.Add(new MemoryAssetsPool(_immichApi, accountSettings)); - if (accountSettings.Albums.Any()) - pools.Add(new AlbumAssetsPool(_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)); + if (accountSettings.People.Any()) + pools.Add(new PersonAssetsPool(_apiCache, _immichApi, accountSettings)); - return new MultiAssetPool(pools); + basePool = new MultiAssetPool(pools); + } + + // Prefer actual chronological pool if enabled + if (_generalSettings.ChronologicalImagesCount > 0) + { + var randomDatePool = new RandomDateAssetsPool(_apiCache, _immichApi, AccountSettings); + randomDatePool.ConfigureAssetsPerRandomDate(_generalSettings.ChronologicalImagesCount); + return new ChronologicalAssetsPoolWrapper(randomDatePool, _generalSettings); + } + + return basePool; } public async Task GetNextAsset() diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV1.json b/ImmichFrame.WebApi.Tests/Resources/TestV1.json index 1ce862ed..edb841d9 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV1.json @@ -51,6 +51,7 @@ "WeatherLatLong": "WeatherLatLong_TEST", "Language": "Language_TEST", "Webhook": "Webhook_TEST", + "ChronologicalImagesCount": 7, "Account2.ImmichServerUrl": "Account2.ImmichServerUrl_TEST", "Account2.ApiKey": "Account2.ApiKey_TEST", "Account2.ImagesFromDate": "Account2.ImagesFromDate_TEST" diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index 9c9b1d43..e860fc83 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -34,7 +34,8 @@ "ImageZoom": true, "ImagePan": true, "ImageFill": true, - "Layout": "Layout_TEST" + "Layout": "Layout_TEST", + "ChronologicalImagesCount": 7 }, "Accounts": [ { diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml index 71f7b13c..fb80504f 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -34,6 +34,7 @@ General: ImagePan: true ImageFill: true Layout: Layout_TEST + ChronologicalImagesCount: 7 Accounts: - ImmichServerUrl: Account1.ImmichServerUrl_TEST ApiKey: Account1.ApiKey_TEST diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 7bd9bd34..8dd36856 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -52,6 +52,7 @@ public class ServerSettingsV1 : IConfigSettable public bool ImagePan { get; set; } = false; public bool ImageFill { get; set; } = false; public string Layout { get; set; } = "splitview"; + public int ChronologicalImagesCount { get; set; } = 3; } /// @@ -115,5 +116,6 @@ class GeneralSettingsV1Adapter(ServerSettingsV1 _delegate) : IGeneralSettings public bool ImageFill => _delegate.ImageFill; public string Layout => _delegate.Layout; public string Language => _delegate.Language; + public int ChronologicalImagesCount => _delegate.ChronologicalImagesCount; } } \ No newline at end of file diff --git a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs index 264824a7..58942303 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -24,6 +24,7 @@ public class ClientSettingsDto public string Style { get; set; } public string? BaseFontSize { get; set; } public bool ShowWeatherDescription { get; set; } + public int ChronologicalImagesCount { get; set; } public string? WeatherIconUrl { get; set; } public bool ImageZoom { get; set; } public bool ImagePan { get; set; } @@ -59,6 +60,7 @@ public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSett dto.ImagePan = generalSettings.ImagePan; dto.ImageFill = generalSettings.ImageFill; dto.Layout = generalSettings.Layout; + dto.ChronologicalImagesCount = generalSettings.ChronologicalImagesCount; dto.Language = generalSettings.Language; return dto; } diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 24e4633c..cfa52aca 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -52,6 +52,7 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable public bool ImagePan { get; set; } = false; public bool ImageFill { get; set; } = false; public string Layout { get; set; } = "splitview"; + public int ChronologicalImagesCount { get; set; } = 0; public int RenewImagesDuration { get; set; } = 30; public List Webcalendars { get; set; } = new(); public int RefreshAlbumPeopleInterval { get; set; } = 12; diff --git a/docker/Settings.example.json b/docker/Settings.example.json index 118ce629..463b2796 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -34,7 +34,8 @@ "ImageZoom": true, "ImagePan": false, "ImageFill": false, - "Layout": "splitview" + "Layout": "splitview", + "ChronologicalImagesCount": 0 }, "Accounts": [ { diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 41fea573..5a6d7959 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -33,6 +33,7 @@ General: ImagePan: false ImageFill: false Layout: splitview + ChronologicalImagesCount: 0 Accounts: - ImmichServerUrl: REQUIRED ApiKey: REQUIRED diff --git a/docker/example.env b/docker/example.env index e0044d1e..9ff7275b 100644 --- a/docker/example.env +++ b/docker/example.env @@ -6,6 +6,7 @@ ApiKey=KEY # ImageZoom=true # ImagePan=false # Layout=splitview +# ChronologicalImagesCount=0 # DownloadImages=false # ShowMemories=false # ShowFavorites=false