diff --git a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs index 54977293..1faccbfe 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs @@ -123,4 +123,84 @@ public async Task LoadAssets_PersonHasNoAssets_DoesNotAffectOthers() Assert.That(result.Count, Is.EqualTo(10)); Assert.That(result.All(a => a.Id.StartsWith("p1_"))); } + + [Test] + public async Task LoadAssets_RequireAllPeople_IssuesSingleQueryWithAllPersonIds() + { + // Arrange + var person1Id = Guid.NewGuid(); + var person2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + _mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true); + + var assets = Enumerable.Range(0, 5).Select(i => CreateAsset($"combined_{i}")).ToList(); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => + d.PersonIds.Contains(person1Id) && + d.PersonIds.Contains(person2Id) && + d.PersonIds.Count == 2), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(assets, 5)); + + // Act + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(5)); + // Only one call was made (AND mode), not one per person + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task LoadAssets_RequireAllPeople_Paginates() + { + // Arrange + var person1Id = Guid.NewGuid(); + var person2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + _mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true); + + int batchSize = 1000; + var page1Assets = Enumerable.Range(0, batchSize).Select(i => CreateAsset($"a_{i}")).ToList(); + var page2Assets = Enumerable.Range(0, 15).Select(i => CreateAsset($"b_{i}")).ToList(); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id) && d.Page == 1), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(page1Assets, batchSize)); + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id) && d.Page == 2), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(page2Assets, 15)); + + // Act + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(batchSize + 15)); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task LoadAssets_RequireAllPeople_NoSharedAssets_ReturnsEmpty() + { + // Arrange: two people configured, but no asset features both of them + var person1Id = Guid.NewGuid(); + var person2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + _mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id)), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List(), 0)); + + // Act + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result, Is.Empty); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.Once); + } } diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index fea6c442..fa17d282 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -23,6 +23,7 @@ public interface IAccountSettings public List Albums { get; } public List ExcludedAlbums { get; } public List People { get; } + public bool RequireAllPeople { get; } public List Tags { get; } public int? Rating { get; } diff --git a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs index 8aa52bd8..97274ba4 100644 --- a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs @@ -14,8 +14,15 @@ protected override async Task> LoadAssets(Cancella { return personAssets; } - - foreach (var personId in people) + + // AND mode: pass all person IDs in a single query so the API returns only + // assets that feature every person in the list. + // OR mode (default): query each person separately and combine results. + var personIdGroups = accountSettings.RequireAllPeople + ? [people] + : people.Select(id => (IList)[id]); + + foreach (var personIds in personIdGroups) { int page = 1; int batchSize = 1000; @@ -26,7 +33,7 @@ protected override async Task> LoadAssets(Cancella { Page = page, Size = batchSize, - PersonIds = [personId], + PersonIds = personIds, WithExif = true, WithPeople = true }; diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV1.json b/ImmichFrame.WebApi.Tests/Resources/TestV1.json index e6c49102..3795863e 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV1.json @@ -28,6 +28,7 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "RequireAllPeople": true, "Tags": [ "Tags_TEST" ], diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index 4d603dc9..8fde0ffd 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -59,6 +59,7 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "RequireAllPeople": true, "Tags": [ "Account1.Tags_TEST" ] @@ -84,6 +85,7 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "RequireAllPeople": true, "Tags": [ "Account2.Tags_TEST" ] diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml index 47f45947..d2fb6528 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -53,6 +53,7 @@ Accounts: - 00000000-0000-0000-0000-000000000001 People: - 00000000-0000-0000-0000-000000000001 + RequireAllPeople: true Tags: - Account1.Tags_TEST - ImmichServerUrl: Account2.ImmichServerUrl_TEST @@ -72,5 +73,6 @@ Accounts: - 00000000-0000-0000-0000-000000000001 People: - 00000000-0000-0000-0000-000000000001 + RequireAllPeople: true Tags: - Account2.Tags_TEST diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json b/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json index 87279ffe..29b28700 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json @@ -18,7 +18,8 @@ ], "People": [ "00000000-0000-0000-0000-000000000001" - ] + ], + "RequireAllPeople": true }, { "ImmichServerUrl": "Account2.ImmichServerUrl_TEST", diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 076f36da..dfb1258b 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -21,6 +21,7 @@ public class ServerSettingsV1 : IConfigSettable public List Albums { get; set; } = new List(); public List ExcludedAlbums { get; set; } = new List(); public List People { get; set; } = new List(); + public bool RequireAllPeople { get; set; } = false; public List Tags { get; set; } = new List(); public int? Rating { get; set; } public List Webcalendars { get; set; } = new List(); @@ -92,6 +93,7 @@ class AccountSettingsV1Adapter(ServerSettingsV1 _delegate) : IAccountSettings public List Albums => _delegate.Albums; public List ExcludedAlbums => _delegate.ExcludedAlbums; public List People => _delegate.People; + public bool RequireAllPeople => _delegate.RequireAllPeople; public List Tags => _delegate.Tags; public int? Rating => _delegate.Rating; diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 74d0fb8e..6a697287 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -92,6 +92,7 @@ public class ServerAccountSettings : IAccountSettings, IConfigSettable public List Albums { get; set; } = new(); public List ExcludedAlbums { get; set; } = new(); public List People { get; set; } = new(); + public bool RequireAllPeople { get; set; } = false; public List Tags { get; set; } = new(); public int? Rating { get; set; } diff --git a/docker/Settings.example.json b/docker/Settings.example.json index a86a4d00..aa7506d8 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -59,6 +59,7 @@ "People": [ "UUID" ], + "RequireAllPeople": false, "Tags": [ "Vacation", "Travel/Europe" diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 173b31a5..e55da607 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -54,6 +54,7 @@ Accounts: - UUID People: - UUID + RequireAllPeople: false Tags: - Vacation - Travel/Europe diff --git a/docker/example.env b/docker/example.env index 51d80ed5..0ade8554 100644 --- a/docker/example.env +++ b/docker/example.env @@ -24,6 +24,7 @@ ApiKey=KEY # Albums=ALBUM1,ALBUM2 # ExcludedAlbums=ALBUM3,ALBUM4 # People=PERSON1,PERSON2 +# RequireAllPeople=false # Webcalendars=https://calendar.google.com/calendar/ical/XXXXXX/public/basic.ics,https://user:pass@calendar.immichframe.dev/dav/calendars/basic.ics # RefreshAlbumPeopleInterval=12 # ShowClock=true diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 7378c7d1..87dfdbf1 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -138,6 +138,8 @@ Accounts: # UUID of People People: # string[] - UUID + # If this is set, all specified people must be present in an image for it to be displayed. + RequireAllPeople: false # boolean # Tag values (full hierarchical paths, case-sensitive) Tags: # string[] - "Vacation"