From 0986549e7e683a3f773f76e7e014ab921ec836f3 Mon Sep 17 00:00:00 2001 From: nopoz Date: Fri, 21 Aug 2026 12:18:06 -0700 Subject: [PATCH] Add ExcludedPeople setting to filter out assets containing specific people Adds a per-account ExcludedPeople list. Any asset containing one of the named people is removed from the slideshow, mirroring how ExcludedAlbums already works. The exclusion is applied in ApplyAccountFilters, so it covers every pool rather than one: album, favorite, memory, people, tag and all-assets. AssetHelper.GetExcludedPeopleAssets fetches and pages the excluded people's assets, and CachingApiAssetsPool/AllAssetsPool cache the result alongside the existing excluded-album lookup. Assets are matched by id rather than by inspecting each asset's people array, because pools populate that field inconsistently: memory assets arrive without it and are only back-filled per asset, and GetExcludedAlbumAssets does not request it at all. Matching on id keeps the behaviour independent of how a pool happened to fetch its assets. --- .../Helpers/AssetHelperTests.cs | 90 +++++++++++++++++++ .../Logic/Pool/CachingApiAssetsPoolTests.cs | 61 +++++++++++++ .../Helpers/AssetExtensionMethods.cs | 8 +- ImmichFrame.Core/Helpers/AssetHelper.cs | 33 +++++++ .../Interfaces/IServerSettings.cs | 1 + ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs | 5 +- .../Logic/Pool/CachingApiAssetsPool.cs | 3 +- .../Resources/TestV2.json | 6 ++ ImmichFrame.WebApi.Tests/Resources/TestV2.yml | 4 + .../Helpers/Config/ServerSettingsV1.cs | 2 + ImmichFrame.WebApi/Models/ServerSettings.cs | 1 + docker/Settings.example.json | 3 + docker/Settings.example.yml | 2 + docker/example.env | 1 + 14 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 ImmichFrame.Core.Tests/Helpers/AssetHelperTests.cs 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