Skip to content

Commit 7c6dfa2

Browse files
[FEAT] Introduced RequireAllPeople flag to filter assets for people in an AND fashion
1 parent 265a890 commit 7c6dfa2

13 files changed

Lines changed: 283 additions & 181 deletions

File tree

ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,84 @@ public async Task LoadAssets_PersonHasNoAssets_DoesNotAffectOthers()
123123
Assert.That(result.Count, Is.EqualTo(10));
124124
Assert.That(result.All(a => a.Id.StartsWith("p1_")));
125125
}
126+
127+
[Test]
128+
public async Task LoadAssets_RequireAllPeople_IssuesSingleQueryWithAllPersonIds()
129+
{
130+
// Arrange
131+
var person1Id = Guid.NewGuid();
132+
var person2Id = Guid.NewGuid();
133+
_mockAccountSettings.SetupGet(s => s.People).Returns(new List<Guid> { person1Id, person2Id });
134+
_mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true);
135+
136+
var assets = Enumerable.Range(0, 5).Select(i => CreateAsset($"combined_{i}")).ToList();
137+
138+
_mockImmichApi.Setup(api => api.SearchAssetsAsync(
139+
It.Is<MetadataSearchDto>(d =>
140+
d.PersonIds.Contains(person1Id) &&
141+
d.PersonIds.Contains(person2Id) &&
142+
d.PersonIds.Count == 2),
143+
It.IsAny<CancellationToken>()))
144+
.ReturnsAsync(CreateSearchResult(assets, 5));
145+
146+
// Act
147+
var result = (await _personAssetsPool.TestLoadAssets()).ToList();
148+
149+
// Assert
150+
Assert.That(result.Count, Is.EqualTo(5));
151+
// Only one call was made (AND mode), not one per person
152+
_mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny<MetadataSearchDto>(), It.IsAny<CancellationToken>()), Times.Once);
153+
}
154+
155+
[Test]
156+
public async Task LoadAssets_RequireAllPeople_Paginates()
157+
{
158+
// Arrange
159+
var person1Id = Guid.NewGuid();
160+
var person2Id = Guid.NewGuid();
161+
_mockAccountSettings.SetupGet(s => s.People).Returns(new List<Guid> { person1Id, person2Id });
162+
_mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true);
163+
164+
int batchSize = 1000;
165+
var page1Assets = Enumerable.Range(0, batchSize).Select(i => CreateAsset($"a_{i}")).ToList();
166+
var page2Assets = Enumerable.Range(0, 15).Select(i => CreateAsset($"b_{i}")).ToList();
167+
168+
_mockImmichApi.Setup(api => api.SearchAssetsAsync(
169+
It.Is<MetadataSearchDto>(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id) && d.Page == 1),
170+
It.IsAny<CancellationToken>()))
171+
.ReturnsAsync(CreateSearchResult(page1Assets, batchSize));
172+
_mockImmichApi.Setup(api => api.SearchAssetsAsync(
173+
It.Is<MetadataSearchDto>(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id) && d.Page == 2),
174+
It.IsAny<CancellationToken>()))
175+
.ReturnsAsync(CreateSearchResult(page2Assets, 15));
176+
177+
// Act
178+
var result = (await _personAssetsPool.TestLoadAssets()).ToList();
179+
180+
// Assert
181+
Assert.That(result.Count, Is.EqualTo(batchSize + 15));
182+
_mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny<MetadataSearchDto>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
183+
}
184+
185+
[Test]
186+
public async Task LoadAssets_RequireAllPeople_NoSharedAssets_ReturnsEmpty()
187+
{
188+
// Arrange: two people configured, but no asset features both of them
189+
var person1Id = Guid.NewGuid();
190+
var person2Id = Guid.NewGuid();
191+
_mockAccountSettings.SetupGet(s => s.People).Returns(new List<Guid> { person1Id, person2Id });
192+
_mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true);
193+
194+
_mockImmichApi.Setup(api => api.SearchAssetsAsync(
195+
It.Is<MetadataSearchDto>(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id)),
196+
It.IsAny<CancellationToken>()))
197+
.ReturnsAsync(CreateSearchResult(new List<AssetResponseDto>(), 0));
198+
199+
// Act
200+
var result = (await _personAssetsPool.TestLoadAssets()).ToList();
201+
202+
// Assert
203+
Assert.That(result, Is.Empty);
204+
_mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny<MetadataSearchDto>(), It.IsAny<CancellationToken>()), Times.Once);
205+
}
126206
}

