diff --git a/ImmichFrame.Core.Tests/Logic/Pool/ShuffleBagAssetPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/ShuffleBagAssetPoolTests.cs new file mode 100644 index 00000000..a2b86de3 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/ShuffleBagAssetPoolTests.cs @@ -0,0 +1,112 @@ +using NUnit.Framework; +using Moq; +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Logic.Pool; + +namespace ImmichFrame.Core.Tests.Logic.Pool; + +[TestFixture] +public class ShuffleBagAssetPoolTests +{ + private static List Sample(int n) => + Enumerable.Range(0, n) + .Select(i => new AssetResponseDto { Id = $"asset{i}", Type = AssetTypeEnum.IMAGE }) + .ToList(); + + private static IAssetPool InnerReturning(IReadOnlyList assets) + { + var mock = new Mock(); + mock.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(assets.Count); + mock.Setup(p => p.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => assets); + return mock.Object; + } + + // Like InnerReturning, but honors the requested page size the way Immich's random + // search does (returns at most `n` assets) — needed to exercise the MaxBagSize cap. + private static IAssetPool InnerHonoringSize(IReadOnlyList assets) + { + var mock = new Mock(); + mock.Setup(p => p.GetAssetCount(It.IsAny())).ReturnsAsync(assets.Count); + mock.Setup(p => p.GetAssets(It.IsAny(), It.IsAny())) + .ReturnsAsync((int n, CancellationToken _) => assets.Take(n).ToList()); + return mock.Object; + } + + [Test] + public async Task ShowsEachAssetOnceBeforeRepeating() + { + var assets = Sample(10); + var pool = new ShuffleBagAssetPool(InnerReturning(assets)); + + var cycle = (await pool.GetAssets(10)).ToList(); + + Assert.That(cycle, Has.Count.EqualTo(10)); + Assert.That(cycle.Select(a => a.Id).Distinct().Count(), Is.EqualTo(10), "no repeats within a cycle"); + Assert.That(cycle.Select(a => a.Id), Is.EquivalentTo(assets.Select(a => a.Id)), "every asset shown once"); + } + + [Test] + public async Task ReshufflesAndCoversFullSetEachCycle() + { + var assets = Sample(10); + var pool = new ShuffleBagAssetPool(InnerReturning(assets)); + + var first = (await pool.GetAssets(10)).Select(a => a.Id).ToList(); + var second = (await pool.GetAssets(10)).Select(a => a.Id).ToList(); + + Assert.That(first, Is.EquivalentTo(assets.Select(a => a.Id))); + Assert.That(second, Is.EquivalentTo(assets.Select(a => a.Id)), "no starvation: full set served again next cycle"); + } + + [Test] + public async Task DedupesAssetsFromInnerPool() + { + var withDupes = new List + { + new() { Id = "a", Type = AssetTypeEnum.IMAGE }, + new() { Id = "a", Type = AssetTypeEnum.IMAGE }, + new() { Id = "b", Type = AssetTypeEnum.IMAGE }, + }; + var pool = new ShuffleBagAssetPool(InnerReturning(withDupes)); + + var cycle = (await pool.GetAssets(2)).Select(a => a.Id).ToList(); + + Assert.That(cycle, Has.Count.EqualTo(2)); + Assert.That(cycle.Distinct().Count(), Is.EqualTo(cycle.Count), "no duplicate ids served within a cycle"); + } + + [Test] + public async Task EmptyPoolYieldsNothing() + { + var pool = new ShuffleBagAssetPool(InnerReturning(new List())); + + var result = await pool.GetAssets(5); + + Assert.That(result, Is.Empty); + } + + [Test] + public async Task CapsBagAtMaxBagSizeForLargeLibraries() + { + // Mirrors ShuffleBagAssetPool.MaxBagSize (Immich's random-search page cap). + // Above this size each cycle is a rolling no-repeat window, not the whole library. + const int maxBagSize = 1000; + var assets = Sample(maxBagSize + 10); + var pool = new ShuffleBagAssetPool(InnerHonoringSize(assets)); + + var cycle = (await pool.GetAssets(maxBagSize)).Select(a => a.Id).ToList(); + + Assert.That(cycle, Has.Count.EqualTo(maxBagSize), "bag is capped at MaxBagSize"); + Assert.That(cycle.Distinct().Count(), Is.EqualTo(maxBagSize), "no repeats within the capped window"); + Assert.That(cycle, Is.SubsetOf(assets.Select(a => a.Id)), "all drawn from the inner set"); + } + + [Test] + public async Task GetAssetCountDelegatesToInner() + { + var pool = new ShuffleBagAssetPool(InnerReturning(Sample(7))); + + Assert.That(await pool.GetAssetCount(), Is.EqualTo(7)); + } +} diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index fea6c442..61aec067 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -25,6 +25,7 @@ public interface IAccountSettings public List People { get; } public List Tags { get; } public int? Rating { get; } + public bool ExhaustiveShuffle { get; } public void ValidateAndInitialize(); } diff --git a/ImmichFrame.Core/Logic/Pool/ShuffleBagAssetPool.cs b/ImmichFrame.Core/Logic/Pool/ShuffleBagAssetPool.cs new file mode 100644 index 00000000..751a4290 --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/ShuffleBagAssetPool.cs @@ -0,0 +1,83 @@ +using ImmichFrame.Core.Api; + +namespace ImmichFrame.Core.Logic.Pool; + +/// +/// Decorates an asset pool to serve assets without repetition ("exhaustive shuffle"). +/// It draws the underlying set, shuffles it (Fisher-Yates), and serves each asset +/// exactly once before reshuffling, so every photo is shown before any repeats. +/// Resolves https://github.com/immichFrame/ImmichFrame/issues/438. +/// +/// Notes: +/// - Single-client semantics: one bag is shared per pool instance. +/// - The bag is rebuilt from a fresh draw whenever it empties, so newly added +/// assets are picked up on the next cycle without restarting the frame. +/// - Exhaustive for single-source pools (all-assets, a single album, a person, ...). +/// For multi-source aggregates it dedupes by id and is best-effort per cycle. +/// - For very large libraries the bag is capped at , turning +/// each cycle into a rolling no-repeat window rather than the entire library. +/// The cap also matches Immich's maximum random-search page size (1000). +/// +public class ShuffleBagAssetPool(IAssetPool inner) : AggregatingAssetPool +{ + // Immich's random search rejects size > 1000, so a single draw cannot exceed it. + private const int MaxBagSize = 1000; + + private readonly SemaphoreSlim _gate = new(1, 1); + private List _bag = new(); + private int _index; + + public override Task GetAssetCount(CancellationToken ct = default) => inner.GetAssetCount(ct); + + protected override async Task GetNextAsset(CancellationToken ct) + { + await _gate.WaitAsync(ct); + try + { + if (_index >= _bag.Count) + { + await RefillAsync(ct); + if (_bag.Count == 0) return null; + } + + return _bag[_index++]; + } + finally + { + _gate.Release(); + } + } + + private async Task RefillAsync(CancellationToken ct) + { + _index = 0; + _bag = new List(); + + var count = await inner.GetAssetCount(ct); + if (count <= 0) return; + + var requested = (int)Math.Min(count, MaxBagSize); + var seen = new HashSet(); + var fresh = new List(requested); + + foreach (var asset in await inner.GetAssets(requested, ct)) + { + if (seen.Add(asset.Id)) + { + fresh.Add(asset); + } + } + + Shuffle(fresh); + _bag = fresh; + } + + private static void Shuffle(IList list) + { + for (var i = list.Count - 1; i > 0; i--) + { + var j = Random.Shared.Next(i + 1); + (list[i], list[j]) = (list[j], list[i]); + } + } +} diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 451f839c..0fdb8ce1 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -35,6 +35,15 @@ private static TimeSpan RefreshInterval(int hours) public IAccountSettings AccountSettings { get; } private IAssetPool BuildPool(IAccountSettings accountSettings) + { + return WithExhaustiveShuffle(BuildSourcePool(accountSettings), accountSettings); + } + + // Wraps the source pool so each asset is shown once before any repeats (see ShuffleBagAssetPool). + private static IAssetPool WithExhaustiveShuffle(IAssetPool pool, IAccountSettings accountSettings) + => accountSettings.ExhaustiveShuffle ? new ShuffleBagAssetPool(pool) : pool; + + private IAssetPool BuildSourcePool(IAccountSettings accountSettings) { var hasAlbums = accountSettings.Albums?.Any() ?? false; var hasPeople = accountSettings.People?.Any() ?? false; diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 076f36da..e85a0e17 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -23,6 +23,7 @@ public class ServerSettingsV1 : IConfigSettable public List People { get; set; } = new List(); public List Tags { get; set; } = new List(); public int? Rating { get; set; } + public bool ExhaustiveShuffle { get; set; } = false; public List Webcalendars { get; set; } = new List(); public int RefreshAlbumPeopleInterval { get; set; } = 12; public string? WeatherApiKey { get; set; } = string.Empty; @@ -94,6 +95,7 @@ class AccountSettingsV1Adapter(ServerSettingsV1 _delegate) : IAccountSettings public List People => _delegate.People; public List Tags => _delegate.Tags; public int? Rating => _delegate.Rating; + public bool ExhaustiveShuffle => _delegate.ExhaustiveShuffle; public void ValidateAndInitialize() { } } diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 74d0fb8e..ab2fcf1c 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -94,6 +94,7 @@ public class ServerAccountSettings : IAccountSettings, IConfigSettable public List People { get; set; } = new(); public List Tags { get; set; } = new(); public int? Rating { get; set; } + public bool ExhaustiveShuffle { get; set; } = false; public void ValidateAndInitialize() { diff --git a/docker/Settings.example.json b/docker/Settings.example.json index a86a4d00..8690bc6d 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -50,6 +50,7 @@ "ImagesFromDays": null, "ImagesUntilDate": "2020-01-02", "Rating": null, + "ExhaustiveShuffle": false, "Albums": [ "UUID" ], diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 173b31a5..dc822664 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -48,6 +48,7 @@ Accounts: ImagesFromDays: null ImagesUntilDate: '2020-01-02' Rating: null + ExhaustiveShuffle: false Albums: - UUID ExcludedAlbums: diff --git a/docs/docs/getting-started/configurationV1.md b/docs/docs/getting-started/configurationV1.md index bf9ca932..55b55d46 100644 --- a/docs/docs/getting-started/configurationV1.md +++ b/docs/docs/getting-started/configurationV1.md @@ -15,6 +15,7 @@ sidebar_position: 4 | [Filtering](#filtering) | ExcludedAlbums | string[] | [] | UUID of excluded album(s) | | [Filtering](#filtering) | People | string[] | [] | UUID of person(s) | | [Filtering](#filtering) | Rating | int | | Rating of an image in stars, allowed values from -1 to 5. This will only show images with the exact rating you are filtering for. | +| [Filtering](#filtering) | ExhaustiveShuffle | boolean | false | Show every asset once in a random order before any repeats (shuffle without replacement). Resolves #438. | | [Filtering](#filtering) | ShowMemories | boolean | false | If this is set, memories are displayed. | | [Filtering](#filtering) | ShowFavorites | boolean | false | If this is set, favorites are displayed. | | [Filtering](#filtering) | ShowArchived | boolean | false | If this is set, assets marked archived are displayed. |