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
1 change: 1 addition & 0 deletions ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public void Setup()
_mockAccountSettings.SetupGet(s => s.ImagesFromDays).Returns((int?)null);
_mockAccountSettings.SetupGet(s => s.Rating).Returns((int?)null);
_mockAccountSettings.SetupGet(s => s.ExcludedAlbums).Returns(new List<Guid>());
_mockAccountSettings.SetupGet(s => s.Tags).Returns(new List<Guid>());

// Default ApiCache setup
_mockApiCache.Setup(c => c.GetOrAddAsync(
Expand Down
134 changes: 134 additions & 0 deletions ImmichFrame.Core.Tests/Logic/Pool/TagAssetsPoolTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using NUnit.Framework;
using Moq;
using ImmichFrame.Core.Api;
using ImmichFrame.Core.Interfaces;
using ImmichFrame.Core.Logic.Pool;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Threading;

namespace ImmichFrame.Core.Tests.Logic.Pool;

[TestFixture]
public class TagAssetsPoolTests
{
private Mock<IApiCache> _mockApiCache;
private Mock<ImmichApi> _mockImmichApi;
private Mock<IAccountSettings> _mockAccountSettings;
private TestableTagAssetsPool _tagAssetsPool;

private class TestableTagAssetsPool : TagAssetsPool
{
public TestableTagAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings)
: base(apiCache, immichApi, accountSettings) { }

public Task<IEnumerable<AssetResponseDto>> TestLoadAssets(CancellationToken ct = default)
{
return base.LoadAssets(ct);
}
}

[SetUp]
public void Setup()
{
_mockApiCache = new Mock<IApiCache>();
_mockImmichApi = new Mock<ImmichApi>(null, null);
_mockAccountSettings = new Mock<IAccountSettings>();
_tagAssetsPool = new TestableTagAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object);

_mockAccountSettings.SetupGet(s => s.Tags).Returns(new List<Guid>());
}

private AssetResponseDto CreateAsset(string id) => new AssetResponseDto { Id = id, Type = AssetTypeEnum.IMAGE, Tags = new List<TagResponseDto>() };
private SearchResponseDto CreateSearchResult(List<AssetResponseDto> assets, int total) =>
new SearchResponseDto { Assets = new SearchAssetResponseDto { Items = assets, Total = total } };
Comment on lines +44 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add test coverage for the GetAssetInfoAsync code path.

The CreateAsset helper always initializes Tags as an empty list (not null), so the tests never exercise the code path in TagAssetsPool.LoadAssets where asset.Tags == null triggers a call to GetAssetInfoAsync to fetch full asset details. This is a critical path that should be tested.

🔎 Suggested test case for null Tags scenario

Add a test method to verify the GetAssetInfoAsync behavior:

[Test]
public async Task LoadAssets_WhenAssetTagsIsNull_CallsGetAssetInfoAsync()
{
    // Arrange
    var tagId = Guid.NewGuid();
    _mockAccountSettings.SetupGet(s => s.Tags).Returns(new List<Guid> { tagId });
    
    var assetWithNullTags = new AssetResponseDto 
    { 
        Id = "asset_1", 
        Type = AssetTypeEnum.IMAGE, 
        Tags = null  // Null tags to trigger GetAssetInfoAsync
    };
    
    var fullAssetInfo = new AssetResponseDto
    {
        Id = "asset_1",
        Type = AssetTypeEnum.IMAGE,
        Tags = new List<TagResponseDto> { new TagResponseDto { Id = tagId.ToString() } },
        ExifInfo = new ExifResponseDto(),
        People = new List<PersonResponseDto>()
    };
    
    _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.IsAny<MetadataSearchDto>(), It.IsAny<CancellationToken>()))
        .ReturnsAsync(CreateSearchResult(new List<AssetResponseDto> { assetWithNullTags }, 1));
    
    _mockImmichApi.Setup(api => api.GetAssetInfoAsync(new Guid("asset_1"), null, It.IsAny<CancellationToken>()))
        .ReturnsAsync(fullAssetInfo);
    
    // Act
    var result = (await _tagAssetsPool.TestLoadAssets()).ToList();
    
    // Assert
    Assert.That(result.Count, Is.EqualTo(1));
    _mockImmichApi.Verify(api => api.GetAssetInfoAsync(new Guid("asset_1"), null, It.IsAny<CancellationToken>()), Times.Once);
    Assert.That(result[0].Tags, Is.Not.Null);
    Assert.That(result[0].ExifInfo, Is.Not.Null);
    Assert.That(result[0].People, Is.Not.Null);
}
🤖 Prompt for AI Agents
In ImmichFrame.Core.Tests/Logic/Pool/TagAssetsPoolTests.cs around lines 44-46,
the CreateAsset helper always initializes Tags to an empty list so tests never
exercise the TagAssetsPool.LoadAssets branch that detects asset.Tags == null and
calls GetAssetInfoAsync; add a new test that arranges an AssetResponseDto with
Tags = null, configures _mockImmichApi.SearchAssetsAsync to return that asset,
configures _mockImmichApi.GetAssetInfoAsync to return a full AssetResponseDto
(with Tags, ExifInfo, People populated), invokes the pool loader
(TestLoadAssets), and asserts GetAssetInfoAsync was called once and the returned
asset has non-null Tags, ExifInfo and People.