ImmichFrame.Core/Interfaces/IServerSettings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public interface IAccountSettings
2323
public List<Guid> Albums { get; }
2424
public List<Guid> ExcludedAlbums { get; }
2525
public List<Guid> People { get; }
26+
public bool RequireAllPeople { get; }
2627
public List<string> Tags { get; }
2728
public int? Rating { get; }
2829

ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,15 @@ protected override async Task<IEnumerable<AssetResponseDto>> LoadAssets(Cancella
1414
{
1515
return personAssets;
1616
}
17-
18-
foreach (var personId in people)
17+
18+
// AND mode: pass all person IDs in a single query so the API returns only
19+
// assets that feature every person in the list.
20+
// OR mode (default): query each person separately and combine results.
21+
var personIdGroups = accountSettings.RequireAllPeople
22+
? [people]
23+
: people.Select(id => (IList<Guid>)[id]);
24+
25+
foreach (var personIds in personIdGroups)
1926
{
2027
int page = 1;
2128
int batchSize = 1000;
@@ -26,7 +33,7 @@ protected override async Task<IEnumerable<AssetResponseDto>> LoadAssets(Cancella
2633
{
2734
Page = page,
2835
Size = batchSize,
29-
PersonIds = [personId],
36+
PersonIds = personIds,
3037
WithExif = true,
3138
WithPeople = true
3239
};

ImmichFrame.WebApi.Tests/Resources/TestV1.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"People": [
2929
"00000000-0000-0000-0000-000000000001"
3030
],
31+
"RequireAllPeople": true,
3132
"Tags": [
3233
"Tags_TEST"
3334
],

ImmichFrame.WebApi.Tests/Resources/TestV2.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"People": [
6060
"00000000-0000-0000-0000-000000000001"
6161
],
62+
"RequireAllPeople": true,
6263
"Tags": [
6364
"Account1.Tags_TEST"
6465
]
@@ -84,6 +85,7 @@
8485
"People": [
8586
"00000000-0000-0000-0000-000000000001"
8687
],
88+
"RequireAllPeople": true,
8789
"Tags": [
8890
"Account2.Tags_TEST"
8991
]

ImmichFrame.WebApi.Tests/Resources/TestV2.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Accounts:
5353
- 00000000-0000-0000-0000-000000000001
5454
People:
5555
- 00000000-0000-0000-0000-000000000001
56+
RequireAllPeople: true
5657
Tags:
5758
- Account1.Tags_TEST
5859
- ImmichServerUrl: Account2.ImmichServerUrl_TEST
@@ -72,5 +73,6 @@ Accounts:
7273
- 00000000-0000-0000-0000-000000000001
7374
People:
7475
- 00000000-0000-0000-0000-000000000001
76+
RequireAllPeople: true
7577
Tags:
7678
- Account2.Tags_TEST

ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
],
1919
"People": [
2020
"00000000-0000-0000-0000-000000000001"
21-
]
21+
],
22+
"RequireAllPeople": true
2223
},
2324
{
2425
"ImmichServerUrl": "Account2.ImmichServerUrl_TEST",

ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public class ServerSettingsV1 : IConfigSettable
2121
public List<Guid> Albums { get; set; } = new List<Guid>();
2222
public List<Guid> ExcludedAlbums { get; set; } = new List<Guid>();
2323
public List<Guid> People { get; set; } = new List<Guid>();
24+
public bool RequireAllPeople { get; set; } = false;
2425
public List<string> Tags { get; set; } = new List<string>();
2526
public int? Rating { get; set; }
2627
public List<string> Webcalendars { get; set; } = new List<string>();
@@ -92,6 +93,7 @@ class AccountSettingsV1Adapter(ServerSettingsV1 _delegate) : IAccountSettings
9293
public List<Guid> Albums => _delegate.Albums;
9394
public List<Guid> ExcludedAlbums => _delegate.ExcludedAlbums;
9495
public List<Guid> People => _delegate.People;
96+
public bool RequireAllPeople => _delegate.RequireAllPeople;
9597
public List<string> Tags => _delegate.Tags;
9698
public int? Rating => _delegate.Rating;
9799

ImmichFrame.WebApi/Models/ServerSettings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ public class ServerAccountSettings : IAccountSettings, IConfigSettable
9292
public List<Guid> Albums { get; set; } = new();
9393
public List<Guid> ExcludedAlbums { get; set; } = new();
9494
public List<Guid> People { get; set; } = new();
95+
public bool RequireAllPeople { get; set; } = false;
9596
public List<string> Tags { get; set; } = new();
9697
public int? Rating { get; set; }
9798

docker/Settings.example.json

Lines changed: 69 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,69 @@
1-
{
2-
"General": {
3-
"AuthenticationSecret": null,
4-
"DownloadImages": false,
5-
"RenewImagesDuration": 30,
6-
"Webcalendars": [
7-
"calendarurl"
8-
],
9-
"RefreshAlbumPeopleInterval": 12,
10-
"PhotoDateFormat": "MM/dd/yyyy",
11-
"ImageLocationFormat": "City,State,Country",
12-
"WeatherApiKey": "",
13-
"UnitSystem": "imperial",
14-
"WeatherLatLong": "40.730610,-73.935242",
15-
"Webhook": null,
16-
"Language": "en",
17-
"Interval": 45,
18-
"TransitionDuration": 2,
19-
"ShowClock": true,
20-
"ClockFormat": "hh:mm",
21-
"ClockDateFormat": "eee, MMM d",
22-
"ShowProgressBar": true,
23-
"ShowPhotoDate": true,
24-
"ShowImageDesc": true,
25-
"ShowPeopleDesc": true,
26-
"ShowAlbumName": true,
27-
"ShowImageLocation": true,
28-
"PrimaryColor": "#f5deb3",
29-
"SecondaryColor": "#000000",
30-
"Style": "none",
31-
"BaseFontSize": "17px",
32-
"ShowWeatherDescription": true,
33-
"WeatherIconUrl": "https://openweathermap.org/img/wn/{IconId}.png",
34-
"ImageZoom": true,
35-
"ImagePan": false,
36-
"ImageFill": false,
37-
"PlayAudio": false,
38-
"Layout": "splitview"
39-
},
40-
"Accounts": [
41-
{
42-
"ImmichServerUrl": "REQUIRED",
43-
"ApiKey": "super-secret-api-key",
44-
"ApiKeyFile": "/path/to/api.key",
45-
"ImagesFromDate": null,
46-
"ShowMemories": false,
47-
"ShowFavorites": false,
48-
"ShowArchived": false,
49-
"ShowVideos": false,
50-
"ImagesFromDays": null,
51-
"ImagesUntilDate": "2020-01-02",
52-
"Rating": null,
53-
"Albums": [
54-
"UUID"
55-
],
56-
"ExcludedAlbums": [
57-
"UUID"
58-
],
59-
"People": [
60-
"UUID"
61-
],
62-
"Tags": [
63-
"Vacation",
64-
"Travel/Europe"
65-
]
66-
}
67-
]
68-
}
1+
{
2+
"General": {
3+
"AuthenticationSecret": null,
4+
"DownloadImages": false,
5+
"RenewImagesDuration": 30,
6+
"Webcalendars": [
7+
"calendarurl"
8+
],
9+
"RefreshAlbumPeopleInterval": 12,
10+
"PhotoDateFormat": "MM/dd/yyyy",
11+
"ImageLocationFormat": "City,State,Country",
12+
"WeatherApiKey": "",
13+
"UnitSystem": "imperial",
14+
"WeatherLatLong": "40.730610,-73.935242",
15+
"Webhook": null,
16+
"Language": "en",
17+
"Interval": 45,
18+
"TransitionDuration": 2,
19+
"ShowClock": true,
20+
"ClockFormat": "hh:mm",
21+
"ClockDateFormat": "eee, MMM d",
22+
"ShowProgressBar": true,
23+
"ShowPhotoDate": true,
24+
"ShowImageDesc": true,
25+
"ShowPeopleDesc": true,
26+
"ShowAlbumName": true,
27+
"ShowImageLocation": true,
28+
"PrimaryColor": "#f5deb3",
29+
"SecondaryColor": "#000000",
30+
"Style": "none",
31+
"BaseFontSize": "17px",
32+
"ShowWeatherDescription": true,
33+
"WeatherIconUrl": "https://openweathermap.org/img/wn/{IconId}.png",
34+
"ImageZoom": true,
35+
"ImagePan": false,
36+
"ImageFill": false,
37+
"PlayAudio": false,
38+
"Layout": "splitview"
39+
},
40+
"Accounts": [
41+
{
42+
"ImmichServerUrl": "REQUIRED",
43+
"ApiKey": "super-secret-api-key",
44+
"ApiKeyFile": "/path/to/api.key",
45+
"ImagesFromDate": null,
46+
"ShowMemories": false,
47+
"ShowFavorites": false,
48+
"ShowArchived": false,
49+
"ShowVideos": false,
50+
"ImagesFromDays": null,
51+
"ImagesUntilDate": "2020-01-02",
52+
"Rating": null,
53+
"Albums": [
54+
"UUID"
55+
],
56+
"ExcludedAlbums": [
57+
"UUID"
58+
],
59+
"People": [
60+
"UUID"
61+
],
62+
"RequireAllPeople": false,
63+
"Tags": [
64+
"Vacation",
65+
"Travel/Europe"
66+
]
67+
}
68+
]
69+
}

0 commit comments

Comments
 (0)