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
90 changes: 90 additions & 0 deletions ImmichFrame.Core.Tests/Helpers/AssetHelperTests.cs
Original file line number Diff line number Diff line change
@@ -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<ImmichApi> _mockImmichApi;
private Mock<IAccountSettings> _mockAccountSettings;

[SetUp]
public void Setup()
{
_mockImmichApi = new Mock<ImmichApi>(null, null);
_mockAccountSettings = new Mock<IAccountSettings>();
}

private SearchResponseDto CreateSearchResult(List<AssetResponseDto> assets) =>
new SearchResponseDto { Assets = new SearchAssetResponseDto { Items = assets, Total = assets.Count } };

private void SetupPage(Guid personId, int page, List<AssetResponseDto> assets) =>
_mockImmichApi.Setup(api => api.SearchAssetsAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.Is<MetadataSearchDto>(d => d.PersonIds != null && d.PersonIds.Contains(personId) && d.Page == page),
It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateSearchResult(assets));

[Test]
public async Task GetExcludedPeopleAssets_FetchesEveryPage_WhenPersonExceedsOneBatch()
{
var personId = Guid.NewGuid();
_mockAccountSettings.SetupGet(s => s.ExcludedPeople).Returns(new List<Guid> { 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<Guid> { person1, person2 });

SetupPage(person1, 1, new List<AssetResponseDto> { new AssetResponseDto { Id = FixtureHelpers.GuidFor("a") } });
SetupPage(person2, 1, new List<AssetResponseDto> { 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<Guid>());

var result = (await AssetHelper.GetExcludedPeopleAssets(_mockImmichApi.Object, _mockAccountSettings.Object)).ToList();

Assert.That(result, Is.Empty);
_mockImmichApi.Verify(api => api.SearchAssetsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MetadataSearchDto>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Test]
public async Task GetExcludedPeopleAssets_NullExcludedPeople_ReturnsEmpty()
{
_mockAccountSettings.SetupGet(s => s.ExcludedPeople).Returns((List<Guid>)null);

var result = (await AssetHelper.GetExcludedPeopleAssets(_mockImmichApi.Object, _mockAccountSettings.Object)).ToList();

Assert.That(result, Is.Empty);
}
}
61 changes: 61 additions & 0 deletions ImmichFrame.Core.Tests/Logic/Pool/CachingApiAssetsPoolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IEnumerable<AssetResponseDto>>(assets);
_mockAccountSettings.SetupGet(s => s.ShowArchived).Returns(true);
_mockAccountSettings.SetupGet(s => s.ExcludedPeople).Returns(new List<Guid> { excludedPersonId });

// Asset "4" is the one that contains the excluded person
_mockImmichApi.Setup(api => api.SearchAssetsAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.Is<MetadataSearchDto>(d => d.PersonIds != null && d.PersonIds.Contains(excludedPersonId)),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new SearchResponseDto
{
Assets = new SearchAssetResponseDto
{
Items = new List<AssetResponseDto> { 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<IEnumerable<AssetResponseDto>>(new List<AssetResponseDto>());
_mockAccountSettings.SetupGet(s => s.ExcludedPeople).Returns(new List<Guid> { excludedPersonId });

_mockImmichApi.Setup(api => api.SearchAssetsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MetadataSearchDto>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new SearchResponseDto
{
Assets = new SearchAssetResponseDto { Items = new List<AssetResponseDto>(), Total = 0 }
});

// Act
await _testPool.GetAssets(1, cts.Token);

// Assert
_mockImmichApi.Verify(api => api.SearchAssetsAsync(
It.IsAny<string>(), It.IsAny<string>(),
It.Is<MetadataSearchDto>(d => d.PersonIds != null && d.PersonIds.Contains(excludedPersonId)),
cts.Token),
Times.Once, "a cancelled request must not leave the excluded-person search running");
}
}
8 changes: 5 additions & 3 deletions ImmichFrame.Core/Helpers/AssetExtensionMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ public static bool IsSupportedAsset(this AssetResponseDto asset)
return asset.Type == AssetTypeEnum.IMAGE || asset.Type == AssetTypeEnum.VIDEO;
}

public static async Task<IEnumerable<AssetResponseDto>> ApplyAccountFilters(this Task<IEnumerable<AssetResponseDto>> unfilteredAssets, IAccountSettings accountSettings, IEnumerable<AssetResponseDto> excludedAlbumAssets)
public static async Task<IEnumerable<AssetResponseDto>> ApplyAccountFilters(this Task<IEnumerable<AssetResponseDto>> unfilteredAssets, IAccountSettings accountSettings, IEnumerable<AssetResponseDto> excludedAlbumAssets, IEnumerable<AssetResponseDto> excludedPeopleAssets)
{
return ApplyAccountFilters(await unfilteredAssets, accountSettings, excludedAlbumAssets);
return ApplyAccountFilters(await unfilteredAssets, accountSettings, excludedAlbumAssets, excludedPeopleAssets);
}

public static IEnumerable<AssetResponseDto> ApplyAccountFilters(this IEnumerable<AssetResponseDto> unfilteredAssets, IAccountSettings accountSettings, IEnumerable<AssetResponseDto> excludedAlbumAssets)
public static IEnumerable<AssetResponseDto> ApplyAccountFilters(this IEnumerable<AssetResponseDto> unfilteredAssets, IAccountSettings accountSettings, IEnumerable<AssetResponseDto> excludedAlbumAssets, IEnumerable<AssetResponseDto> excludedPeopleAssets)
{
// Display supported media types
var assets = unfilteredAssets.Where(asset => asset.IsSupportedAsset());
Expand Down Expand Up @@ -45,6 +45,8 @@ public static IEnumerable<AssetResponseDto> ApplyAccountFilters(this IEnumerable

assets = assets.WhereExcludes(excludedAlbumAssets, t => t.Id);

assets = assets.WhereExcludes(excludedPeopleAssets, t => t.Id);

return assets;
}
}
Expand Down
33 changes: 33 additions & 0 deletions ImmichFrame.Core/Helpers/AssetHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,37 @@ public static async Task<IEnumerable<AssetResponseDto>> GetExcludedAlbumAssets(I

return excludedAlbumAssets;
}

public static async Task<IEnumerable<AssetResponseDto>> GetExcludedPeopleAssets(ImmichApi immichApi, IAccountSettings accountSettings, CancellationToken ct = default)
{
var excludedPeopleAssets = new List<AssetResponseDto>();

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

Expand Down
5 changes: 4 additions & 1 deletion ImmichFrame.Core/Logic/Pool/AllAssetsPool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@ public async Task<IEnumerable<AssetResponseDto>> 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);
}

}
3 changes: 2 additions & 1 deletion ImmichFrame.Core/Logic/Pool/CachingApiAssetsPool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ public async Task<IEnumerable<AssetResponseDto>> GetAssets(int requested, Cancel
private async Task<IEnumerable<AssetResponseDto>> 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<IEnumerable<AssetResponseDto>> LoadAssets(CancellationToken ct = default);
Expand Down
6 changes: 6 additions & 0 deletions ImmichFrame.WebApi.Tests/Resources/TestV2.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
"People": [
"00000000-0000-0000-0000-000000000001"
],
"ExcludedPeople": [
"00000000-0000-0000-0000-000000000001"
],
"Tags": [
"Account1.Tags_TEST"
]
Expand All @@ -84,6 +87,9 @@
"People": [
"00000000-0000-0000-0000-000000000001"
],
"ExcludedPeople": [
"00000000-0000-0000-0000-000000000001"
],
"Tags": [
"Account2.Tags_TEST"
]
Expand Down
4 changes: 4 additions & 0 deletions ImmichFrame.WebApi.Tests/Resources/TestV2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 2 additions & 0 deletions ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class ServerSettingsV1 : IConfigSettable
public List<Guid> Albums { get; set; } = new List<Guid>();
public List<Guid> ExcludedAlbums { get; set; } = new List<Guid>();
public List<Guid> People { get; set; } = new List<Guid>();
public List<Guid> ExcludedPeople { get; set; } = new List<Guid>();
public List<string> Tags { get; set; } = new List<string>();
public int? Rating { get; set; }
public List<string> Webcalendars { get; set; } = new List<string>();
Expand Down Expand Up @@ -92,6 +93,7 @@ class AccountSettingsV1Adapter(ServerSettingsV1 _delegate) : IAccountSettings
public List<Guid> Albums => _delegate.Albums;
public List<Guid> ExcludedAlbums => _delegate.ExcludedAlbums;
public List<Guid> People => _delegate.People;
public List<Guid> ExcludedPeople => _delegate.ExcludedPeople;
public List<string> Tags => _delegate.Tags;
public int? Rating => _delegate.Rating;

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 @@ -92,6 +92,7 @@ public class ServerAccountSettings : IAccountSettings, IConfigSettable
public List<Guid> Albums { get; set; } = new();
public List<Guid> ExcludedAlbums { get; set; } = new();
public List<Guid> People { get; set; } = new();
public List<Guid> ExcludedPeople { get; set; } = new();
public List<string> Tags { get; set; } = new();
public int? Rating { get; set; }

Expand Down
3 changes: 3 additions & 0 deletions docker/Settings.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
"People": [
"UUID"
],
"ExcludedPeople": [
"UUID"
],
"Tags": [
"Vacation",
"Travel/Europe"
Expand Down
2 changes: 2 additions & 0 deletions docker/Settings.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Accounts:
- UUID
People:
- UUID
ExcludedPeople:
- UUID
Tags:
- Vacation
- Travel/Europe
1 change: 1 addition & 0 deletions docker/example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down