[Test]
public async Task LoadAssets_CallsSearchAssetsForEachTag_AndPaginates()
{
// Arrange
var tag1Id = Guid.NewGuid();
var tag2Id = Guid.NewGuid();
_mockAccountSettings.SetupGet(s => s.Tags).Returns(new List<Guid> { tag1Id, tag2Id });

var batchSize = 1000; // From TagAssetsPool.cs
var t1AssetsPage1 = Enumerable.Range(0, batchSize).Select(i => CreateAsset($"t1_p1_{i}")).ToList();
var t1AssetsPage2 = Enumerable.Range(0, 30).Select(i => CreateAsset($"t1_p2_{i}")).ToList();
var t2AssetsPage1 = Enumerable.Range(0, 20).Select(i => CreateAsset($"t2_p1_{i}")).ToList();

// Tag 1 - Page 1
_mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is<MetadataSearchDto>(d => d.TagIds.Contains(tag1Id) && d.Page == 1), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateSearchResult(t1AssetsPage1, batchSize));
// Tag 1 - Page 2
_mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is<MetadataSearchDto>(d => d.TagIds.Contains(tag1Id) && d.Page == 2), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateSearchResult(t1AssetsPage2, 30));
// Tag 2 - Page 1
_mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is<MetadataSearchDto>(d => d.TagIds.Contains(tag2Id) && d.Page == 1), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateSearchResult(t2AssetsPage1, 20));

// Act
var result = (await _tagAssetsPool.TestLoadAssets()).ToList();

// Assert
Assert.That(result.Count, Is.EqualTo(batchSize + 30 + 20));
Assert.That(result.Any(a => a.Id == "t1_p1_0"));
Assert.That(result.Any(a => a.Id == "t1_p2_29"));
Assert.That(result.Any(a => a.Id == "t2_p1_19"));

_mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is<MetadataSearchDto>(d => d.TagIds.Contains(tag1Id) && d.Page == 1), It.IsAny<CancellationToken>()), Times.Once);
_mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is<MetadataSearchDto>(d => d.TagIds.Contains(tag1Id) && d.Page == 2), It.IsAny<CancellationToken>()), Times.Once);
_mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is<MetadataSearchDto>(d => d.TagIds.Contains(tag2Id) && d.Page == 1), It.IsAny<CancellationToken>()), Times.Once);
}

