From cf8f769f81a9b807c90b9a2b64c8805023bb5b6c Mon Sep 17 00:00:00 2001 From: Florian Obermayer Date: Sun, 22 Mar 2026 22:13:38 +0100 Subject: [PATCH 01/21] [FEAT] Introduced RequireAllPeople flag to filter assets for people in an AND fashion --- .../Logic/Pool/PersonAssetsPoolTests.cs | 80 +++++++++++++++++++ .../Interfaces/IServerSettings.cs | 1 + .../Logic/Pool/PeopleAssetsPool.cs | 13 ++- .../Resources/TestV1.json | 1 + .../Resources/TestV2.json | 2 + ImmichFrame.WebApi.Tests/Resources/TestV2.yml | 2 + .../Resources/TestV2_NoGeneral.json | 3 +- .../Helpers/Config/ServerSettingsV1.cs | 2 + ImmichFrame.WebApi/Models/ServerSettings.cs | 1 + 9 files changed, 101 insertions(+), 4 deletions(-) diff --git a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs index 54977293..1faccbfe 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs @@ -123,4 +123,84 @@ public async Task LoadAssets_PersonHasNoAssets_DoesNotAffectOthers() Assert.That(result.Count, Is.EqualTo(10)); Assert.That(result.All(a => a.Id.StartsWith("p1_"))); } + + [Test] + public async Task LoadAssets_RequireAllPeople_IssuesSingleQueryWithAllPersonIds() + { + // Arrange + var person1Id = Guid.NewGuid(); + var person2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + _mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true); + + var assets = Enumerable.Range(0, 5).Select(i => CreateAsset($"combined_{i}")).ToList(); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => + d.PersonIds.Contains(person1Id) && + d.PersonIds.Contains(person2Id) && + d.PersonIds.Count == 2), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(assets, 5)); + + // Act + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(5)); + // Only one call was made (AND mode), not one per person + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task LoadAssets_RequireAllPeople_Paginates() + { + // Arrange + var person1Id = Guid.NewGuid(); + var person2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + _mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true); + + int batchSize = 1000; + var page1Assets = Enumerable.Range(0, batchSize).Select(i => CreateAsset($"a_{i}")).ToList(); + var page2Assets = Enumerable.Range(0, 15).Select(i => CreateAsset($"b_{i}")).ToList(); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id) && d.Page == 1), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(page1Assets, batchSize)); + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id) && d.Page == 2), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(page2Assets, 15)); + + // Act + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(batchSize + 15)); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task LoadAssets_RequireAllPeople_NoSharedAssets_ReturnsEmpty() + { + // Arrange: two people configured, but no asset features both of them + var person1Id = Guid.NewGuid(); + var person2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + _mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id)), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List(), 0)); + + // Act + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result, Is.Empty); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.Once); + } } diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index fea6c442..fa17d282 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 bool RequireAllPeople { get; } public List Tags { get; } public int? Rating { get; } diff --git a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs index 8aa52bd8..97274ba4 100644 --- a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs @@ -14,8 +14,15 @@ protected override async Task> LoadAssets(Cancella { return personAssets; } - - foreach (var personId in people) + + // AND mode: pass all person IDs in a single query so the API returns only + // assets that feature every person in the list. + // OR mode (default): query each person separately and combine results. + var personIdGroups = accountSettings.RequireAllPeople + ? [people] + : people.Select(id => (IList)[id]); + + foreach (var personIds in personIdGroups) { int page = 1; int batchSize = 1000; @@ -26,7 +33,7 @@ protected override async Task> LoadAssets(Cancella { Page = page, Size = batchSize, - PersonIds = [personId], + PersonIds = personIds, WithExif = true, WithPeople = true }; diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV1.json b/ImmichFrame.WebApi.Tests/Resources/TestV1.json index e6c49102..3795863e 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV1.json @@ -28,6 +28,7 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "RequireAllPeople": true, "Tags": [ "Tags_TEST" ], diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index 4d603dc9..8fde0ffd 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -59,6 +59,7 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "RequireAllPeople": true, "Tags": [ "Account1.Tags_TEST" ] @@ -84,6 +85,7 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "RequireAllPeople": true, "Tags": [ "Account2.Tags_TEST" ] diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml index 47f45947..d2fb6528 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -53,6 +53,7 @@ Accounts: - 00000000-0000-0000-0000-000000000001 People: - 00000000-0000-0000-0000-000000000001 + RequireAllPeople: true Tags: - Account1.Tags_TEST - ImmichServerUrl: Account2.ImmichServerUrl_TEST @@ -72,5 +73,6 @@ Accounts: - 00000000-0000-0000-0000-000000000001 People: - 00000000-0000-0000-0000-000000000001 + RequireAllPeople: true Tags: - Account2.Tags_TEST diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json b/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json index 87279ffe..29b28700 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json @@ -18,7 +18,8 @@ ], "People": [ "00000000-0000-0000-0000-000000000001" - ] + ], + "RequireAllPeople": true }, { "ImmichServerUrl": "Account2.ImmichServerUrl_TEST", diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 076f36da..dfb1258b 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 bool RequireAllPeople { get; set; } = false; 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 bool RequireAllPeople => _delegate.RequireAllPeople; 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..6a697287 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 bool RequireAllPeople { get; set; } = false; public List Tags { get; set; } = new(); public int? Rating { get; set; } From 3f7f4c1199b73748aeaa216daeb3bb9900df823c Mon Sep 17 00:00:00 2001 From: Florian Obermayer Date: Mon, 23 Mar 2026 23:49:55 +0100 Subject: [PATCH 02/21] Add RequireAllPeople configuration to documentation --- docs/docs/getting-started/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 94e285d6..d7f8677a 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -138,6 +138,8 @@ Accounts: # UUID of People People: # string[] - UUID + # If this is set, all specified people must be present in an image for it to be displayed. + RequireAllPeople: false # boolean # Tag values (full hierarchical paths, case-sensitive) Tags: # string[] - "Vacation" From eb6198173b4e48e59b8e55d7462c515d04e3f31b Mon Sep 17 00:00:00 2001 From: Florian Obermayer Date: Sun, 22 Mar 2026 22:13:38 +0100 Subject: [PATCH 03/21] [FEAT] Introduced RequireAllPeople flag to filter assets for people in an AND fashion --- .../Logic/Pool/PersonAssetsPoolTests.cs | 80 +++++++++++++++++++ .../Interfaces/IServerSettings.cs | 1 + .../Logic/Pool/PeopleAssetsPool.cs | 13 ++- .../Resources/TestV1.json | 1 + .../Resources/TestV2.json | 2 + ImmichFrame.WebApi.Tests/Resources/TestV2.yml | 2 + .../Resources/TestV2_NoGeneral.json | 3 +- .../Helpers/Config/ServerSettingsV1.cs | 2 + ImmichFrame.WebApi/Models/ServerSettings.cs | 1 + 9 files changed, 101 insertions(+), 4 deletions(-) diff --git a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs index 54977293..1faccbfe 100644 --- a/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs +++ b/ImmichFrame.Core.Tests/Logic/Pool/PersonAssetsPoolTests.cs @@ -123,4 +123,84 @@ public async Task LoadAssets_PersonHasNoAssets_DoesNotAffectOthers() Assert.That(result.Count, Is.EqualTo(10)); Assert.That(result.All(a => a.Id.StartsWith("p1_"))); } + + [Test] + public async Task LoadAssets_RequireAllPeople_IssuesSingleQueryWithAllPersonIds() + { + // Arrange + var person1Id = Guid.NewGuid(); + var person2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + _mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true); + + var assets = Enumerable.Range(0, 5).Select(i => CreateAsset($"combined_{i}")).ToList(); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => + d.PersonIds.Contains(person1Id) && + d.PersonIds.Contains(person2Id) && + d.PersonIds.Count == 2), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(assets, 5)); + + // Act + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(5)); + // Only one call was made (AND mode), not one per person + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task LoadAssets_RequireAllPeople_Paginates() + { + // Arrange + var person1Id = Guid.NewGuid(); + var person2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + _mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true); + + int batchSize = 1000; + var page1Assets = Enumerable.Range(0, batchSize).Select(i => CreateAsset($"a_{i}")).ToList(); + var page2Assets = Enumerable.Range(0, 15).Select(i => CreateAsset($"b_{i}")).ToList(); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id) && d.Page == 1), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(page1Assets, batchSize)); + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id) && d.Page == 2), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(page2Assets, 15)); + + // Act + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result.Count, Is.EqualTo(batchSize + 15)); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task LoadAssets_RequireAllPeople_NoSharedAssets_ReturnsEmpty() + { + // Arrange: two people configured, but no asset features both of them + var person1Id = Guid.NewGuid(); + var person2Id = Guid.NewGuid(); + _mockAccountSettings.SetupGet(s => s.People).Returns(new List { person1Id, person2Id }); + _mockAccountSettings.SetupGet(s => s.RequireAllPeople).Returns(true); + + _mockImmichApi.Setup(api => api.SearchAssetsAsync( + It.Is(d => d.PersonIds.Contains(person1Id) && d.PersonIds.Contains(person2Id)), + It.IsAny())) + .ReturnsAsync(CreateSearchResult(new List(), 0)); + + // Act + var result = (await _personAssetsPool.TestLoadAssets()).ToList(); + + // Assert + Assert.That(result, Is.Empty); + _mockImmichApi.Verify(api => api.SearchAssetsAsync(It.IsAny(), It.IsAny()), Times.Once); + } } diff --git a/ImmichFrame.Core/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index fea6c442..fa17d282 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 bool RequireAllPeople { get; } public List Tags { get; } public int? Rating { get; } diff --git a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs index 8aa52bd8..97274ba4 100644 --- a/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/PeopleAssetsPool.cs @@ -14,8 +14,15 @@ protected override async Task> LoadAssets(Cancella { return personAssets; } - - foreach (var personId in people) + + // AND mode: pass all person IDs in a single query so the API returns only + // assets that feature every person in the list. + // OR mode (default): query each person separately and combine results. + var personIdGroups = accountSettings.RequireAllPeople + ? [people] + : people.Select(id => (IList)[id]); + + foreach (var personIds in personIdGroups) { int page = 1; int batchSize = 1000; @@ -26,7 +33,7 @@ protected override async Task> LoadAssets(Cancella { Page = page, Size = batchSize, - PersonIds = [personId], + PersonIds = personIds, WithExif = true, WithPeople = true }; diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV1.json b/ImmichFrame.WebApi.Tests/Resources/TestV1.json index e6c49102..3795863e 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV1.json @@ -28,6 +28,7 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "RequireAllPeople": true, "Tags": [ "Tags_TEST" ], diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index 4d603dc9..8fde0ffd 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -59,6 +59,7 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "RequireAllPeople": true, "Tags": [ "Account1.Tags_TEST" ] @@ -84,6 +85,7 @@ "People": [ "00000000-0000-0000-0000-000000000001" ], + "RequireAllPeople": true, "Tags": [ "Account2.Tags_TEST" ] diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml index 47f45947..d2fb6528 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -53,6 +53,7 @@ Accounts: - 00000000-0000-0000-0000-000000000001 People: - 00000000-0000-0000-0000-000000000001 + RequireAllPeople: true Tags: - Account1.Tags_TEST - ImmichServerUrl: Account2.ImmichServerUrl_TEST @@ -72,5 +73,6 @@ Accounts: - 00000000-0000-0000-0000-000000000001 People: - 00000000-0000-0000-0000-000000000001 + RequireAllPeople: true Tags: - Account2.Tags_TEST diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json b/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json index 87279ffe..29b28700 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2_NoGeneral.json @@ -18,7 +18,8 @@ ], "People": [ "00000000-0000-0000-0000-000000000001" - ] + ], + "RequireAllPeople": true }, { "ImmichServerUrl": "Account2.ImmichServerUrl_TEST", diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 076f36da..dfb1258b 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 bool RequireAllPeople { get; set; } = false; 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 bool RequireAllPeople => _delegate.RequireAllPeople; 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..6a697287 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 bool RequireAllPeople { get; set; } = false; public List Tags { get; set; } = new(); public int? Rating { get; set; } From 2a47f8ab8e449c8b734170d0fd51a6f1d6bfdafe Mon Sep 17 00:00:00 2001 From: Florian Obermayer Date: Mon, 23 Mar 2026 23:49:55 +0100 Subject: [PATCH 04/21] Add RequireAllPeople configuration to documentation --- docs/docs/getting-started/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 7378c7d1..87dfdbf1 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -138,6 +138,8 @@ Accounts: # UUID of People People: # string[] - UUID + # If this is set, all specified people must be present in an image for it to be displayed. + RequireAllPeople: false # boolean # Tag values (full hierarchical paths, case-sensitive) Tags: # string[] - "Vacation" From 8e7173ece8819c38bdfa3318980fbbc43463acae Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 16 Apr 2026 14:27:38 -0400 Subject: [PATCH 05/21] Memories fix...yet again --- ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs b/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs index 4ad20f99..4f81297e 100644 --- a/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs +++ b/ImmichFrame.Core/Logic/Pool/MemoryAssetsPool.cs @@ -9,7 +9,7 @@ public class MemoryAssetsPool(ImmichApi immichApi, IAccountSettings accountSetti { protected override async Task> LoadAssets(CancellationToken ct = default) { - var searchDate = new DateTimeOffset(DateTime.SpecifyKind(DateTime.Today, DateTimeKind.Utc), TimeSpan.Zero); + var searchDate = DateTimeOffset.Now; var memories = await immichApi.SearchMemoriesAsync(searchDate, null, null, null, ct); var memoryAssets = new List(); From 593668e4f4fd944ad8ddb330f780cb21b7bac204 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 23 Apr 2026 15:28:39 -0400 Subject: [PATCH 06/21] resolve asset transition lockups and video stall freezes --- .../elements/asset-component.svelte | 7 +- .../src/lib/components/elements/asset.svelte | 23 ++++++- .../components/elements/progress-bar.svelte | 33 ++++----- .../lib/components/home-page/home-page.svelte | 68 +++++++++++++------ 4 files changed, 87 insertions(+), 44 deletions(-) diff --git a/immichFrame.Web/src/lib/components/elements/asset-component.svelte b/immichFrame.Web/src/lib/components/elements/asset-component.svelte index 7f02e36b..66e8f92a 100644 --- a/immichFrame.Web/src/lib/components/elements/asset-component.svelte +++ b/immichFrame.Web/src/lib/components/elements/asset-component.svelte @@ -32,6 +32,7 @@ playAudio?: boolean; onVideoWaiting?: () => void; onVideoPlaying?: () => void; + onAssetError?: () => void; } let { @@ -53,7 +54,8 @@ showInfo = $bindable(false), playAudio = false, onVideoWaiting = () => {}, - onVideoPlaying = () => {} + onVideoPlaying = () => {}, + onAssetError = () => {} }: Props = $props(); let instantTransition = slideshowStore.instantTransition; let transitionDuration = $derived( @@ -119,6 +121,7 @@ {playAudio} {onVideoWaiting} {onVideoPlaying} + {onAssetError} bind:this={primaryAssetComponent} bind:showInfo /> @@ -140,6 +143,7 @@ {playAudio} {onVideoWaiting} {onVideoPlaying} + {onAssetError} bind:this={secondaryAssetComponent} bind:showInfo /> @@ -163,6 +167,7 @@ {playAudio} {onVideoWaiting} {onVideoPlaying} + {onAssetError} bind:this={primaryAssetComponent} bind:showInfo /> diff --git a/immichFrame.Web/src/lib/components/elements/asset.svelte b/immichFrame.Web/src/lib/components/elements/asset.svelte index 14ee6465..20431e46 100644 --- a/immichFrame.Web/src/lib/components/elements/asset.svelte +++ b/immichFrame.Web/src/lib/components/elements/asset.svelte @@ -1,4 +1,5 @@ {#if !hidden} @@ -86,6 +79,6 @@ id="progressbar" class="fixed left-0 h-[3px] bg-primary z-[1000] {location == ProgressBarLocation.Top ? 'top-0' : 'bottom-0'}" - style:width={`${$progress * 100}%`} + style:width={`${progress.current * 100}%`} > {/if} diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index fa183550..7dc19481 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -26,11 +26,14 @@ api.init(); - // TODO: make this configurable? const PRELOAD_ASSETS = 5; + const TRANSITION_WATCHDOG_MS = 10000; + const VIDEO_STALL_MS = 15000; + const CURSOR_HIDE_MS = 2000; + const RELOAD_ON_ERROR_MS = 30000; - let assetHistory: api.AssetResponseDto[] = []; - let assetBacklog: api.AssetResponseDto[] = []; + let assetHistory: api.AssetResponseDto[] = $state([]); + let assetBacklog: api.AssetResponseDto[] = $state([]); let displayingAssets: api.AssetResponseDto[] = $state([]); @@ -40,6 +43,11 @@ let progressBar: ProgressBar = $state() as ProgressBar; let assetComponent: AssetComponentInstance = $state() as AssetComponentInstance; let currentDuration: number = $state($configStore.interval ?? 20); + + let watchdogTimer: number | undefined = $state(); + let videoStallTimeout: number | undefined = $state(); + let timeoutId: number | undefined = $state(); + let userPaused: boolean = $state(false); let error: boolean = $state(false); @@ -63,7 +71,6 @@ let refreshInterval: number; let cursorVisible = $state(true); - let timeoutId: number; const clientIdentifier = page.url.searchParams.get('client'); const authsecret = page.url.searchParams.get('authsecret'); @@ -93,7 +100,7 @@ const showCursor = () => { cursorVisible = true; clearTimeout(timeoutId); - timeoutId = setTimeout(hideCursor, 2000); + timeoutId = window.setTimeout(hideCursor, CURSOR_HIDE_MS); }; async function updateAssetPromises() { @@ -116,16 +123,14 @@ !displayingAssets.find((item) => item.id === key) && !assetBacklog.find((item) => item.id === key) ); - for (const key of keysToRemove) { - try { - const [url] = await assetPromisesDict[key]; - revokeObjectUrl(url); - } catch (err) { - console.warn('Failed to resolve asset during cleanup:', err); - } finally { - delete assetPromisesDict[key]; - } - } + + keysToRemove.forEach((key) => { + const promise = assetPromisesDict[key]; + delete assetPromisesDict[key]; + promise + .then(([url]) => revokeObjectUrl(url)) + .catch((err) => console.warn('Failed to resolve asset during cleanup:', err)); + }); } async function loadAssets() { @@ -149,23 +154,36 @@ } } - let isHandlingAssetTransition = false; + let isHandlingAssetTransition = $state(false); const handleDone = async (previous: boolean = false, instant: boolean = false) => { if (isHandlingAssetTransition) { + console.warn('Transition already in progress, ignoring request'); return; } isHandlingAssetTransition = true; + + clearTimeout(watchdogTimer); + // Watchdog: If the transition (fetching/loading assets) takes longer than + // the current interval plus a 10s buffer, force-release the lock. + watchdogTimer = window.setTimeout(() => { + if (isHandlingAssetTransition) { + console.error('Transition watchdog triggered: Force-resetting lock due to hang'); + isHandlingAssetTransition = false; + } + }, (currentDuration * 1000) + TRANSITION_WATCHDOG_MS); + try { userPaused = false; progressBar.restart(false); $instantTransition = instant; if (previous) await getPreviousAssets(); else await getNextAssets(); - await tick(); + await tick(); await assetComponent?.play?.(); progressBar.play(); } finally { isHandlingAssetTransition = false; + clearTimeout(watchdogTimer); } }; @@ -182,7 +200,6 @@ const useSplit = shouldUseSplitView(assetBacklog); const next = assetBacklog.splice(0, useSplit ? 2 : 1); - assetBacklog = [...assetBacklog]; if (displayingAssets.length) { assetHistory.push(...displayingAssets); @@ -204,7 +221,6 @@ const useSplit = shouldUseSplitView(assetHistory.slice(-2)); const next = assetHistory.splice(useSplit ? -2 : -1); - assetHistory = [...assetHistory]; if (displayingAssets.length) { assetBacklog.unshift(...displayingAssets); @@ -392,7 +408,7 @@ // 30 second reload on error refreshInterval = window.setInterval(() => { if (error) window.location.reload(); - }, 30000); + }, RELOAD_ON_ERROR_MS); if ($configStore.primaryColor) { document.documentElement.style.setProperty('--primary-color', $configStore.primaryColor); @@ -426,6 +442,9 @@ window.removeEventListener('mousemove', showCursor); window.removeEventListener('click', showCursor); window.clearInterval(refreshInterval); + window.clearTimeout(timeoutId); + window.clearTimeout(videoStallTimeout); + window.clearTimeout(watchdogTimer); }; }); @@ -473,12 +492,21 @@ playAudio={$configStore.playAudio} onVideoWaiting={async () => { await progressBar.pause(); + clearTimeout(videoStallTimeout); + videoStallTimeout = window.setTimeout(() => { + console.warn('Video stalled, skipping...'); + handleDone(false, true); + }, Math.min(VIDEO_STALL_MS, currentDuration * 1000)); }} onVideoPlaying={async () => { if (!userPaused) { await progressBar.play(); + clearTimeout(videoStallTimeout); } }} + onAssetError={async () => { + await handleDone(false, true); + }} /> From 262cbbc0ea1700db9f05204451197ee2d77f433f Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 23 Apr 2026 15:32:29 -0400 Subject: [PATCH 07/21] comment cleanup --- immichFrame.Web/src/lib/components/elements/asset.svelte | 3 --- immichFrame.Web/src/lib/components/home-page/home-page.svelte | 3 ++- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/immichFrame.Web/src/lib/components/elements/asset.svelte b/immichFrame.Web/src/lib/components/elements/asset.svelte index 20431e46..fad28e55 100644 --- a/immichFrame.Web/src/lib/components/elements/asset.svelte +++ b/immichFrame.Web/src/lib/components/elements/asset.svelte @@ -54,8 +54,6 @@ let debug = false; const isVideo = $derived(isVideoAsset(asset[1])); - // Snapshot the interval when the asset ID changes to prevent "jumps" - // when the global currentDuration changes for the next asset. const animationDuration = $derived.by(() => { asset[1].id; return untrack(() => interval); @@ -64,7 +62,6 @@ let videoElement = $state(null); $effect(() => { - // Track asset URL to cleanup when it changes asset[0]; return () => { if (videoElement) { diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index 7dc19481..1a80a322 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -26,6 +26,7 @@ api.init(); + // TODO: make this configurable? const PRELOAD_ASSETS = 5; const TRANSITION_WATCHDOG_MS = 10000; const VIDEO_STALL_MS = 15000; @@ -164,7 +165,7 @@ clearTimeout(watchdogTimer); // Watchdog: If the transition (fetching/loading assets) takes longer than - // the current interval plus a 10s buffer, force-release the lock. + // the current interval plus a buffer, force-release the lock. watchdogTimer = window.setTimeout(() => { if (isHandlingAssetTransition) { console.error('Transition watchdog triggered: Force-resetting lock due to hang'); From 9439ab1629ed2a9ce6d3bec1cfbd6dc0fdf6c715 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 23 Apr 2026 15:50:29 -0400 Subject: [PATCH 08/21] coderabbit nitpicks --- .../src/lib/components/home-page/home-page.svelte | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index 1a80a322..9b26d2e1 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -164,6 +164,7 @@ isHandlingAssetTransition = true; clearTimeout(watchdogTimer); + clearTimeout(videoStallTimeout); // Watchdog: If the transition (fetching/loading assets) takes longer than // the current interval plus a buffer, force-release the lock. watchdogTimer = window.setTimeout(() => { @@ -495,14 +496,16 @@ await progressBar.pause(); clearTimeout(videoStallTimeout); videoStallTimeout = window.setTimeout(() => { - console.warn('Video stalled, skipping...'); - handleDone(false, true); - }, Math.min(VIDEO_STALL_MS, currentDuration * 1000)); + if (!userPaused) { + console.warn('Video stalled, skipping...'); + handleDone(false, true); + } + }, Math.min(VIDEO_STALL_MS, Math.max(currentDuration * 1000, 5000))); }} onVideoPlaying={async () => { + clearTimeout(videoStallTimeout); if (!userPaused) { await progressBar.play(); - clearTimeout(videoStallTimeout); } }} onAssetError={async () => { From c95396ca6a31e4002a29765a46333ee56207b65b Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 23 Apr 2026 16:02:23 -0400 Subject: [PATCH 09/21] coderrabbit nitpicks 2 --- .../src/lib/components/home-page/home-page.svelte | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index 9b26d2e1..6dac3b78 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -156,9 +156,14 @@ } let isHandlingAssetTransition = $state(false); + let pendingAssetError = $state(false); const handleDone = async (previous: boolean = false, instant: boolean = false) => { if (isHandlingAssetTransition) { console.warn('Transition already in progress, ignoring request'); + // If an error skip or manual skip is requested while busy, queue it + if (instant && !previous) { + pendingAssetError = true; + } return; } isHandlingAssetTransition = true; @@ -172,7 +177,7 @@ console.error('Transition watchdog triggered: Force-resetting lock due to hang'); isHandlingAssetTransition = false; } - }, (currentDuration * 1000) + TRANSITION_WATCHDOG_MS); + }, TRANSITION_WATCHDOG_MS); try { userPaused = false; @@ -186,6 +191,12 @@ } finally { isHandlingAssetTransition = false; clearTimeout(watchdogTimer); + + // If an asset error occurred during the transition, trigger the next skip now + if (pendingAssetError) { + pendingAssetError = false; + handleDone(false, true); + } } }; From c46a1805768efbf5c951f3d89a17b433fd70e592 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Thu, 23 Apr 2026 18:40:51 -0400 Subject: [PATCH 10/21] coderabbit 3 --- .../lib/components/home-page/home-page.svelte | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index 6dac3b78..7d681379 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -26,7 +26,6 @@ api.init(); - // TODO: make this configurable? const PRELOAD_ASSETS = 5; const TRANSITION_WATCHDOG_MS = 10000; const VIDEO_STALL_MS = 15000; @@ -156,14 +155,9 @@ } let isHandlingAssetTransition = $state(false); - let pendingAssetError = $state(false); const handleDone = async (previous: boolean = false, instant: boolean = false) => { if (isHandlingAssetTransition) { console.warn('Transition already in progress, ignoring request'); - // If an error skip or manual skip is requested while busy, queue it - if (instant && !previous) { - pendingAssetError = true; - } return; } isHandlingAssetTransition = true; @@ -171,13 +165,13 @@ clearTimeout(watchdogTimer); clearTimeout(videoStallTimeout); // Watchdog: If the transition (fetching/loading assets) takes longer than - // the current interval plus a buffer, force-release the lock. + // the current interval plus a 10s buffer, force-release the lock. watchdogTimer = window.setTimeout(() => { if (isHandlingAssetTransition) { console.error('Transition watchdog triggered: Force-resetting lock due to hang'); isHandlingAssetTransition = false; } - }, TRANSITION_WATCHDOG_MS); + }, (currentDuration * 1000) + TRANSITION_WATCHDOG_MS); try { userPaused = false; @@ -191,12 +185,6 @@ } finally { isHandlingAssetTransition = false; clearTimeout(watchdogTimer); - - // If an asset error occurred during the transition, trigger the next skip now - if (pendingAssetError) { - pendingAssetError = false; - handleDone(false, true); - } } }; @@ -507,16 +495,14 @@ await progressBar.pause(); clearTimeout(videoStallTimeout); videoStallTimeout = window.setTimeout(() => { - if (!userPaused) { - console.warn('Video stalled, skipping...'); - handleDone(false, true); - } - }, Math.min(VIDEO_STALL_MS, Math.max(currentDuration * 1000, 5000))); + console.warn('Video stalled, skipping...'); + handleDone(false, true); + }, Math.min(VIDEO_STALL_MS, currentDuration * 1000)); }} onVideoPlaying={async () => { - clearTimeout(videoStallTimeout); if (!userPaused) { await progressBar.play(); + clearTimeout(videoStallTimeout); } }} onAssetError={async () => { From 4c2b11d6ee99941792cdcddf0d3eb79ca585d618 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Fri, 24 Apr 2026 07:57:30 -0400 Subject: [PATCH 11/21] handle manual back skip mid-transition --- .../src/lib/components/home-page/home-page.svelte | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index 7d681379..6dbc8008 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -155,9 +155,11 @@ } let isHandlingAssetTransition = $state(false); + let pendingTransition: { previous: boolean; instant: boolean } | null = $state(null); + const handleDone = async (previous: boolean = false, instant: boolean = false) => { if (isHandlingAssetTransition) { - console.warn('Transition already in progress, ignoring request'); + pendingTransition = { previous, instant }; return; } isHandlingAssetTransition = true; @@ -185,6 +187,12 @@ } finally { isHandlingAssetTransition = false; clearTimeout(watchdogTimer); + + if (pendingTransition) { + const next = pendingTransition; + pendingTransition = null; + handleDone(next.previous, next.instant); + } } }; From 4da6151dfab0207344ad866a2071d1c36b69c732 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Fri, 24 Apr 2026 08:00:25 -0400 Subject: [PATCH 12/21] drain pending transition queue --- .../src/lib/components/home-page/home-page.svelte | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index 6dbc8008..25ae3f6c 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -172,6 +172,12 @@ if (isHandlingAssetTransition) { console.error('Transition watchdog triggered: Force-resetting lock due to hang'); isHandlingAssetTransition = false; + + if (pendingTransition) { + const next = pendingTransition; + pendingTransition = null; + handleDone(next.previous, next.instant); + } } }, (currentDuration * 1000) + TRANSITION_WATCHDOG_MS); From 39e4aabd923140ccb06d078c90d56f54e306fe63 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Fri, 24 Apr 2026 08:03:10 -0400 Subject: [PATCH 13/21] clear videoStallTimeout --- immichFrame.Web/src/lib/components/home-page/home-page.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index 25ae3f6c..f1cf7138 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -193,6 +193,7 @@ } finally { isHandlingAssetTransition = false; clearTimeout(watchdogTimer); + clearTimeout(videoStallTimeout); if (pendingTransition) { const next = pendingTransition; From 294e62234dd0f8c74c2a066a2bc71763b9b2a369 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Fri, 24 Apr 2026 08:23:19 -0400 Subject: [PATCH 14/21] add transition epoch --- .../lib/components/home-page/home-page.svelte | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index f1cf7138..ac4598ce 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -155,6 +155,7 @@ } let isHandlingAssetTransition = $state(false); + let transitionEpoch = 0; let pendingTransition: { previous: boolean; instant: boolean } | null = $state(null); const handleDone = async (previous: boolean = false, instant: boolean = false) => { @@ -162,14 +163,15 @@ pendingTransition = { previous, instant }; return; } + + const currentEpoch = ++transitionEpoch; isHandlingAssetTransition = true; clearTimeout(watchdogTimer); clearTimeout(videoStallTimeout); - // Watchdog: If the transition (fetching/loading assets) takes longer than - // the current interval plus a 10s buffer, force-release the lock. + // Watchdog: If the transition (fetching/loading assets) hangs, force-release the lock. watchdogTimer = window.setTimeout(() => { - if (isHandlingAssetTransition) { + if (currentEpoch === transitionEpoch && isHandlingAssetTransition) { console.error('Transition watchdog triggered: Force-resetting lock due to hang'); isHandlingAssetTransition = false; @@ -179,7 +181,7 @@ handleDone(next.previous, next.instant); } } - }, (currentDuration * 1000) + TRANSITION_WATCHDOG_MS); + }, TRANSITION_WATCHDOG_MS); try { userPaused = false; @@ -188,17 +190,22 @@ if (previous) await getPreviousAssets(); else await getNextAssets(); await tick(); + + if (currentEpoch !== transitionEpoch) return; + await assetComponent?.play?.(); progressBar.play(); } finally { - isHandlingAssetTransition = false; - clearTimeout(watchdogTimer); - clearTimeout(videoStallTimeout); - - if (pendingTransition) { - const next = pendingTransition; - pendingTransition = null; - handleDone(next.previous, next.instant); + if (currentEpoch === transitionEpoch) { + isHandlingAssetTransition = false; + clearTimeout(watchdogTimer); + clearTimeout(videoStallTimeout); + + if (pendingTransition) { + const next = pendingTransition; + pendingTransition = null; + handleDone(next.previous, next.instant); + } } } }; From 4b05b08a50de88b4197aab6738b3333f56c3c33b Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Fri, 24 Apr 2026 08:51:21 -0400 Subject: [PATCH 15/21] nitpick fixes --- .../src/lib/components/home-page/home-page.svelte | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index ac4598ce..528f4dad 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -44,9 +44,9 @@ let assetComponent: AssetComponentInstance = $state() as AssetComponentInstance; let currentDuration: number = $state($configStore.interval ?? 20); - let watchdogTimer: number | undefined = $state(); - let videoStallTimeout: number | undefined = $state(); - let timeoutId: number | undefined = $state(); + let watchdogTimer: number | undefined; + let videoStallTimeout: number | undefined; + let timeoutId: number | undefined; let userPaused: boolean = $state(false); @@ -199,7 +199,6 @@ if (currentEpoch === transitionEpoch) { isHandlingAssetTransition = false; clearTimeout(watchdogTimer); - clearTimeout(videoStallTimeout); if (pendingTransition) { const next = pendingTransition; From 3fcfefb3928d04616e4043e110a7d1995faa2e48 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Fri, 24 Apr 2026 11:23:47 -0400 Subject: [PATCH 16/21] Stall-timer gating --- .../src/lib/components/home-page/home-page.svelte | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index 528f4dad..79c9b68b 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -515,15 +515,19 @@ onVideoWaiting={async () => { await progressBar.pause(); clearTimeout(videoStallTimeout); + if (userPaused) return; + videoStallTimeout = window.setTimeout(() => { - console.warn('Video stalled, skipping...'); - handleDone(false, true); - }, Math.min(VIDEO_STALL_MS, currentDuration * 1000)); + if (!userPaused) { + console.warn('Video stalled, skipping...'); + handleDone(false, true); + } + }, Math.max(5000, Math.min(VIDEO_STALL_MS, currentDuration * 1000))); }} onVideoPlaying={async () => { + clearTimeout(videoStallTimeout); if (!userPaused) { await progressBar.play(); - clearTimeout(videoStallTimeout); } }} onAssetError={async () => { From 279748db5fbea84d17e9a88376766a9689fe76a3 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Fri, 24 Apr 2026 13:41:00 -0400 Subject: [PATCH 17/21] watchdog recovery, comments --- .../src/lib/components/elements/asset.svelte | 4 ++- .../components/elements/progress-bar.svelte | 1 + .../lib/components/home-page/home-page.svelte | 34 +++++++++++-------- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/immichFrame.Web/src/lib/components/elements/asset.svelte b/immichFrame.Web/src/lib/components/elements/asset.svelte index fad28e55..09182bc0 100644 --- a/immichFrame.Web/src/lib/components/elements/asset.svelte +++ b/immichFrame.Web/src/lib/components/elements/asset.svelte @@ -54,8 +54,10 @@ let debug = false; const isVideo = $derived(isVideoAsset(asset[1])); + // Re-evaluate only when the asset changes; keep the interval stable for the + // lifetime of the current asset so zoom/pan animations don't restart. const animationDuration = $derived.by(() => { - asset[1].id; + void asset[1].id; return untrack(() => interval); }); diff --git a/immichFrame.Web/src/lib/components/elements/progress-bar.svelte b/immichFrame.Web/src/lib/components/elements/progress-bar.svelte index 24e28f2b..5e4b083f 100644 --- a/immichFrame.Web/src/lib/components/elements/progress-bar.svelte +++ b/immichFrame.Web/src/lib/components/elements/progress-bar.svelte @@ -57,6 +57,7 @@ export const pause = async () => { status = ProgressBarStatus.Paused; onPaused(); + // Freeze in place: targeting the current value with duration(to-from≈0) ≈ 0 halts motion. await progress.set(progress.current); }; diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index 79c9b68b..fbfb5677 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -43,11 +43,11 @@ let progressBar: ProgressBar = $state() as ProgressBar; let assetComponent: AssetComponentInstance = $state() as AssetComponentInstance; let currentDuration: number = $state($configStore.interval ?? 20); - + let watchdogTimer: number | undefined; let videoStallTimeout: number | undefined; let timeoutId: number | undefined; - + let userPaused: boolean = $state(false); let error: boolean = $state(false); @@ -166,7 +166,7 @@ const currentEpoch = ++transitionEpoch; isHandlingAssetTransition = true; - + clearTimeout(watchdogTimer); clearTimeout(videoStallTimeout); // Watchdog: If the transition (fetching/loading assets) hangs, force-release the lock. @@ -175,11 +175,12 @@ console.error('Transition watchdog triggered: Force-resetting lock due to hang'); isHandlingAssetTransition = false; - if (pendingTransition) { - const next = pendingTransition; - pendingTransition = null; - handleDone(next.previous, next.instant); - } + // Bump the epoch so the original (still-awaiting) transition becomes a no-op + // when/if it eventually resolves, and force a fresh advance. + transitionEpoch++; + const next = pendingTransition ?? { previous: false, instant: true }; + pendingTransition = null; + handleDone(next.previous, next.instant); } }, TRANSITION_WATCHDOG_MS); @@ -189,7 +190,7 @@ $instantTransition = instant; if (previous) await getPreviousAssets(); else await getNextAssets(); - await tick(); + await tick(); if (currentEpoch !== transitionEpoch) return; @@ -517,12 +518,15 @@ clearTimeout(videoStallTimeout); if (userPaused) return; - videoStallTimeout = window.setTimeout(() => { - if (!userPaused) { - console.warn('Video stalled, skipping...'); - handleDone(false, true); - } - }, Math.max(5000, Math.min(VIDEO_STALL_MS, currentDuration * 1000))); + videoStallTimeout = window.setTimeout( + () => { + if (!userPaused) { + console.warn('Video stalled, skipping...'); + handleDone(false, true); + } + }, + Math.max(5000, Math.min(VIDEO_STALL_MS, currentDuration * 1000)) + ); }} onVideoPlaying={async () => { clearTimeout(videoStallTimeout); From 367532f49530a03d5cd8569d710476c11e765a37 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Sat, 25 Apr 2026 20:23:30 -0400 Subject: [PATCH 18/21] stop consecutive errors --- .../lib/components/home-page/home-page.svelte | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index fbfb5677..454be1a0 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -44,6 +44,8 @@ let assetComponent: AssetComponentInstance = $state() as AssetComponentInstance; let currentDuration: number = $state($configStore.interval ?? 20); + let consecutiveErrorSkips = 0; + let errorSkipScheduled = false; let watchdogTimer: number | undefined; let videoStallTimeout: number | undefined; let timeoutId: number | undefined; @@ -180,7 +182,10 @@ transitionEpoch++; const next = pendingTransition ?? { previous: false, instant: true }; pendingTransition = null; - handleDone(next.previous, next.instant); + handleDone(next.previous, next.instant).catch((err) => { + console.error('handleDone failed:', err); + isHandlingAssetTransition = false; + }); } }, TRANSITION_WATCHDOG_MS); @@ -196,6 +201,7 @@ await assetComponent?.play?.(); progressBar.play(); + consecutiveErrorSkips = 0; } finally { if (currentEpoch === transitionEpoch) { isHandlingAssetTransition = false; @@ -204,7 +210,10 @@ if (pendingTransition) { const next = pendingTransition; pendingTransition = null; - handleDone(next.previous, next.instant); + handleDone(next.previous, next.instant).catch((err) => { + console.error('handleDone failed:', err); + isHandlingAssetTransition = false; + }); } } } @@ -529,13 +538,26 @@ ); }} onVideoPlaying={async () => { + consecutiveErrorSkips = 0; clearTimeout(videoStallTimeout); if (!userPaused) { await progressBar.play(); } }} onAssetError={async () => { + if (errorSkipScheduled) return; + errorSkipScheduled = true; + + consecutiveErrorSkips++; + if (consecutiveErrorSkips > 10) { + error = true; + errorMessage = 'Too many consecutive asset load failures. Please check your network or server connection.'; + errorSkipScheduled = false; + return; + } + await handleDone(false, true); + errorSkipScheduled = false; }} /> From 27c84953d604f2944d010dd0dbf748e5202f3217 Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Mon, 27 Apr 2026 10:51:47 -0400 Subject: [PATCH 19/21] progress tween fix --- immichFrame.Web/src/lib/components/elements/progress-bar.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/immichFrame.Web/src/lib/components/elements/progress-bar.svelte b/immichFrame.Web/src/lib/components/elements/progress-bar.svelte index 5e4b083f..aba08d02 100644 --- a/immichFrame.Web/src/lib/components/elements/progress-bar.svelte +++ b/immichFrame.Web/src/lib/components/elements/progress-bar.svelte @@ -25,7 +25,7 @@ onPaused = () => {} }: Props = $props(); - const progress = new Tween(0, { + const progress = new Tween(0, { duration: (from: number, to: number) => { if (to === 0) return 0; return duration * 1000 * (to - from); From 46f51333b670bec4851fed66d9c7f7ffaf29404a Mon Sep 17 00:00:00 2001 From: Rob Rogers Date: Wed, 27 May 2026 08:04:01 -0400 Subject: [PATCH 20/21] prevent unhandled promise failures --- .../src/lib/components/elements/progress-bar.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/immichFrame.Web/src/lib/components/elements/progress-bar.svelte b/immichFrame.Web/src/lib/components/elements/progress-bar.svelte index aba08d02..747f6adc 100644 --- a/immichFrame.Web/src/lib/components/elements/progress-bar.svelte +++ b/immichFrame.Web/src/lib/components/elements/progress-bar.svelte @@ -9,7 +9,7 @@ location?: ProgressBarLocation; hidden?: boolean; duration?: number; - onDone: () => void; + onDone: () => void | Promise; onPlaying?: () => void; onPaused?: () => void; } @@ -36,7 +36,10 @@ $effect(() => { if (progress.current >= 1 && !completed) { completed = true; - untrack(() => onDone()); + const result = untrack(() => onDone()); + void Promise.resolve(result).catch((err) => { + console.error('ProgressBar onDone failed:', err); + }); } else if (progress.current < 1) { completed = false; } From e72e8122e96347f8342bf8589aae0612d41cb1c3 Mon Sep 17 00:00:00 2001 From: Florian Obermayer Date: Sun, 12 Apr 2026 01:25:55 +0200 Subject: [PATCH 21/21] [FEAT] Introduced RequireAllPeople flag to filter assets for people in an AND fashion --- docker/Settings.example.json | 1 + docker/Settings.example.yml | 1 + docker/example.env | 1 + 3 files changed, 3 insertions(+) diff --git a/docker/Settings.example.json b/docker/Settings.example.json index a86a4d00..aa7506d8 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -59,6 +59,7 @@ "People": [ "UUID" ], + "RequireAllPeople": false, "Tags": [ "Vacation", "Travel/Europe" diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 173b31a5..e55da607 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -54,6 +54,7 @@ Accounts: - UUID People: - UUID + RequireAllPeople: false Tags: - Vacation - Travel/Europe diff --git a/docker/example.env b/docker/example.env index 51d80ed5..0ade8554 100644 --- a/docker/example.env +++ b/docker/example.env @@ -24,6 +24,7 @@ ApiKey=KEY # Albums=ALBUM1,ALBUM2 # ExcludedAlbums=ALBUM3,ALBUM4 # People=PERSON1,PERSON2 +# RequireAllPeople=false # 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