diff --git a/ImmichFrame.Core.Tests/Helpers/AssetHelperTests.cs b/ImmichFrame.Core.Tests/Helpers/AssetHelperTests.cs new file mode 100644 index 00000000..e95c9fe4 --- /dev/null +++ b/ImmichFrame.Core.Tests/Helpers/AssetHelperTests.cs @@ -0,0 +1,90 @@ +using NUnit.Framework; +using Moq; +using ImmichFrame.Core.Api; +using ImmichFrame.Core.Helpers; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Tests.Logic.Pool; + +namespace ImmichFrame.Core.Tests.Helpers; + +[TestFixture] +public class AssetHelperTests +{ + private Mock _mockImmichApi; + private Mock _mockAccountSettings; + + [SetUp] + public void Setup() + { + _mockImmichApi = new Mock(null, null); + _mockAccountSettings = new Mock(); + } + + private SearchResponseDto CreateSearchResult(List assets) => + new SearchResponseDto { Assets = new SearchAssetResponseDto { Items = assets, Total = assets.Count } }; + + private void SetupPage(Guid personId, int page, List assets) => + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.IsAny(), It.IsAny(), + It.Is(d => d.PersonIds != null && d.PersonIds.Contains(personId) && d.Page == page), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(assets)); + + [Test] + public async Task GetExcludedPeopleAssets_FetchesEveryPage_WhenPersonExceedsOneBatch() + { + var personId = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.ExcludedPeople).Returns(new List { personId }); + + var batchSize = 1000; + var page1 = Enumerable.Range(0, batchSize).Select(i => new AssetResponseDto { Id = FixtureHelpers.GuidFor($"p1_{i}") }).ToList(); + var page2 = Enumerable.Range(0, 30).Select(i => new AssetResponseDto { Id = FixtureHelpers.GuidFor($"p2_{i}") }).ToList(); + SetupPage(personId, 1, page1); + SetupPage(personId, 2, page2); + + var result = (await AssetHelper.GetExcludedPeopleAssets(_mockImmichApi.Object, _mockAccountSettings.Object)).ToList(); + + Assert.That(result.Count, Is.EqualTo(batchSize + 30), + "a person with more than one batch of assets must be fetched across every page"); + Assert.That(result.Any(a => a.Id == FixtureHelpers.GuidFor("p2_29"))); + } + + [Test] + public async Task GetExcludedPeopleAssets_AggregatesAcrossPeople() + { + var person1 = Guid.NewGuid(); + var person2 = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.ExcludedPeople).Returns(new List { person1, person2 }); + + SetupPage(person1, 1, new List { new AssetResponseDto { Id = FixtureHelpers.GuidFor("a") } }); + SetupPage(person2, 1, new List { new AssetResponseDto { Id = FixtureHelpers.GuidFor("b") } }); + + var result = (await AssetHelper.GetExcludedPeopleAssets(_mockImmichApi.Object, _mockAccountSettings.Object)).ToList(); + + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.Any(a => a.Id == FixtureHelpers.GuidFor("a"))); + Assert.That(result.Any(a => a.Id == FixtureHelpers.GuidFor("b"))); + } + + [Test] + public async Task GetExcludedPeopleAssets_NoExcludedPeople_MakesNoApiCalls() + { + _mockAccountSettings.SetupGet(s => s.ExcludedPeople).Returns(new List()); + + var result = (await AssetHelper.GetExcludedPeopleAssets(_mockImmichApi.Object, _mockAccountSettings.Object)).ToList(); + + Assert.That(result, Is.Empty); + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task GetExcludedPeopleAssets_NullExcludedPeople_ReturnsEmpty() + { + _mockAccountSettings.SetupGet(s => s.ExcludedPeople).Returns((List)null); + + var result = (await AssetHelper.GetExcludedPeopleAssets(_mockImmichApi.Object, _mockAccountSettings.Object)).ToList(); + + Assert.That(result, Is.Empty); + } +} diff --git a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs index ca32a48b..5408ccd9 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs @@ -333,4 +333,65 @@ public async Task ApplyAccountFilters_CombinedFilters() Assert.That(result.Any(a => a.Id == FixtureHelpers.GuidFor("4"))); Assert.That(result.Any(a => a.Id == FixtureHelpers.GuidFor("3") || a.Id == FixtureHelpers.GuidFor("5") || a.Id == FixtureHelpers.GuidFor("2")), Is.False); } + + [Test] + public async Task ApplyAccountFilters_ExcludesAssetsContainingExcludedPeople() + { + // Arrange + var excludedPersonId = Guid.NewGuid(); + var assets = CreateSampleAssets(); + _testPool.LoadAssetsFunc = () => Task.FromResult>(assets); + _mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true); + _mockAccountSettings.SetupGet(s => s.ExcludedPeople).Returns(new List { excludedPersonId }); + + // Asset "4" is the one that contains the excluded person + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.IsAny(), It.IsAny(), + It.Is(d => d.PersonIds != null && d.PersonIds.Contains(excludedPersonId)), + It.IsAny())) + .ReturnsAsync(new SearchResponseDto + { + Assets = new SearchAssetResponseDto + { + Items = new List { new AssetResponseDto { Id = FixtureHelpers.GuidFor("4") } }, + Total = 1 + } + }); + + // Act + var result = (await _testPool.GetAssets(5)).ToList(); + + // Assert + // Images are 1, 3, 4, 5 (2 is video); "4" must be dropped + Assert.That(result.Any(a => a.Id == FixtureHelpers.GuidFor("4")), Is.False, + "asset containing an excluded person should not be returned"); + Assert.That(result.Count, Is.EqualTo(3)); + } + + [Test] + public async Task ExcludedPeopleLookup_ForwardsCallersCancellationToken() + { + // Arrange + var excludedPersonId = Guid.NewGuid(); + using var cts = new CancellationTokenSource(); + _testPool.LoadAssetsFunc = () => Task.FromResult>(new List()); + _mockAccountSettings.SetupGet(s => s.ExcludedPeople).Returns(new List { excludedPersonId }); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new SearchResponseDto + { + Assets = new SearchAssetResponseDto { Items = new List(), Total = 0 } + }); + + // Act + await _testPool.GetAssets(1, cts.Token); + + // Assert + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.IsAny(), It.IsAny(), + It.Is(d => d.PersonIds != null && d.PersonIds.Contains(excludedPersonId)), + cts.Token), + Times.Once, "a cancelled request must not leave the excluded-person search running"); + } } diff --git a/ImmichFrame.Core/Helpers/AssetExtensionMethods.cs b/ImmichFrame.Core/Helpers/AssetExtensionMethods.cs index 572dacbb..ce613570 100644 --- a/ImmichFrame.Core/Helpers/AssetExtensionMethods.cs +++ b/ImmichFrame.Core/Helpers/AssetExtensionMethods.cs @@ -10,12 +10,12 @@ public static bool IsSupportedAsset(this AssetResponseDto asset) return asset.Type == AssetTypeEnum.IMAGE || asset.Type == AssetTypeEnum.VIDEO; } - public static async Task> ApplyAccountFilters(this Task> unfilteredAssets, IAccountSettings accountSettings, IEnumerable excludedAlbumAssets) + public static async Task> ApplyAccountFilters(this Task> unfilteredAssets, IAccountSettings accountSettings, IEnumerable excludedAlbumAssets, IEnumerable excludedPeopleAssets) { - return ApplyAccountFilters(await unfilteredAssets, accountSettings, excludedAlbumAssets); + return ApplyAccountFilters(await unfilteredAssets, accountSettings, excludedAlbumAssets, excludedPeopleAssets); } - public static IEnumerable ApplyAccountFilters(this IEnumerable unfilteredAssets, IAccountSettings accountSettings, IEnumerable excludedAlbumAssets) + public static IEnumerable ApplyAccountFilters(this IEnumerable unfilteredAssets, IAccountSettings accountSettings, IEnumerable excludedAlbumAssets, IEnumerable excludedPeopleAssets) { // Display supported media types var assets = unfilteredAssets.Where(asset => asset.IsSupportedAsset()); @@ -45,6 +45,8 @@ public static IEnumerable ApplyAccountFilters(this IEnumerable assets = assets.WhereExcludes(excludedAlbumAssets, t => t.Id); + assets = assets.WhereExcludes(excludedPeopleAssets, t => t.Id); + return assets; } } diff --git a/ImmichFrame.Core/Helpers/AssetHelper.cs b/ImmichFrame.Core/Helpers/AssetHelper.cs index 1ea82c2b..77a8e882 100644 --- a/ImmichFrame.Core/Helpers/AssetHelper.cs +++ b/ImmichFrame.Core/Helpers/AssetHelper.cs @@ -38,4 +38,37 @@ public static async Task> GetExcludedAlbumAssets(I return excludedAlbumAssets; } + + public static async Task> GetExcludedPeopleAssets(ImmichApi immichApi, IAccountSettings accountSettings, CancellationToken ct = default) + { + var excludedPeopleAssets = new List(); + + foreach (var personId in accountSettings?.ExcludedPeople ?? new()) + { + int page = 1; + int batchSize = 1000; + int itemsInPage; + do + { + var metadataBody = new MetadataSearchDto + { + Page = page, + Size = batchSize, + PersonIds = [personId] + }; + var searchResponse = await immichApi.SearchAssetsAsync(null, null, metadataBody, ct); + + itemsInPage = searchResponse.Assets?.Items.Count ?? 0; + + if (searchResponse.Assets != null) + { + excludedPeopleAssets.AddRange(searchResponse.Assets.Items); + } + + page++; + } while (itemsInPage == batchSize); + } + + return excludedPeopleAssets; + } } \ No newline at end of file diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index 9c12a3bd..fc09c7fb 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 List ExcludedPeople { get; } public List Tags { get; } public int? Rating { get; } diff --git a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs index 84ddc217..a2c0226d 100644 --- a/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs @@ -64,8 +64,11 @@ public async Task> GetAssets(int requested, Cancel var excludedAlbumAssets = await apiCache.GetOrAddAsync( $"{nameof(AllAssetsPool)}_ExcludedAlbums", () => AssetHelper.GetExcludedAlbumAssets(immichApi, accountSettings, ct)); + var excludedPeopleAssets = await apiCache.GetOrAddAsync( + $"{nameof(AllAssetsPool)}_ExcludedPeople", + () => AssetHelper.GetExcludedPeopleAssets(immichApi, accountSettings, ct)); - return assets.ApplyAccountFilters(accountSettings, excludedAlbumAssets); + return assets.ApplyAccountFilters(accountSettings, excludedAlbumAssets, excludedPeopleAssets); } } \ No newline at end of file diff --git a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs index 9dce8acf..a46b334c 100644 --- a/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs @@ -21,8 +21,9 @@ public async Task> GetAssets(int requested, Cancel private async Task> AllAssets(CancellationToken ct = default) { var excludedAlbumAssets = await apiCache.GetOrAddAsync($"{GetType().FullName}_ExcludedAlbums", () => AssetHelper.GetExcludedAlbumAssets(immichApi, accountSettings)); + var excludedPeopleAssets = await apiCache.GetOrAddAsync($"{GetType().FullName}_ExcludedPeople", () => AssetHelper.GetExcludedPeopleAssets(immichApi, accountSettings, ct)); - return await apiCache.GetOrAddAsync(GetType().FullName!, () => LoadAssets().ApplyAccountFilters(accountSettings, excludedAlbumAssets)); + return await apiCache.GetOrAddAsync(GetType().FullName!, () => LoadAssets().ApplyAccountFilters(accountSettings, excludedAlbumAssets, excludedPeopleAssets)); } protected abstract Task> LoadAssets(CancellationToken ct = default); diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index 4d603dc9..2360055b 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -59,6 +59,9 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "ExcludedPeople": [ + "00000000-0000-0000-0000-000000000001" + ], "Tags": [ "Account1.Tags_TEST" ] @@ -84,6 +87,9 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "ExcludedPeople": [ + "00000000-0000-0000-0000-000000000001" + ], "Tags": [ "Account2.Tags_TEST" ] diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml index 47f45947..acc7a4f4 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -53,6 +53,8 @@ Accounts: - 00000000-0000-0000-0000-000000000001 People: - 00000000-0000-0000-0000-000000000001 + ExcludedPeople: + - 00000000-0000-0000-0000-000000000001 Tags: - Account1.Tags_TEST - ImmichServerUrl: Account2.ImmichServerUrl_TEST @@ -72,5 +74,7 @@ Accounts: - 00000000-0000-0000-0000-000000000001 People: - 00000000-0000-0000-0000-000000000001 + ExcludedPeople: + - 00000000-0000-0000-0000-000000000001 Tags: - Account2.Tags_TEST diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 076f36da..87b50c2d 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 List ExcludedPeople { get; set; } = new List(); 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 List ExcludedPeople => _delegate.ExcludedPeople; 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..14d35fb1 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 List ExcludedPeople { get; set; } = new(); 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..214a9a89 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -59,6 +59,9 @@ "People": [ "UUID" ], + "ExcludedPeople": [ + "UUID" + ], "Tags": [ "Vacation", "Travel/Europe" diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 173b31a5..d1e9f6bc 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -54,6 +54,8 @@ Accounts: - UUID People: - UUID + ExcludedPeople: + - UUID Tags: - Vacation - Travel/Europe diff --git a/docker/example.env b/docker/example.env index 51d80ed5..7db77563 100644 --- a/docker/example.env +++ b/docker/example.env @@ -24,6 +24,7 @@ ApiKey=KEY # Albums=ALBUM1,ALBUM2 # ExcludedAlbums=ALBUM3,ALBUM4 # People=PERSON1,PERSON2 +# ExcludedPeople=PERSON3,PERSON4 # 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