[Test]
public async Task LoadAssets_NoTagsConfigured_ReturnsEmpty()
{
_mockAccountSettings.SetupGet(s => s.Tags).Returns(new List<Guid>());
var result = (await _tagAssetsPool.TestLoadAssets()).ToList();
Assert.That(result, Is.Empty);
_mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny<MetadataSearchDto>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Test]
public async Task LoadAssets_TagHasNoAssets_DoesNotAffectOthers()
{
var tag1Id = Guid.NewGuid(); // Has assets
var tag2Id = Guid.NewGuid(); // No assets
_mockAccountSettings.SetupGet(s => s.Tags).Returns(new List<Guid> { tag1Id, tag2Id });

var t1Assets = Enumerable.Range(0, 10).Select(i => CreateAsset($"t1_{i}")).ToList();
_mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is<MetadataSearchDto>(d => d.TagIds.Contains(tag1Id)), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateSearchResult(t1Assets, 10));
_mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is<MetadataSearchDto>(d => d.TagIds.Contains(tag2Id)), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateSearchResult(new List<AssetResponseDto>(), 0));

var result = (await _tagAssetsPool.TestLoadAssets()).ToList();
Assert.That(result.Count, Is.EqualTo(10));
Assert.That(result.All(a => a.Id.StartsWith("t1_")));
}

[Test]
public async Task LoadAssets_PassesCorrectMetadataSearchParameters()
{
var tagId = Guid.NewGuid();
_mockAccountSettings.SetupGet(s => s.Tags).Returns(new List<Guid> { tagId });

var assets = Enumerable.Range(0, 5).Select(i => CreateAsset($"asset_{i}")).ToList();
_mockImmichApi.Setup(api => api.SearchAssetsAsync(It.IsAny<MetadataSearchDto>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateSearchResult(assets, 5));

await _tagAssetsPool.TestLoadAssets();

_mockImmichApi.Verify(api => api.SearchAssetsAsync(
It.Is<MetadataSearchDto>(d =>
d.TagIds.Contains(tagId) &&
d.Page == 1 &&
d.Size == 1000 &&
d.Type == AssetTypeEnum.IMAGE &&
d.WithExif == true &&
d.WithPeople == true
), It.IsAny<CancellationToken>()), Times.Once);
}
}
2 changes: 2 additions & 0 deletions ImmichFrame.Core/Interfaces/IServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public interface IAccountSettings
public List<Guid> Albums { get; }
public List<Guid> ExcludedAlbums { get; }
public List<Guid> People { get; }
public List<Guid> Tags { get; }
public int? Rating { get; }

public void ValidateAndInitialize();
Expand All @@ -48,6 +49,7 @@ public interface IGeneralSettings
public string? PhotoDateFormat { get; }
public bool ShowImageDesc { get; }
public bool ShowPeopleDesc { get; }
public bool ShowTagsDesc { get; }
public bool ShowAlbumName { get; }
public bool ShowImageLocation { get; }
public string? ImageLocationFormat { get; }
Expand Down
52 changes: 52 additions & 0 deletions ImmichFrame.Core/Logic/Pool/TagAssetsPool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using ImmichFrame.Core.Api;
using ImmichFrame.Core.Interfaces;

namespace ImmichFrame.Core.Logic.Pool;

public class TagAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) : CachingApiAssetsPool(apiCache, immichApi, accountSettings)
{
protected override async Task<IEnumerable<AssetResponseDto>> LoadAssets(CancellationToken ct = default)
{
var tagAssets = new List<AssetResponseDto>();

foreach (var tagId in accountSettings.Tags)
{
int page = 1;
int batchSize = 1000;
int total;
do
{
var metadataBody = new MetadataSearchDto
{
Page = page,
Size = batchSize,
TagIds = [tagId],
Type = AssetTypeEnum.IMAGE,
WithExif = true,
WithPeople = true
};

var tagInfo = await immichApi.SearchAssetsAsync(metadataBody, ct);

total = tagInfo.Assets.Total;

// Fetch full asset details to get tag information
foreach (var asset in tagInfo.Assets.Items)
{
if (asset.Tags == null)
{
var assetInfo = await immichApi.GetAssetInfoAsync(new Guid(asset.Id), null, ct);
asset.Tags = assetInfo.Tags;
asset.ExifInfo = assetInfo.ExifInfo;
asset.People = assetInfo.People;
}
}

tagAssets.AddRange(tagInfo.Assets.Items);
page++;
} while (total == batchSize);
}

return tagAssets;
}
}
5 changes: 4 additions & 1 deletion ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ private static TimeSpan RefreshInterval(int hours)

