Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions ImmichFrame.Core.Tests/Logic/Pool/ShuffleBagAssetPoolTests.cs
Original file line number Diff line number Diff line change
@@ -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<AssetResponseDto> Sample(int n) =>
Enumerable.Range(0, n)
.Select(i => new AssetResponseDto { Id = $"asset{i}", Type = AssetTypeEnum.IMAGE })
.ToList();

private static IAssetPool InnerReturning(IReadOnlyList<AssetResponseDto> assets)
{
var mock = new Mock<IAssetPool>();
mock.Setup(p => p.GetAssetCount(It.IsAny<CancellationToken>())).ReturnsAsync(assets.Count);
mock.Setup(p => p.GetAssets(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.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<AssetResponseDto> assets)
{
var mock = new Mock<IAssetPool>();
mock.Setup(p => p.GetAssetCount(It.IsAny<CancellationToken>())).ReturnsAsync(assets.Count);
mock.Setup(p => p.GetAssets(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.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<AssetResponseDto>
{
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<AssetResponseDto>()));

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));
}
}
1 change: 1 addition & 0 deletions ImmichFrame.Core/Interfaces/IServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public interface IAccountSettings
public List<Guid> People { get; }
public List<string> Tags { get; }
public int? Rating { get; }
public bool ExhaustiveShuffle { get; }

public void ValidateAndInitialize();
}
Expand Down
83 changes: 83 additions & 0 deletions ImmichFrame.Core/Logic/Pool/ShuffleBagAssetPool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using ImmichFrame.Core.Api;

namespace ImmichFrame.Core.Logic.Pool;

/// <summary>
/// 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 <see cref="MaxBagSize"/>, 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).
/// </summary>
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<AssetResponseDto> _bag = new();
private int _index;

public override Task<long> GetAssetCount(CancellationToken ct = default) => inner.GetAssetCount(ct);

protected override async Task<AssetResponseDto?> 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<AssetResponseDto>();

var count = await inner.GetAssetCount(ct);
if (count <= 0) return;

var requested = (int)Math.Min(count, MaxBagSize);
var seen = new HashSet<string>();
var fresh = new List<AssetResponseDto>(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<AssetResponseDto> 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]);
}
}
}
9 changes: 9 additions & 0 deletions ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public class ServerSettingsV1 : IConfigSettable
public List<Guid> People { get; set; } = new List<Guid>();
public List<string> Tags { get; set; } = new List<string>();
public int? Rating { get; set; }
public bool ExhaustiveShuffle { get; set; } = false;
public List<string> Webcalendars { get; set; } = new List<string>();
public int RefreshAlbumPeopleInterval { get; set; } = 12;
public string? WeatherApiKey { get; set; } = string.Empty;
Expand Down Expand Up @@ -94,6 +95,7 @@ class AccountSettingsV1Adapter(ServerSettingsV1 _delegate) : IAccountSettings
public List<Guid> People => _delegate.People;
public List<string> Tags => _delegate.Tags;
public int? Rating => _delegate.Rating;
public bool ExhaustiveShuffle => _delegate.ExhaustiveShuffle;

public void ValidateAndInitialize() { }
}
Expand Down
1 change: 1 addition & 0 deletions ImmichFrame.WebApi/Models/ServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ public class ServerAccountSettings : IAccountSettings, IConfigSettable
public List<Guid> People { get; set; } = new();
public List<string> Tags { get; set; } = new();
public int? Rating { get; set; }
public bool ExhaustiveShuffle { get; set; } = false;

public void ValidateAndInitialize()
{
Expand Down
1 change: 1 addition & 0 deletions docker/Settings.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"ImagesFromDays": null,
"ImagesUntilDate": "2020-01-02",
"Rating": null,
"ExhaustiveShuffle": false,
"Albums": [
"UUID"
],
Expand Down
1 change: 1 addition & 0 deletions docker/Settings.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Accounts:
ImagesFromDays: null
ImagesUntilDate: '2020-01-02'
Rating: null
ExhaustiveShuffle: false
Albums:
- UUID
ExcludedAlbums:
Expand Down
1 change: 1 addition & 0 deletions docs/docs/getting-started/configurationV1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down