From 4c2529c519d6615f40ff1ad90da6f6807df84eef Mon Sep 17 00:00:00 2001 From: Andrew Halberstadt Date: Thu, 25 Dec 2025 23:18:09 -0500 Subject: [PATCH] feat: support selecting photos from tags Personally I'd prefer to use tags to select which photos show up on my frame, rather than adding photos to an album. --- .../Logic/Pool/AllAssetsPoolTests.cs | 1 + .../Logic/Pool/TagAssetsPoolTests.cs | 289 ++++++++++++++++++ .../Interfaces/IServerSettings.cs | 2 + ImmichFrame.Core/Logic/Pool/TagAssetsPool.cs | 73 +++++ .../Logic/PooledImmichFrameLogic.cs | 5 +- .../Resources/TestV1.json | 3 + .../Resources/TestV2.json | 6 + ImmichFrame.WebApi.Tests/Resources/TestV2.yml | 4 + .../Helpers/Config/ServerSettingsV1.cs | 4 + .../Models/ClientSettingsDto.cs | 2 + ImmichFrame.WebApi/Models/ServerSettings.cs | 2 + docker/Settings.example.json | 4 + docker/Settings.example.yml | 3 + docs/docs/getting-started/configuration.md | 16 +- .../lib/components/elements/asset-info.svelte | 13 +- .../elements/image-component.svelte | 5 + .../src/lib/components/elements/image.svelte | 3 + .../imageoverlay/image-overlay.svelte | 9 + .../lib/components/home-page/home-page.svelte | 1 + immichFrame.Web/src/lib/immichFrameApi.ts | 1 + 20 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 ImmichFrame.Core.Tests/Logic/Pool/TagAssetsPoolTests.cs create mode 100644 ImmichFrame.Core/Logic/Pool/TagAssetsPool.cs diff --git a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs index 5ea7e984..7d31597f 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/AllAssetsPoolTests.cs @@ -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()); + _mockAccountSettings.SetupGet(s => s.Tags).Returns(new List()); // Default ApiCache setup _mockApiCache.Setup(c => c.GetOrAddAsync( diff --git a/ImmichFrame.Core.Tests/Logic/Pool/TagAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/TagAssetsPoolTests.cs new file mode 100644 index 00000000..b88dd9d4 --- /dev/null +++ b/ImmichFrame.Core.Tests/Logic/Pool/TagAssetsPoolTests.cs @@ -0,0 +1,289 @@ +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 _mockApiCache; + private Mock _mockImmichApi; + private Mock _mockAccountSettings; + private TestableTagAssetsPool _tagAssetsPool; + + private class TestableTagAssetsPool : TagAssetsPool + { + public TestableTagAssetsPool(IApiCache apiCache, ImmichApi immichApi, IAccountSettings accountSettings) + : base(apiCache, immichApi, accountSettings) { } + + public Task> TestLoadAssets(CancellationToken ct = default) + { + return base.LoadAssets(ct); + } + } + + [SetUp] + public void Setup() + { + _mockApiCache = new Mock(); + _mockImmichApi = new Mock(null, null); + _mockAccountSettings = new Mock(); + _tagAssetsPool = new TestableTagAssetsPool(_mockApiCache.Object, _mockImmichApi.Object, _mockAccountSettings.Object); + + _mockAccountSettings.SetupGet(s => s.Tags).Returns(new List()); + } + + private AssetResponseDto CreateAsset(string id) => new AssetResponseDto { Id = id, Type = AssetTypeEnum.IMAGE, Tags = new List() }; + private SearchResponseDto CreateSearchResult(List assets, int total) => + new SearchResponseDto { Assets = new SearchAssetResponseDto { Items = assets, Total = total } }; + + [Test] + public async Task LoadAssets_CallsSearchAssetsForEachTag_AndPaginates() + { + var tag1Id = Guid.NewGuid(); + var tag2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.Tags).Returns(new List { "Vacation", "Family" }); + + var allTags = new List + { + new TagResponseDto { Id = tag1Id.ToString(), Name = "Vacation", Value = "Vacation" }, + new TagResponseDto { Id = tag2Id.ToString(), Name = "Family", Value = "Family" } + }; + _mockImmichApi.Setup(api => api.GetAllTagsAsync(It.IsAny())) + .ReturnsAsync(allTags); + + var batchSize = 1000; + 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(); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag1Id) && d.Page == 1), It.IsAny())) + .ReturnsAsync(CreateSearchResult(t1AssetsPage1, batchSize)); + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag1Id) && d.Page == 2), It.IsAny())) + .ReturnsAsync(CreateSearchResult(t1AssetsPage2, 30)); + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag2Id) && d.Page == 1), It.IsAny())) + .ReturnsAsync(CreateSearchResult(t2AssetsPage1, 20)); + + var result = (await _tagAssetsPool.TestLoadAssets()).ToList(); + + 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.GetAllTagsAsync(It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag1Id) && d.Page == 1), It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag1Id) && d.Page == 2), It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag2Id) && d.Page == 1), It.IsAny()), Times.Once); + } + + [Test] + public async Task LoadAssets_NoTagsConfigured_ReturnsEmpty() + { + _mockAccountSettings.SetupGet(s => s.Tags).Returns(new List()); + + _mockImmichApi.Setup(api => api.GetAllTagsAsync(It.IsAny())) + .ReturnsAsync(new List()); + + var result = (await _tagAssetsPool.TestLoadAssets()).ToList(); + Assert.That(result, Is.Empty); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), 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 { "Vacation", "Work" }); + + var allTags = new List + { + new TagResponseDto { Id = tag1Id.ToString(), Name = "Vacation", Value = "Vacation" }, + new TagResponseDto { Id = tag2Id.ToString(), Name = "Work", Value = "Work" } + }; + _mockImmichApi.Setup(api => api.GetAllTagsAsync(It.IsAny())) + .ReturnsAsync(allTags); + + var t1Assets = Enumerable.Range(0, 10).Select(i => CreateAsset($"t1_{i}")).ToList(); + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag1Id)), It.IsAny())) + .ReturnsAsync(CreateSearchResult(t1Assets, 10)); + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag2Id)), It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List(), 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 { "Vacation" }); + + var allTags = new List + { + new TagResponseDto { Id = tagId.ToString(), Name = "Vacation", Value = "Vacation" } + }; + _mockImmichApi.Setup(api => api.GetAllTagsAsync(It.IsAny())) + .ReturnsAsync(allTags); + + var assets = Enumerable.Range(0, 5).Select(i => CreateAsset($"asset_{i}")).ToList(); + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(CreateSearchResult(assets, 5)); + + await _tagAssetsPool.TestLoadAssets(); + + _mockImmichApi.Verify(api => api.SearchAssetsAsync( + It.Is(d => + d.TagIds.Contains(tagId) && + d.Page == 1 && + d.Size == 1000 && + d.Type == AssetTypeEnum.IMAGE && + d.WithExif == true && + d.WithPeople == true + ), It.IsAny()), Times.Once); + } + + [Test] + public async Task LoadAssets_EnrichesAssetsWithNullTags_CallsGetAssetInfoAsync() + { + var tagId = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.Tags).Returns(new List { "TestTag" }); + + var allTags = new List + { + new TagResponseDto { Id = tagId.ToString(), Name = "TestTag", Value = "TestTag" } + }; + _mockImmichApi.Setup(api => api.GetAllTagsAsync(It.IsAny())) + .ReturnsAsync(allTags); + + var assetId = Guid.NewGuid(); + var assetWithNullTags = new AssetResponseDto + { + Id = assetId.ToString(), + Type = AssetTypeEnum.IMAGE, + Tags = null // Key: Tags is null, should trigger GetAssetInfoAsync + }; + + var enrichedAsset = new AssetResponseDto + { + Id = assetId.ToString(), + Type = AssetTypeEnum.IMAGE, + Tags = new List { new TagResponseDto { Id = tagId.ToString(), Name = "TestTag" } }, + ExifInfo = new ExifResponseDto { Make = "TestCamera" }, + People = new List { new PersonWithFacesResponseDto { Name = "TestPerson" } } + }; + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => d.TagIds.Contains(tagId)), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List { assetWithNullTags }, 1)); + + _mockImmichApi.Setup(api => api.GetAssetInfoAsync( + assetId, null, It.IsAny())) + .ReturnsAsync(enrichedAsset); + + var result = (await _tagAssetsPool.TestLoadAssets()).ToList(); + + Assert.That(result.Count, Is.EqualTo(1)); + Assert.That(result[0].Tags, Is.Not.Null); + Assert.That(result[0].ExifInfo, Is.Not.Null); + Assert.That(result[0].People, Is.Not.Null); + Assert.That(result[0].Tags!.Count, Is.EqualTo(1)); + Assert.That(result[0].Tags!.First().Name, Is.EqualTo("TestTag")); + _mockImmichApi.Verify(api => api.GetAssetInfoAsync( + assetId, null, It.IsAny()), Times.Once); + } + + [Test] + public async Task LoadAssets_HierarchicalTags_MatchesByFullValue() + { + // Arrange - Three tags with the same name "Child" but different full paths + var tag1Id = Guid.NewGuid(); + var tag2Id = Guid.NewGuid(); + var tag3Id = Guid.NewGuid(); + // User configures the full hierarchical path + _mockAccountSettings.SetupGet(s => s.Tags).Returns(new List { "Parent1/Child" }); + + // Mock GetAllTagsAsync to return three tags with same name but different values + var allTags = new List + { + new TagResponseDto { Id = tag1Id.ToString(), Name = "Child", Value = "Parent1/Child" }, + new TagResponseDto { Id = tag2Id.ToString(), Name = "Child", Value = "Parent2/Child" }, + new TagResponseDto { Id = tag3Id.ToString(), Name = "Child", Value = "Child" } + }; + _mockImmichApi.Setup(api => api.GetAllTagsAsync(It.IsAny())) + .ReturnsAsync(allTags); + + var tag1Assets = Enumerable.Range(0, 5).Select(i => CreateAsset($"tag1_{i}")).ToList(); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag1Id)), It.IsAny())) + .ReturnsAsync(CreateSearchResult(tag1Assets, 5)); + + var result = (await _tagAssetsPool.TestLoadAssets()).ToList(); + + // Assert - Should only include assets from the exact matching tag value + Assert.That(result.Count, Is.EqualTo(5)); + Assert.That(result.All(a => a.Id.StartsWith("tag1_"))); + + _mockImmichApi.Verify(api => api.GetAllTagsAsync(It.IsAny()), Times.Once); + // Should only search for the one matching tag, not the others + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag1Id)), It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag2Id)), It.IsAny()), Times.Never); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag3Id)), It.IsAny()), Times.Never); + } + + [Test] + public async Task LoadAssets_AssetWithMultipleTags_OnlyIncludedOnce() + { + // Arrange - Configure two tags that an asset has both of + var tag1Id = Guid.NewGuid(); + var tag2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.Tags).Returns(new List { "Vacation", "Family" }); + + var allTags = new List + { + new TagResponseDto { Id = tag1Id.ToString(), Name = "Vacation", Value = "Vacation" }, + new TagResponseDto { Id = tag2Id.ToString(), Name = "Family", Value = "Family" } + }; + _mockImmichApi.Setup(api => api.GetAllTagsAsync(It.IsAny())) + .ReturnsAsync(allTags); + + // Same asset returned for both tags + var sharedAssetId = Guid.NewGuid().ToString(); + var sharedAsset = CreateAsset(sharedAssetId); + var tag1OnlyAsset = CreateAsset("tag1_only"); + var tag2OnlyAsset = CreateAsset("tag2_only"); + + var tag1Assets = new List { sharedAsset, tag1OnlyAsset }; + var tag2Assets = new List { sharedAsset, tag2OnlyAsset }; + + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag1Id)), It.IsAny())) + .ReturnsAsync(CreateSearchResult(tag1Assets, 2)); + _mockImmichApi.Setup(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag2Id)), It.IsAny())) + .ReturnsAsync(CreateSearchResult(tag2Assets, 2)); + + // Act + var result = (await _tagAssetsPool.TestLoadAssets()).ToList(); + + // Assert - Should only include the shared asset once, plus the two unique assets + Assert.That(result.Count, Is.EqualTo(3)); + Assert.That(result.Count(a => a.Id == sharedAssetId), Is.EqualTo(1), "Shared asset should only appear once"); + Assert.That(result.Any(a => a.Id == "tag1_only"), "Should include tag1-only asset"); + Assert.That(result.Any(a => a.Id == "tag2_only"), "Should include tag2-only asset"); + + _mockImmichApi.Verify(api => api.GetAllTagsAsync(It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag1Id)), It.IsAny()), Times.Once); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.Is(d => d.TagIds.Contains(tag2Id)), It.IsAny()), Times.Once); + } +} diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index 0141af37..55194d7b 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -22,6 +22,7 @@ public interface IAccountSettings public List Albums { get; } public List ExcludedAlbums { get; } public List People { get; } + public List Tags { get; } public int? Rating { get; } public void ValidateAndInitialize(); @@ -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; } diff --git a/ImmichFrame.Core/Logic/Pool/TagAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/TagAssetsPool.cs new file mode 100644 index 00000000..17598c4b --- /dev/null +++ b/ImmichFrame.Core/Logic/Pool/TagAssetsPool.cs @@ -0,0 +1,73 @@ +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> LoadAssets(CancellationToken ct = default) + { + var tagAssets = new List(); + var allTags = await immichApi.GetAllTagsAsync(ct); + var tagValueToIds = allTags + .ToDictionary(t => t.Value, t => new Guid(t.Id), StringComparer.OrdinalIgnoreCase); + + // Find the tag IDs for the configured tag values + var tagIds = new List(); + foreach (var tagValue in accountSettings.Tags) + { + if (tagValueToIds.TryGetValue(tagValue, out var id)) + { + tagIds.Add(id); + } + } + + var seenIds = new HashSet(); + foreach (var tagId in tagIds) + { + int page = 1; + int batchSize = 1000; + int itemsInPage; + 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); + + itemsInPage = tagInfo.Assets.Items.Count; + + // Fetch full asset details to get tag information + foreach (var asset in tagInfo.Assets.Items) + { + if (seenIds.Contains(asset.Id)) + { + continue; + } + + 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; + } + + seenIds.Add(asset.Id); + tagAssets.Add(asset); + } + + page++; + } while (itemsInPage == batchSize); + } + + return tagAssets; + } +} diff --git a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs index 4961555b..4a12fd46 100644 --- a/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs +++ b/ImmichFrame.Core/Logic/PooledImmichFrameLogic.cs @@ -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); } @@ -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); } diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV1.json b/ImmichFrame.WebApi.Tests/Resources/TestV1.json index 71b938f1..0bfa1d0c 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV1.json @@ -26,6 +26,9 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "Tags": [ + "Tags_TEST" + ], "Webcalendars": [ "Webcalendars_TEST" ], diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index ce64ebee..eb7ea716 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -56,6 +56,9 @@ ], "People": [ "00000000-0000-0000-0000-000000000001" + ], + "Tags": [ + "Account1.Tags_TEST" ] }, { @@ -77,6 +80,9 @@ ], "People": [ "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 f4072cd0..c579fe6d 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -51,6 +51,8 @@ Accounts: - 00000000-0000-0000-0000-000000000001 People: - 00000000-0000-0000-0000-000000000001 + Tags: + - Account1.Tags_TEST - ImmichServerUrl: Account2.ImmichServerUrl_TEST ApiKey: Account2.ApiKey_TEST ApiKeyFile: Account2.ApiKeyFile_TEST @@ -67,3 +69,5 @@ Accounts: - 00000000-0000-0000-0000-000000000001 People: - 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 1e6ae02b..bea50d7b 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -20,6 +20,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 Tags { get; set; } = new List(); public int? Rating { get; set; } public List Webcalendars { get; set; } = new List(); public int RefreshAlbumPeopleInterval { get; set; } = 12; @@ -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"; @@ -86,6 +88,7 @@ class AccountSettingsV1Adapter(ServerSettingsV1 _delegate) : IAccountSettings public List Albums => _delegate.Albums; public List ExcludedAlbums => _delegate.ExcludedAlbums; public List People => _delegate.People; + public List Tags => _delegate.Tags; public int? Rating => _delegate.Rating; public void ValidateAndInitialize() { } @@ -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; diff --git a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs index 264824a7..9a978261 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -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; } @@ -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; diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 6f2ce348..15e2f010 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -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; } @@ -89,6 +90,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 Tags { get; set; } = new(); public int? Rating { get; set; } public void ValidateAndInitialize() diff --git a/docker/Settings.example.json b/docker/Settings.example.json index fe37b5a5..59406e78 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -56,6 +56,10 @@ ], "People": [ "UUID" + ], + "Tags": [ + "Vacation", + "Family" ] } ] diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 5662b364..1ba1bc00 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -52,3 +52,6 @@ Accounts: - UUID People: - UUID + Tags: + - Vacation + - Family diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index fbfac287..36057d78 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -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. @@ -130,6 +132,10 @@ Accounts: # UUID of People People: # string[] - UUID + # Tag values (full hierarchical paths, case-insensitive) + Tags: # string[] + - "Vacation" + - "Travel/Europe" ``` ### Security @@ -140,6 +146,13 @@ If this is enabled, the web api required the `Authorization`-Header with `Bearer ### Filtering on Albums or People 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` +### Filtering on Tags +For tags, use the full hierarchical path (the `value` field) as it appears in Immich. Tags in Immich support hierarchical structures using forward slashes (e.g., `Parent/Child`). Matching is case-insensitive, and the full path will be automatically resolved to the tag ID. + +**Examples:** +- `"Vacation"` - matches a top-level tag named "Vacation" +- `"Travel/Europe"` - matches a tag "Europe" under parent "Travel" + ### Weather Weather is enabled by entering an API key. Get yours free from [OpenWeatherMap][openweathermap-url] @@ -176,6 +189,7 @@ For full ImmichFrame functionality, the API key being used needs the following p - `memory.read` - `person.read` - `person.statistics` +- `tag.read` ### Custom CSS @@ -193,4 +207,4 @@ volumes: ``` -[openweathermap-url]: https://openweathermap.org/appid \ No newline at end of file +[openweathermap-url]: https://openweathermap.org/appid diff --git a/immichFrame.Web/src/lib/components/elements/asset-info.svelte b/immichFrame.Web/src/lib/components/elements/asset-info.svelte index 8768df5b..a3aa3885 100644 --- a/immichFrame.Web/src/lib/components/elements/asset-info.svelte +++ b/immichFrame.Web/src/lib/components/elements/asset-info.svelte @@ -4,7 +4,7 @@ import * as locale from 'date-fns/locale'; import { configStore } from '$lib/stores/config.store'; import Icon from './icon.svelte'; - import { mdiCalendar, mdiMapMarker, mdiAccount, mdiText, mdiImageAlbum } from '@mdi/js'; + import { mdiCalendar, mdiMapMarker, mdiAccount, mdiText, mdiImageAlbum, mdiTag } from '@mdi/js'; interface Props { asset: AssetResponseDto; @@ -13,6 +13,7 @@ showPhotoDate: boolean; showImageDesc: boolean; showPeopleDesc: boolean; + showTagsDesc: boolean; showAlbumName: boolean; split: boolean; } @@ -24,6 +25,7 @@ showPhotoDate, showImageDesc, showPeopleDesc, + showTagsDesc, showAlbumName, split, }: Props = $props(); @@ -65,9 +67,10 @@ ) ); let availablePeople = $derived(asset.people?.filter((x) => x.name)); + let availableTags = $derived(asset.tags?.filter((x) => x.name)); -{#if showPhotoDate || showLocation || showImageDesc || showPeopleDesc || showAlbumName} +{#if showPhotoDate || showLocation || showImageDesc || showPeopleDesc || showTagsDesc || showAlbumName}