private IAssetPool BuildPool(IAccountSettings accountSettings)
{
if (!accountSettings.ShowFavorites && !accountSettings.ShowMemories && !accountSettings.Albums.Any() && !accountSettings.People.Any())
if (!accountSettings.ShowFavorites && !accountSettings.ShowMemories && !accountSettings.Albums.Any() && !accountSettings.People.Any() && !accountSettings.Tags.Any())
{
return new AllAssetsPool(_apiCache, _immichApi, accountSettings);
}
Expand All @@ -54,6 +54,9 @@ private IAssetPool BuildPool(IAccountSettings accountSettings)
if (accountSettings.People.Any())
pools.Add(new PersonAssetsPool(_apiCache, _immichApi, accountSettings));

if (accountSettings.Tags.Any())
pools.Add(new TagAssetsPool(_apiCache, _immichApi, accountSettings));

return new MultiAssetPool(pools);
}

Expand Down
4 changes: 4 additions & 0 deletions ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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> Tags { get; set; } = new List<Guid>();
public int? Rating { get; set; }
public List<string> Webcalendars { get; set; } = new List<string>();
public int RefreshAlbumPeopleInterval { get; set; } = 12;
Expand All @@ -39,6 +40,7 @@ public class ServerSettingsV1 : IConfigSettable
public string? PhotoDateFormat { get; set; } = "MM/dd/yyyy";
public bool ShowImageDesc { get; set; } = true;
public bool ShowPeopleDesc { get; set; } = true;
public bool ShowTagsDesc { get; set; } = true;
public bool ShowAlbumName { get; set; } = true;
public bool ShowImageLocation { get; set; } = true;
public string? ImageLocationFormat { get; set; } = "City,State,Country";
Expand Down Expand Up @@ -86,6 +88,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> Tags => _delegate.Tags;
public int? Rating => _delegate.Rating;

public void ValidateAndInitialize() { }
Expand All @@ -112,6 +115,7 @@ class GeneralSettingsV1Adapter(ServerSettingsV1 _delegate) : IGeneralSettings
public string? PhotoDateFormat => _delegate.PhotoDateFormat;
public bool ShowImageDesc => _delegate.ShowImageDesc;
public bool ShowPeopleDesc => _delegate.ShowPeopleDesc;
public bool ShowTagsDesc => _delegate.ShowTagsDesc;
public bool ShowAlbumName => _delegate.ShowAlbumName;
public bool ShowImageLocation => _delegate.ShowImageLocation;
public string? ImageLocationFormat => _delegate.ImageLocationFormat;
Expand Down
2 changes: 2 additions & 0 deletions ImmichFrame.WebApi/Models/ClientSettingsDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class ClientSettingsDto
public string? PhotoDateFormat { get; set; }
public bool ShowImageDesc { get; set; }
public bool ShowPeopleDesc { get; set; }
public bool ShowTagsDesc { get; set; }
public bool ShowAlbumName { get; set; }
public bool ShowImageLocation { get; set; }
public string? ImageLocationFormat { get; set; }
Expand Down Expand Up @@ -46,6 +47,7 @@ public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSett
dto.PhotoDateFormat = generalSettings.PhotoDateFormat;
dto.ShowImageDesc = generalSettings.ShowImageDesc;
dto.ShowPeopleDesc = generalSettings.ShowPeopleDesc;
dto.ShowTagsDesc = generalSettings.ShowTagsDesc;
dto.ShowAlbumName = generalSettings.ShowAlbumName;
dto.ShowImageLocation = generalSettings.ShowImageLocation;
dto.ImageLocationFormat = generalSettings.ImageLocationFormat;
Expand Down
2 changes: 2 additions & 0 deletions ImmichFrame.WebApi/Models/ServerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable
public bool ShowPhotoDate { get; set; } = true;
public bool ShowImageDesc { get; set; } = true;
public bool ShowPeopleDesc { get; set; } = true;
public bool ShowTagsDesc { get; set; } = true;
public bool ShowAlbumName { get; set; } = true;
public bool ShowImageLocation { get; set; } = true;
public string? PrimaryColor { get; set; }
Expand Down Expand Up @@ -89,6 +90,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> Tags { get; set; } = new();
public int? Rating { get; set; }

public void ValidateAndInitialize()
Expand Down
3 changes: 3 additions & 0 deletions docker/Settings.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@
],
"People": [
"UUID"
],
"Tags": [
"UUID"
]
}
]
Expand Down
2 changes: 2 additions & 0 deletions docker/Settings.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,5 @@ Accounts:
- UUID
People:
- UUID
Tags:
- UUID
10 changes: 9 additions & 1 deletion docs/docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ General:
ShowImageDesc: true # boolean
# Displays a comma separated list of names of all the people that are assigned in immich.
ShowPeopleDesc: true # boolean
# Displays a comma separated list of names of all the tags that are assigned in immich.
ShowTagsDesc: true # boolean
# Displays a comma separated list of names of all the albums for an image.
ShowAlbumName: true # boolean
# Displays the location of the current image.
Expand Down Expand Up @@ -130,16 +132,21 @@ Accounts:
# UUID of People
People: # string[]
- UUID
# UUID of Tags
Tags: # string[]
- UUID
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the easiest way to get a tag UUID for a user? Wouldn't value be the better option?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe? I guess there could be issues when renaming tags. Mostly was trying to stay consistent with people and albums. If we used value for tags, then why not use a value for people and albums as well?

But I'm happy to change it to value if you prefer

Copy link
Author

@ahal ahal Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: easiest way, I didn't see a way to get it outside the API. But maybe I didn't look hard enough. Personally I'm fine with using the API, but you're right that for most users, that would be a barrier.

Probably worth mentioning that tags aren't enabled by default, so they're already power user territory.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe? I guess there could be issues when renaming tags. Mostly was trying to stay consistent with people and albums. If we used value for tags, then why not use a value for people and albums as well?

But I'm happy to change it to value if you prefer

People and Albums could have the same value but not be the same; when I checked, that was not the case for tags. I just saw that the tags in Immich does not display a UUID; that's why I asked.

With albums or people, users can easily get the UUID via the URI.

I'd suggest using the tags value and, in the backend, getting all tags and their IDs manually (https://api.immich.app/endpoints/tags/getAllTags)

Please let me know what you think about this. :) I know that it would be easier to use the UUID for you, but for the end user it's better to have the value, since there won't be tag duplicates (afaik)


```
### Security
Basic authentication can be added via `AuthenticationSecret`. It is **NOT** recommended to expose immichFrame to the public web, if you still choose to do so, you can set this to a secure secret. Every client needs to authenticate itself with this secret. This can be done in the Webclient via input field or via URL-Parameter. The URL-Parameter will look like this: `?authsecret=[MYSECRET]`

If this is enabled, the web api required the `Authorization`-Header with `Bearer [MYSECRET]`.

### Filtering on Albums or People
### Filtering on Albums, People, or Tags
You can get the UUIDs from the URL of the album/person. For this URL: `https://demo.immich.app/albums/85c85b29-c95d-4a8b-90f7-c87da1d518ba` this is the UUID: `85c85b29-c95d-4a8b-90f7-c87da1d518ba`

To get Tag UUIDs, you can use the Immich API: `GET /api/tags` with your API key, which returns all tags with their IDs.

### Weather
Weather is enabled by entering an API key. Get yours free from [OpenWeatherMap][openweathermap-url]

Expand Down Expand Up @@ -176,6 +183,7 @@ For full ImmichFrame functionality, the API key being used needs the following p
- `memory.read`
- `person.read`
- `person.statistics`
- `tag.read`


### Custom CSS
Expand Down
Loading