From 33f93fccedb220be88f9f86485b7b2b19c968f4c Mon Sep 17 00:00:00 2001 From: Ryan Gregg Date: Mon, 20 Jul 2026 11:42:25 -0700 Subject: [PATCH 1/5] feat(events): add core event model and in-memory queue Introduce the FrameEvent domain model (mode, action, ack status) and an in-memory priority queue that supports category replacement and per-mode active tracking, with unit tests. --- .../Events/InMemoryFrameEventQueueTests.cs | 250 ++++++++++++++++++ ImmichFrame.Core/Events/FrameEvent.cs | 22 ++ .../Events/FrameEventAckStatus.cs | 13 + ImmichFrame.Core/Events/FrameEventAction.cs | 8 + ImmichFrame.Core/Events/FrameEventInput.cs | 7 + ImmichFrame.Core/Events/FrameEventMode.cs | 11 + .../Interfaces/IFrameEventQueue.cs | 15 ++ .../Services/InMemoryFrameEventQueue.cs | 226 ++++++++++++++++ 8 files changed, 552 insertions(+) create mode 100644 ImmichFrame.Core.Tests/Events/InMemoryFrameEventQueueTests.cs create mode 100644 ImmichFrame.Core/Events/FrameEvent.cs create mode 100644 ImmichFrame.Core/Events/FrameEventAckStatus.cs create mode 100644 ImmichFrame.Core/Events/FrameEventAction.cs create mode 100644 ImmichFrame.Core/Events/FrameEventInput.cs create mode 100644 ImmichFrame.Core/Events/FrameEventMode.cs create mode 100644 ImmichFrame.Core/Interfaces/IFrameEventQueue.cs create mode 100644 ImmichFrame.Core/Services/InMemoryFrameEventQueue.cs diff --git a/ImmichFrame.Core.Tests/Events/InMemoryFrameEventQueueTests.cs b/ImmichFrame.Core.Tests/Events/InMemoryFrameEventQueueTests.cs new file mode 100644 index 00000000..3984cb09 --- /dev/null +++ b/ImmichFrame.Core.Tests/Events/InMemoryFrameEventQueueTests.cs @@ -0,0 +1,250 @@ +using ImmichFrame.Core.Events; +using ImmichFrame.Core.Services; +using NUnit.Framework; + +namespace ImmichFrame.Core.Tests.Events; + +[TestFixture] +public class InMemoryFrameEventQueueTests +{ + private InMemoryFrameEventQueue _queue; + + [SetUp] + public void SetUp() + { + _queue = new InMemoryFrameEventQueue(); + } + + private static FrameEvent MakeEvent(FrameEventMode mode, string id, string deviceId = "device-1", int priority = 0, string? category = null, int? timeoutMs = null) + { + return new FrameEvent + { + Id = id, + DeviceId = deviceId, + Type = "frame.ui.v1", + Mode = mode, + Message = $"Message for {id}", + Priority = priority, + Category = category, + TimeoutMs = timeoutMs, + PostedAt = DateTime.UtcNow + }; + } + + private static FrameEvent MakeEvent(string id, string deviceId = "device-1", int priority = 0, string? category = null, int? timeoutMs = null) + { + return new FrameEvent + { + Id = id, + DeviceId = deviceId, + Type = "frame.ui.v1", + Mode = FrameEventMode.PopupText, + Message = $"Message for {id}", + Priority = priority, + Category = category, + TimeoutMs = timeoutMs, + PostedAt = DateTime.UtcNow + }; + } + + [Test] + public async Task PeekNextAsync_ReturnsNull_WhenQueueEmpty() + { + var result = await _queue.PeekNextAsync("device-1"); + Assert.That(result, Is.Null); + } + + [Test] + public async Task PeekNextAsync_ReturnsHighestPriority() + { + await _queue.EnqueueAsync(MakeEvent("low", priority: 10)); + await _queue.EnqueueAsync(MakeEvent("high", priority: 1)); + + var result = await _queue.PeekNextAsync("device-1"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.Id, Is.EqualTo("high")); + } + + [Test] + public async Task EnqueueAsync_ReturnsFalse_WhenDuplicateId() + { + var first = await _queue.EnqueueAsync(MakeEvent("dup")); + var second = await _queue.EnqueueAsync(MakeEvent("dup")); + + Assert.That(first, Is.True); + Assert.That(second, Is.False); + } + + [Test] + public async Task EnqueueAsync_ReplacesCategory() + { + await _queue.EnqueueAsync(MakeEvent("old", category: "alerts")); + await _queue.EnqueueAsync(MakeEvent("new", category: "alerts")); + + var result = await _queue.PeekNextAsync("device-1"); + Assert.That(result!.Id, Is.EqualTo("new")); + + var snapshot = _queue.GetDeviceSnapshot("device-1"); + Assert.That(snapshot, Has.Count.EqualTo(1)); + } + + [Test] + public async Task EnqueueAsync_CloseMode_RemovesMatchingCategory() + { + await _queue.EnqueueAsync(MakeEvent("alert1", category: "alerts")); + await _queue.EnqueueAsync(MakeEvent("other", category: "info")); + + var closeEvent = new FrameEvent + { + Id = "close-1", + DeviceId = "device-1", + Type = "frame.ui.v1", + Mode = FrameEventMode.Close, + Category = "alerts", + PostedAt = DateTime.UtcNow + }; + await _queue.EnqueueAsync(closeEvent); + + var snapshot = _queue.GetDeviceSnapshot("device-1"); + Assert.That(snapshot, Has.Count.EqualTo(1)); + Assert.That(snapshot[0].Event.Id, Is.EqualTo("other")); + } + + [Test] + public async Task EnqueueAsync_CloseModeWithoutCategory_RemovesAll() + { + await _queue.EnqueueAsync(MakeEvent("a")); + await _queue.EnqueueAsync(MakeEvent("b")); + + var closeEvent = new FrameEvent + { + Id = "close-all", + DeviceId = "device-1", + Type = "frame.ui.v1", + Mode = FrameEventMode.Close, + PostedAt = DateTime.UtcNow + }; + await _queue.EnqueueAsync(closeEvent); + + var result = await _queue.PeekNextAsync("device-1"); + Assert.That(result, Is.Null); + } + + [Test] + public async Task AckAsync_DoesNotRemove_OnShown() + { + await _queue.EnqueueAsync(MakeEvent("evt")); + await _queue.PeekNextAsync("device-1"); + + var acked = await _queue.AckAsync("device-1", "evt", FrameEventAckStatus.Shown); + Assert.That(acked, Is.True); + + var result = await _queue.PeekNextAsync("device-1"); + Assert.That(result, Is.Not.Null); + } + + [Test] + public async Task AckAsync_Removes_OnClosed() + { + await _queue.EnqueueAsync(MakeEvent("evt")); + await _queue.PeekNextAsync("device-1"); + + var acked = await _queue.AckAsync("device-1", "evt", FrameEventAckStatus.Closed); + Assert.That(acked, Is.True); + + var result = await _queue.PeekNextAsync("device-1"); + Assert.That(result, Is.Null); + } + + [Test] + public async Task AckAsync_ReturnsFalse_WhenEventNotFound() + { + var result = await _queue.AckAsync("device-1", "nonexistent", FrameEventAckStatus.Closed); + Assert.That(result, Is.False); + } + + [Test] + public async Task GetDeviceSnapshot_ReturnsEmpty_ForUnknownDevice() + { + var snapshot = _queue.GetDeviceSnapshot("unknown"); + Assert.That(snapshot, Is.Empty); + } + + [Test] + public async Task PeekNext_WithModeFilter_ReturnsOnlyMatchingMode() + { + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-1")); + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-1")); + + var popup = await _queue.PeekNextAsync("device-1", FrameEventMode.PopupText); + var banner = await _queue.PeekNextAsync("device-1", FrameEventMode.Banner); + + Assert.That(popup, Is.Not.Null); + Assert.That(popup!.Id, Is.EqualTo("popup-1")); + Assert.That(banner, Is.Not.Null); + Assert.That(banner!.Id, Is.EqualTo("banner-1")); + } + + [Test] + public async Task PeekNext_WithModeFilter_ReturnsNullWhenNoMatch() + { + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-1")); + + var banner = await _queue.PeekNextAsync("device-1", FrameEventMode.Banner); + + Assert.That(banner, Is.Null); + } + + [Test] + public async Task PeekNext_AfterAckingPopup_BannerStillReturned() + { + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-1")); + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-1")); + + await _queue.AckAsync("device-1", "popup-1", FrameEventAckStatus.Closed); + + var banner = await _queue.PeekNextAsync("device-1", FrameEventMode.Banner); + Assert.That(banner, Is.Not.Null); + Assert.That(banner!.Id, Is.EqualTo("banner-1")); + } + + [Test] + public async Task Enqueue_BannerWithCategory_DoesNotEvictPopupWithSameCategory() + { + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-1", category: "shared")); + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-1", category: "shared")); + + var popup = await _queue.PeekNextAsync("device-1", FrameEventMode.PopupText); + var banner = await _queue.PeekNextAsync("device-1", FrameEventMode.Banner); + + Assert.That(popup, Is.Not.Null); + Assert.That(popup!.Id, Is.EqualTo("popup-1")); + Assert.That(banner, Is.Not.Null); + Assert.That(banner!.Id, Is.EqualTo("banner-1")); + } + + [Test] + public async Task Enqueue_BannerWithCategory_ReplacesOlderBannerWithSameCategory() + { + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-old", category: "shared")); + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-new", category: "shared")); + + var snapshot = _queue.GetDeviceSnapshot("device-1"); + Assert.That(snapshot.Count, Is.EqualTo(1)); + Assert.That(snapshot[0].Event.Id, Is.EqualTo("banner-new")); + } + + [Test] + public async Task PeekNext_NoModeFilter_ReturnsHighestPriorityRegardlessOfMode() + { + // The EventEntryComparer sorts ascending by priority (lower numeric value = higher effective priority). + // popup-low gets priority 10 (low effective priority), banner-high gets priority 0 (high effective priority). + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-low", priority: 10)); + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-high", priority: 0)); + + var top = await _queue.PeekNextAsync("device-1"); + + Assert.That(top, Is.Not.Null); + Assert.That(top!.Id, Is.EqualTo("banner-high")); + } +} diff --git a/ImmichFrame.Core/Events/FrameEvent.cs b/ImmichFrame.Core/Events/FrameEvent.cs new file mode 100644 index 00000000..90a0f174 --- /dev/null +++ b/ImmichFrame.Core/Events/FrameEvent.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace ImmichFrame.Core.Events; + +public class FrameEvent +{ + public string DeviceId { get; init; } = string.Empty; + public string Id { get; init; } = string.Empty; + public string Type { get; init; } = string.Empty; + public FrameEventMode Mode { get; init; } + public string? Message { get; init; } + public int? TimeoutMs { get; init; } + public int Priority { get; init; } + public string? Category { get; init; } + public string? Title { get; init; } + public IReadOnlyDictionary? Meta { get; init; } + public IReadOnlyList Actions { get; init; } = Array.Empty(); + public FrameEventInput Input { get; init; } = new(); + public DateTime PostedAt { get; init; } +} diff --git a/ImmichFrame.Core/Events/FrameEventAckStatus.cs b/ImmichFrame.Core/Events/FrameEventAckStatus.cs new file mode 100644 index 00000000..17ff5e80 --- /dev/null +++ b/ImmichFrame.Core/Events/FrameEventAckStatus.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace ImmichFrame.Core.Events; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FrameEventAckStatus +{ + Shown, + Closed, + Timeout, + Error, + Dismissed +} diff --git a/ImmichFrame.Core/Events/FrameEventAction.cs b/ImmichFrame.Core/Events/FrameEventAction.cs new file mode 100644 index 00000000..09aa33b3 --- /dev/null +++ b/ImmichFrame.Core/Events/FrameEventAction.cs @@ -0,0 +1,8 @@ +namespace ImmichFrame.Core.Events; + +public class FrameEventAction +{ + public string Id { get; init; } = string.Empty; + public string Label { get; init; } = string.Empty; + public string? Kind { get; init; } +} diff --git a/ImmichFrame.Core/Events/FrameEventInput.cs b/ImmichFrame.Core/Events/FrameEventInput.cs new file mode 100644 index 00000000..55ae18ac --- /dev/null +++ b/ImmichFrame.Core/Events/FrameEventInput.cs @@ -0,0 +1,7 @@ +namespace ImmichFrame.Core.Events; + +public class FrameEventInput +{ + public bool AllowTouchDismiss { get; init; } = true; + public bool AllowKeyboardDismiss { get; init; } = true; +} diff --git a/ImmichFrame.Core/Events/FrameEventMode.cs b/ImmichFrame.Core/Events/FrameEventMode.cs new file mode 100644 index 00000000..fcb35312 --- /dev/null +++ b/ImmichFrame.Core/Events/FrameEventMode.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace ImmichFrame.Core.Events; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FrameEventMode +{ + PopupText, + Close, + Banner +} diff --git a/ImmichFrame.Core/Interfaces/IFrameEventQueue.cs b/ImmichFrame.Core/Interfaces/IFrameEventQueue.cs new file mode 100644 index 00000000..f5665a7b --- /dev/null +++ b/ImmichFrame.Core/Interfaces/IFrameEventQueue.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ImmichFrame.Core.Events; + +namespace ImmichFrame.Core.Interfaces; + +public interface IFrameEventQueue +{ + Task EnqueueAsync(FrameEvent frameEvent, CancellationToken cancellationToken = default); + Task PeekNextAsync(string deviceId, FrameEventMode? mode = null, CancellationToken cancellationToken = default); + Task AckAsync(string deviceId, string eventId, FrameEventAckStatus status, CancellationToken cancellationToken = default); + Task RemoveByCategoryAsync(string deviceId, string category, CancellationToken cancellationToken = default); + IReadOnlyList<(FrameEvent Event, FrameEventAckStatus? LastAckStatus)> GetDeviceSnapshot(string deviceId); +} diff --git a/ImmichFrame.Core/Services/InMemoryFrameEventQueue.cs b/ImmichFrame.Core/Services/InMemoryFrameEventQueue.cs new file mode 100644 index 00000000..a7bfe241 --- /dev/null +++ b/ImmichFrame.Core/Services/InMemoryFrameEventQueue.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ImmichFrame.Core.Events; +using ImmichFrame.Core.Interfaces; + +namespace ImmichFrame.Core.Services; + +public class InMemoryFrameEventQueue : IFrameEventQueue +{ + private readonly ConcurrentDictionary _queues = new(); + + public Task EnqueueAsync(FrameEvent frameEvent, CancellationToken cancellationToken = default) + { + var queue = _queues.GetOrAdd(frameEvent.DeviceId, _ => new DeviceQueue()); + return Task.FromResult(queue.Enqueue(frameEvent)); + } + + public Task PeekNextAsync(string deviceId, FrameEventMode? mode = null, CancellationToken cancellationToken = default) + { + if (!_queues.TryGetValue(deviceId, out var queue)) + return Task.FromResult(null); + + return Task.FromResult(queue.PeekNext(mode)); + } + + public Task AckAsync(string deviceId, string eventId, FrameEventAckStatus status, CancellationToken cancellationToken = default) + { + if (!_queues.TryGetValue(deviceId, out var queue)) + return Task.FromResult(false); + + return Task.FromResult(queue.Ack(eventId, status)); + } + + public Task RemoveByCategoryAsync(string deviceId, string category, CancellationToken cancellationToken = default) + { + if (!_queues.TryGetValue(deviceId, out var queue)) + return Task.FromResult(0); + + return Task.FromResult(queue.RemoveByCategory(category)); + } + + public IReadOnlyList<(FrameEvent Event, FrameEventAckStatus? LastAckStatus)> GetDeviceSnapshot(string deviceId) + { + if (!_queues.TryGetValue(deviceId, out var queue)) + return Array.Empty<(FrameEvent, FrameEventAckStatus?)>(); + + return queue.GetSnapshot(); + } + + private class DeviceQueue + { + private readonly object _lock = new(); + private readonly SortedSet _entries = new(EventEntryComparer.Instance); + private readonly Dictionary _byId = new(); + private readonly Dictionary<(FrameEventMode Mode, string Category), EventEntry> _byCategory = new(); + private readonly Dictionary _activeEventIdByMode = new(); + + public bool Enqueue(FrameEvent frameEvent) + { + lock (_lock) + { + if (frameEvent.Mode == FrameEventMode.Close) + { + if (!string.IsNullOrWhiteSpace(frameEvent.Category)) + RemoveByCategory(frameEvent.Category); + else + Clear(); + return true; + } + + if (_byId.ContainsKey(frameEvent.Id)) + return false; + + if (!string.IsNullOrWhiteSpace(frameEvent.Category)) + { + var key = (frameEvent.Mode, frameEvent.Category.ToLowerInvariant()); + if (_byCategory.TryGetValue(key, out var existing)) + Remove(existing); + } + + var entry = new EventEntry(frameEvent); + _entries.Add(entry); + _byId[frameEvent.Id] = entry; + + if (!string.IsNullOrWhiteSpace(frameEvent.Category)) + _byCategory[(frameEvent.Mode, frameEvent.Category.ToLowerInvariant())] = entry; + + return true; + } + } + + public FrameEvent? PeekNext(FrameEventMode? mode = null) + { + lock (_lock) + { + RemoveExpired(); + + if (_entries.Count == 0) + { + _activeEventIdByMode.Clear(); + return null; + } + + EventEntry? selected; + if (mode is null) + { + selected = _entries.Min; + } + else + { + selected = _entries.FirstOrDefault(e => e.Event.Mode == mode.Value); + } + + if (selected is null) + return null; + + _activeEventIdByMode[selected.Event.Mode] = selected.Event.Id; + return selected.Event; + } + } + + public bool Ack(string eventId, FrameEventAckStatus status) + { + lock (_lock) + { + if (!_byId.TryGetValue(eventId, out var entry)) + return false; + + entry.LastAckStatus = status; + + if (status != FrameEventAckStatus.Shown) + { + var mode = entry.Event.Mode; + Remove(entry); + if (_activeEventIdByMode.TryGetValue(mode, out var activeId) && activeId == eventId) + _activeEventIdByMode.Remove(mode); + } + + return true; + } + } + + public int RemoveByCategory(string category) + { + lock (_lock) + { + var toRemove = _entries.Where(e => + string.Equals(e.Event.Category, category, StringComparison.OrdinalIgnoreCase)).ToList(); + + foreach (var entry in toRemove) + Remove(entry); + + return toRemove.Count; + } + } + + public IReadOnlyList<(FrameEvent Event, FrameEventAckStatus? LastAckStatus)> GetSnapshot() + { + lock (_lock) + { + return _entries.Select(e => (e.Event, e.LastAckStatus)).ToList(); + } + } + + private void Remove(EventEntry entry) + { + _entries.Remove(entry); + _byId.Remove(entry.Event.Id); + if (!string.IsNullOrWhiteSpace(entry.Event.Category)) + _byCategory.Remove((entry.Event.Mode, entry.Event.Category.ToLowerInvariant())); + } + + private void Clear() + { + _entries.Clear(); + _byId.Clear(); + _byCategory.Clear(); + _activeEventIdByMode.Clear(); + } + + private void RemoveExpired() + { + var now = DateTime.UtcNow; + var expired = _entries.Where(e => + { + if (e.Event.TimeoutMs is not > 0) return false; + return e.Event.PostedAt.AddMilliseconds(e.Event.TimeoutMs.Value) < now; + }).ToList(); + + foreach (var entry in expired) + Remove(entry); + } + } + + private class EventEntry + { + public FrameEvent Event { get; } + public FrameEventAckStatus? LastAckStatus { get; set; } + + public EventEntry(FrameEvent frameEvent) => Event = frameEvent; + } + + private class EventEntryComparer : IComparer + { + public static readonly EventEntryComparer Instance = new(); + + public int Compare(EventEntry? x, EventEntry? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x is null) return -1; + if (y is null) return 1; + + var priorityCompare = x.Event.Priority.CompareTo(y.Event.Priority); + if (priorityCompare != 0) return priorityCompare; + + var timeCompare = x.Event.PostedAt.CompareTo(y.Event.PostedAt); + if (timeCompare != 0) return timeCompare; + + return string.Compare(x.Event.Id, y.Event.Id, StringComparison.Ordinal); + } + } +} From f4d5a402bf293a93b9cb8b3d9196c9f837c18cea Mon Sep 17 00:00:00 2001 From: Ryan Gregg Date: Mon, 20 Jul 2026 11:42:25 -0700 Subject: [PATCH 2/5] feat(events): add events API, DTOs, and validation Add the EventsController (submit / next / ack) with request and response DTOs, a FrameEventValidator supporting mode filtering, and enum serialization wiring in Program.cs, covered by validator unit tests. --- .../Events/FrameEventValidatorTests.cs | 174 ++++++++++++++++++ .../Controllers/EventsController.cs | 112 +++++++++++ .../Models/Events/FrameEventAckRequestDto.cs | 10 + .../Models/Events/FrameEventDiagnosticsDto.cs | 37 ++++ .../Models/Events/FrameEventRequestDto.cs | 82 +++++++++ .../Models/Events/FrameEventResponseDto.cs | 38 ++++ ImmichFrame.WebApi/Program.cs | 5 + .../Services/FrameEventValidator.cs | 82 +++++++++ 8 files changed, 540 insertions(+) create mode 100644 ImmichFrame.WebApi.Tests/Events/FrameEventValidatorTests.cs create mode 100644 ImmichFrame.WebApi/Controllers/EventsController.cs create mode 100644 ImmichFrame.WebApi/Models/Events/FrameEventAckRequestDto.cs create mode 100644 ImmichFrame.WebApi/Models/Events/FrameEventDiagnosticsDto.cs create mode 100644 ImmichFrame.WebApi/Models/Events/FrameEventRequestDto.cs create mode 100644 ImmichFrame.WebApi/Models/Events/FrameEventResponseDto.cs create mode 100644 ImmichFrame.WebApi/Services/FrameEventValidator.cs diff --git a/ImmichFrame.WebApi.Tests/Events/FrameEventValidatorTests.cs b/ImmichFrame.WebApi.Tests/Events/FrameEventValidatorTests.cs new file mode 100644 index 00000000..2322edc1 --- /dev/null +++ b/ImmichFrame.WebApi.Tests/Events/FrameEventValidatorTests.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using ImmichFrame.Core.Events; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.WebApi.Models.Events; +using ImmichFrame.WebApi.Services; +using Moq; +using NUnit.Framework; + +namespace ImmichFrame.WebApi.Tests.Events; + +[TestFixture] +public class FrameEventValidatorTests +{ + private FrameEventValidator _validator; + + [SetUp] + public void SetUp() + { + var settings = new Mock(); + settings.Setup(s => s.EventDefaultTimeoutMs).Returns(15000); + _validator = new FrameEventValidator(settings.Object); + } + + private static FrameEventRequestDto MakeValidPopupText() + { + return new FrameEventRequestDto + { + DeviceId = "device-1", + Id = "evt-1", + Type = "frame.ui.v1", + Mode = FrameEventMode.PopupText, + Message = "Hello world" + }; + } + + private static FrameEventRequestDto MakeValidBanner() + { + return new FrameEventRequestDto + { + DeviceId = "device-1", + Id = "evt-banner-1", + Type = "frame.ui.banner", + Mode = FrameEventMode.Banner, + Message = "banner text" + }; + } + + [Test] + public void Validate_NullDto_Throws() + { + Assert.Throws(() => _validator.Validate(null!)); + } + + [Test] + public void Validate_Succeeds_ForValidPopupText() + { + var result = _validator.Validate(MakeValidPopupText()); + Assert.That(result.Id, Is.EqualTo("evt-1")); + Assert.That(result.Mode, Is.EqualTo(FrameEventMode.PopupText)); + Assert.That(result.Message, Is.EqualTo("Hello world")); + } + + [Test] + public void Validate_Throws_WhenDeviceIdMissing() + { + var dto = MakeValidPopupText(); + dto.DeviceId = ""; + Assert.Throws(() => _validator.Validate(dto)); + } + + [Test] + public void Validate_Throws_WhenIdMissing() + { + var dto = MakeValidPopupText(); + dto.Id = ""; + Assert.Throws(() => _validator.Validate(dto)); + } + + [Test] + public void Validate_Throws_WhenTypeMissing() + { + var dto = MakeValidPopupText(); + dto.Type = ""; + Assert.Throws(() => _validator.Validate(dto)); + } + + [Test] + public void Validate_Throws_WhenTypeInvalid() + { + var dto = MakeValidPopupText(); + dto.Type = "invalid.type"; + Assert.Throws(() => _validator.Validate(dto)); + } + + [Test] + public void Validate_Throws_ForPopupTextWithoutMessage() + { + var dto = MakeValidPopupText(); + dto.Message = null; + Assert.Throws(() => _validator.Validate(dto)); + } + + [Test] + public void Validate_UsesDefaultTimeout_WhenNotProvided() + { + var dto = MakeValidPopupText(); + dto.TimeoutMs = null; + + var result = _validator.Validate(dto); + Assert.That(result.TimeoutMs, Is.EqualTo(15000)); + } + + [Test] + public void Validate_Throws_WhenTimeoutNegative() + { + var dto = MakeValidPopupText(); + dto.TimeoutMs = -1; + Assert.Throws(() => _validator.Validate(dto)); + } + + [Test] + public void Validate_Succeeds_ForCloseMode() + { + var dto = new FrameEventRequestDto + { + DeviceId = "device-1", + Id = "close-1", + Type = "frame.ui.v1", + Mode = FrameEventMode.Close, + Category = "alerts" + }; + + var result = _validator.Validate(dto); + Assert.That(result.Mode, Is.EqualTo(FrameEventMode.Close)); + } + + [Test] + public void Validate_BannerWithMessage_Succeeds() + { + var dto = MakeValidBanner(); + + var domain = _validator.Validate(dto); + + Assert.That(domain.Mode, Is.EqualTo(FrameEventMode.Banner)); + Assert.That(domain.Message, Is.EqualTo("banner text")); + } + + [Test] + public void Validate_BannerWithoutMessage_Throws() + { + var dto = MakeValidBanner(); + dto.Message = null; + + Assert.Throws(() => _validator.Validate(dto)); + } + + [Test] + public void Validate_BannerWithTitleAndActions_StillValidates() + { + var dto = MakeValidBanner(); + dto.Title = "banner title"; + dto.Actions = new List + { + new() { Id = "ack", Label = "OK", Kind = "primary" } + }; + + var domain = _validator.Validate(dto); + + Assert.That(domain.Title, Is.EqualTo("banner title")); + Assert.That(domain.Actions, Has.Count.EqualTo(1)); + } +} diff --git a/ImmichFrame.WebApi/Controllers/EventsController.cs b/ImmichFrame.WebApi/Controllers/EventsController.cs new file mode 100644 index 00000000..c693d3d7 --- /dev/null +++ b/ImmichFrame.WebApi/Controllers/EventsController.cs @@ -0,0 +1,112 @@ +using System.ComponentModel.DataAnnotations; +using System.Linq; +using ImmichFrame.Core.Events; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.WebApi.Models.Events; +using ImmichFrame.WebApi.Services; +using Microsoft.AspNetCore.Mvc; + +namespace ImmichFrame.WebApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class EventsController : ControllerBase +{ + private readonly IFrameEventQueue _queue; + private readonly FrameEventValidator _validator; + private readonly ILogger _logger; + private readonly IGeneralSettings _settings; + + public EventsController(IFrameEventQueue queue, FrameEventValidator validator, ILogger logger, IGeneralSettings settings) + { + _queue = queue; + _validator = validator; + _logger = logger; + _settings = settings; + } + + [HttpPost] + public async Task PostEvent([FromBody] FrameEventRequestDto request, CancellationToken cancellationToken) + { + try + { + if (!_settings.EventHostEnabled) + return NotFound(new { message = "Event host is disabled" }); + + var frameEvent = _validator.Validate(request); + var enqueued = await _queue.EnqueueAsync(frameEvent, cancellationToken); + + if (!enqueued) + return Conflict(new { message = "Event already exists" }); + + return Accepted(); + } + catch (ValidationException vex) + { + _logger.LogWarning(vex, "Invalid frame event received with id {EventId}", Sanitize(request?.Id)); + return BadRequest(new { message = vex.Message }); + } + } + + [HttpGet("next")] + public async Task GetNext([FromQuery] string deviceId, [FromQuery] FrameEventMode? mode, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(deviceId)) + return BadRequest(new { message = "deviceId is required" }); + + if (!_settings.EventHostEnabled) + return NotFound(new { message = "Event host is disabled" }); + + var frameEvent = await _queue.PeekNextAsync(deviceId, mode, cancellationToken); + + if (frameEvent is null) + return NoContent(); + + return Ok(FrameEventResponseDto.FromDomain(frameEvent)); + } + + [HttpPost("{eventId}/ack")] + public async Task AckEvent(string eventId, [FromQuery] string deviceId, [FromBody] FrameEventAckRequestDto request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(deviceId)) + return BadRequest(new { message = "deviceId is required" }); + + if (request is null || request.Status is null) + return BadRequest(new { message = "status is required" }); + + if (!_settings.EventHostEnabled) + return NotFound(new { message = "Event host is disabled" }); + + var removed = await _queue.AckAsync(deviceId, eventId, request.Status.Value, cancellationToken); + + if (!removed) + return NotFound(); + + _logger.LogInformation("Acked event {EventId} for {DeviceId} with status {Status}", Sanitize(eventId), Sanitize(deviceId), request.Status); + return Ok(new { eventId, deviceId, status = request.Status.Value.ToString() }); + } + + private static string Sanitize(string? value) + => value is null ? "" : value.Replace("\r", "").Replace("\n", ""); + + [HttpGet("pending")] + public IActionResult GetPending([FromQuery] string deviceId) + { + if (string.IsNullOrWhiteSpace(deviceId)) + return BadRequest(new { message = "deviceId is required" }); + + if (!_settings.EventHostEnabled) + return NotFound(new { message = "Event host is disabled" }); + + var snapshot = _queue.GetDeviceSnapshot(deviceId); + var dto = new FrameEventDiagnosticsDto + { + DeviceId = deviceId, + Pending = snapshot + .Select(tuple => FrameEventStateDto.From(tuple.Event, tuple.LastAckStatus)) + .ToList() + }; + + return Ok(dto); + } +} diff --git a/ImmichFrame.WebApi/Models/Events/FrameEventAckRequestDto.cs b/ImmichFrame.WebApi/Models/Events/FrameEventAckRequestDto.cs new file mode 100644 index 00000000..93c618e1 --- /dev/null +++ b/ImmichFrame.WebApi/Models/Events/FrameEventAckRequestDto.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; +using ImmichFrame.Core.Events; + +namespace ImmichFrame.WebApi.Models.Events; + +public class FrameEventAckRequestDto +{ + [Required] + public FrameEventAckStatus? Status { get; set; } +} diff --git a/ImmichFrame.WebApi/Models/Events/FrameEventDiagnosticsDto.cs b/ImmichFrame.WebApi/Models/Events/FrameEventDiagnosticsDto.cs new file mode 100644 index 00000000..d1f5f1fc --- /dev/null +++ b/ImmichFrame.WebApi/Models/Events/FrameEventDiagnosticsDto.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using ImmichFrame.Core.Events; + +namespace ImmichFrame.WebApi.Models.Events; + +public class FrameEventDiagnosticsDto +{ + public string DeviceId { get; init; } = string.Empty; + public IReadOnlyList Pending { get; init; } = new List(); +} + +public class FrameEventStateDto +{ + public string Id { get; init; } = string.Empty; + public FrameEventMode Mode { get; init; } + public string? Category { get; init; } + public int Priority { get; init; } + public FrameEventAckStatus? LastAckStatus { get; init; } + public string? Title { get; init; } + public int? TimeoutMs { get; init; } + public string? PostedAt { get; init; } + + public static FrameEventStateDto From(FrameEvent frameEvent, FrameEventAckStatus? ackStatus) + { + return new FrameEventStateDto + { + Id = frameEvent.Id, + Mode = frameEvent.Mode, + Category = frameEvent.Category, + Priority = frameEvent.Priority, + LastAckStatus = ackStatus, + Title = frameEvent.Title, + TimeoutMs = frameEvent.TimeoutMs, + PostedAt = frameEvent.PostedAt.ToString("o") + }; + } +} diff --git a/ImmichFrame.WebApi/Models/Events/FrameEventRequestDto.cs b/ImmichFrame.WebApi/Models/Events/FrameEventRequestDto.cs new file mode 100644 index 00000000..9eef9742 --- /dev/null +++ b/ImmichFrame.WebApi/Models/Events/FrameEventRequestDto.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using ImmichFrame.Core.Events; + +namespace ImmichFrame.WebApi.Models.Events; + +public class FrameEventRequestDto +{ + [Required] + public string DeviceId { get; set; } = string.Empty; + + [Required] + public string Id { get; set; } = string.Empty; + + [Required] + public string Type { get; set; } = string.Empty; + + [Required] + public FrameEventMode Mode { get; set; } + + public string? Message { get; set; } + public int? TimeoutMs { get; set; } + public int Priority { get; set; } + public string? Category { get; set; } + public string? Title { get; set; } + public Dictionary? Meta { get; set; } + public List Actions { get; set; } = new(); + public FrameEventInputDto? Input { get; set; } + public DateTime? PostedAt { get; set; } + + public FrameEvent ToDomain() + { + return new FrameEvent + { + DeviceId = DeviceId, + Id = Id, + Type = Type, + Mode = Mode, + Message = Message, + TimeoutMs = TimeoutMs, + Priority = Priority, + Category = string.IsNullOrWhiteSpace(Category) ? null : Category, + Title = Title, + Meta = Meta, + Actions = Actions.ConvertAll(a => a.ToDomain()), + Input = (Input ?? new FrameEventInputDto()).ToDomain(), + PostedAt = PostedAt?.ToUniversalTime() ?? DateTime.UtcNow + }; + } +} + +public class FrameEventActionDto +{ + [Required] + public string Id { get; set; } = string.Empty; + + [Required] + public string Label { get; set; } = string.Empty; + + public string? Kind { get; set; } + + public FrameEventAction ToDomain() => new() + { + Id = Id, + Label = Label, + Kind = Kind + }; +} + +public class FrameEventInputDto +{ + public bool AllowTouchDismiss { get; set; } = true; + public bool AllowKeyboardDismiss { get; set; } = true; + + public FrameEventInput ToDomain() => new() + { + AllowTouchDismiss = AllowTouchDismiss, + AllowKeyboardDismiss = AllowKeyboardDismiss + }; +} diff --git a/ImmichFrame.WebApi/Models/Events/FrameEventResponseDto.cs b/ImmichFrame.WebApi/Models/Events/FrameEventResponseDto.cs new file mode 100644 index 00000000..06d9b37e --- /dev/null +++ b/ImmichFrame.WebApi/Models/Events/FrameEventResponseDto.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using ImmichFrame.Core.Events; + +namespace ImmichFrame.WebApi.Models.Events; + +public class FrameEventResponseDto +{ + public string Id { get; init; } = string.Empty; + public string Type { get; init; } = string.Empty; + public FrameEventMode Mode { get; init; } + public string? Message { get; init; } + public int? TimeoutMs { get; init; } + public int Priority { get; init; } + public string? Category { get; init; } + public string? Title { get; init; } + public IReadOnlyDictionary? Meta { get; init; } + public IReadOnlyList Actions { get; init; } = Array.Empty(); + public FrameEventInput Input { get; init; } = new(); + public DateTime PostedAt { get; init; } + + public static FrameEventResponseDto FromDomain(FrameEvent frameEvent) => new() + { + Id = frameEvent.Id, + Type = frameEvent.Type, + Mode = frameEvent.Mode, + Message = frameEvent.Message, + TimeoutMs = frameEvent.TimeoutMs, + Priority = frameEvent.Priority, + Category = frameEvent.Category, + Title = frameEvent.Title, + Meta = frameEvent.Meta, + Actions = frameEvent.Actions, + Input = frameEvent.Input, + PostedAt = frameEvent.PostedAt + }; +} diff --git a/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index 3e5018f8..ecaca43b 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -1,12 +1,15 @@ using ImmichFrame.Core.Helpers; using ImmichFrame.Core.Interfaces; +using ImmichFrame.Core.Services; using ImmichFrame.WebApi.Models; +using ImmichFrame.WebApi.Services; using Microsoft.AspNetCore.Authentication; using System.Reflection; using ImmichFrame.Core.Logic; using ImmichFrame.Core.Logic.AccountSelection; using ImmichFrame.WebApi.Helpers; using ImmichFrame.WebApi.Helpers.Config; +using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); //log the version number @@ -76,6 +79,8 @@ _ _ __ ___ _ __ ___ _ ___| |__ | |_ _ __ __ _ _ __ ___ ___ account => ActivatorUtilities.CreateInstance(srv, account)); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle diff --git a/ImmichFrame.WebApi/Services/FrameEventValidator.cs b/ImmichFrame.WebApi/Services/FrameEventValidator.cs new file mode 100644 index 00000000..19b0a200 --- /dev/null +++ b/ImmichFrame.WebApi/Services/FrameEventValidator.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using ImmichFrame.Core.Events; +using ImmichFrame.Core.Interfaces; +using ImmichFrame.WebApi.Models.Events; + +namespace ImmichFrame.WebApi.Services; + +public class FrameEventValidator +{ + private readonly IGeneralSettings _settings; + + public FrameEventValidator(IGeneralSettings settings) + { + _settings = settings; + } + + public FrameEvent Validate(FrameEventRequestDto dto) + { + if (dto is null) + throw new ValidationException("request body is required"); + + if (string.IsNullOrWhiteSpace(dto.DeviceId)) + throw new ValidationException("deviceId is required"); + + if (string.IsNullOrWhiteSpace(dto.Id)) + throw new ValidationException("id is required"); + + if (string.IsNullOrWhiteSpace(dto.Type)) + throw new ValidationException("type is required"); + + if (!dto.Type.StartsWith("frame.ui.", StringComparison.OrdinalIgnoreCase)) + throw new ValidationException("type must start with 'frame.ui.'"); + + var timeoutMs = dto.TimeoutMs ?? _settings.EventDefaultTimeoutMs; + if (timeoutMs < 0) + throw new ValidationException("timeoutMs must be >= 0"); + + switch (dto.Mode) + { + case FrameEventMode.PopupText: + if (string.IsNullOrWhiteSpace(dto.Message)) + throw new ValidationException("message is required for PopupText mode"); + break; + + case FrameEventMode.Banner: + if (string.IsNullOrWhiteSpace(dto.Message)) + throw new ValidationException("message is required for Banner mode"); + break; + + case FrameEventMode.Close: + break; + + default: + throw new ValidationException($"mode '{dto.Mode}' is not supported"); + } + + IReadOnlyList actions = dto.Actions?.Count > 0 + ? dto.Actions.ConvertAll(a => a.ToDomain()) + : Array.Empty(); + + var input = dto.Input?.ToDomain() ?? new FrameEventInput(); + + return new FrameEvent + { + DeviceId = dto.DeviceId, + Id = dto.Id, + Type = dto.Type, + Mode = dto.Mode, + Message = dto.Message, + TimeoutMs = timeoutMs, + Priority = dto.Priority, + Category = string.IsNullOrWhiteSpace(dto.Category) ? null : dto.Category, + Title = dto.Title, + Meta = dto.Meta, + Actions = actions, + Input = input, + PostedAt = dto.PostedAt?.ToUniversalTime() ?? DateTime.UtcNow + }; + } +} From 73114dc7c4ee5c7ddb5db904ce18762790589600 Mon Sep 17 00:00:00 2001 From: Ryan Gregg Date: Mon, 20 Jul 2026 11:42:25 -0700 Subject: [PATCH 3/5] feat(events): add server and client event settings Add EventHostEnabled, polling interval, and default timeout settings with bounds validation, expose them through the server and client settings DTOs, and update config examples and test resources. --- .../Interfaces/IClientSettings.cs | 3 ++ .../Helpers/Config/ConfigLoaderTest.cs | 8 +++- .../Models/EventSettingsBoundsTests.cs | 45 +++++++++++++++++++ .../Resources/TestV1.json | 3 ++ .../Resources/TestV2.json | 5 ++- ImmichFrame.WebApi.Tests/Resources/TestV2.yml | 3 ++ .../Helpers/Config/ServerSettingsV1.cs | 23 ++++++++++ .../Models/ClientSettingsDto.cs | 3 ++ ImmichFrame.WebApi/Models/ServerSettings.cs | 20 ++++++++- docker/Settings.example.json | 5 ++- docker/Settings.example.yml | 3 ++ 11 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 ImmichFrame.WebApi.Tests/Models/EventSettingsBoundsTests.cs diff --git a/ImmichFrame.Core/Interfaces/IClientSettings.cs b/ImmichFrame.Core/Interfaces/IClientSettings.cs index 576c13eb..edd934b9 100644 --- a/ImmichFrame.Core/Interfaces/IClientSettings.cs +++ b/ImmichFrame.Core/Interfaces/IClientSettings.cs @@ -30,5 +30,8 @@ public interface IClientSettings public bool PlayAudio { get; } public string Layout { get; } public string Language { get; } + public bool EventHostEnabled { get; } + public int EventPollingIntervalSeconds { get; } + public int EventDefaultTimeoutMs { get; } } } diff --git a/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs b/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs index 15c3254b..a1b79aed 100644 --- a/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs +++ b/ImmichFrame.WebApi.Tests/Helpers/Config/ConfigLoaderTest.cs @@ -120,8 +120,14 @@ private void VerifyProperties(object o, string? prefix = "", bool expectNullApiK Assert.That(value, Is.EqualTo(true), prop.Name); break; case var t when t == typeof(int): - Assert.That(value, Is.EqualTo(7), prop.Name); + { + var range = prop.GetCustomAttribute(); + var expected = range is null + ? 7 + : Math.Clamp(7, (int)range.Minimum, (int)range.Maximum); + Assert.That(value, Is.EqualTo(expected), prop.Name); break; + } case var t when t == typeof(double): Assert.That(value, Is.EqualTo(7.7d), prop.Name); break; diff --git a/ImmichFrame.WebApi.Tests/Models/EventSettingsBoundsTests.cs b/ImmichFrame.WebApi.Tests/Models/EventSettingsBoundsTests.cs new file mode 100644 index 00000000..1f14f30f --- /dev/null +++ b/ImmichFrame.WebApi.Tests/Models/EventSettingsBoundsTests.cs @@ -0,0 +1,45 @@ +using ImmichFrame.WebApi.Helpers; +using ImmichFrame.WebApi.Models; +using NUnit.Framework; + +namespace ImmichFrame.WebApi.Tests.Models; + +[TestFixture] +public class EventSettingsBoundsTests +{ + [TestCase(-5, 1)] + [TestCase(0, 1)] + [TestCase(5, 5)] + [TestCase(3601, 3600)] + public void V2_EventPollingIntervalSeconds_IsClampedOnSet(int input, int expected) + { + var settings = new GeneralSettings { EventPollingIntervalSeconds = input }; + Assert.That(settings.EventPollingIntervalSeconds, Is.EqualTo(expected)); + } + + [TestCase(-1, 100)] + [TestCase(50, 100)] + [TestCase(500, 500)] + [TestCase(400_000, 300_000)] + public void V2_EventDefaultTimeoutMs_IsClampedOnSet(int input, int expected) + { + var settings = new GeneralSettings { EventDefaultTimeoutMs = input }; + Assert.That(settings.EventDefaultTimeoutMs, Is.EqualTo(expected)); + } + + [TestCase(0, 1)] + [TestCase(10_000, 3600)] + public void V1_EventPollingIntervalSeconds_IsClampedOnSet(int input, int expected) + { + var settings = new ServerSettingsV1 { EventPollingIntervalSeconds = input }; + Assert.That(settings.EventPollingIntervalSeconds, Is.EqualTo(expected)); + } + + [TestCase(0, 100)] + [TestCase(10_000_000, 300_000)] + public void V1_EventDefaultTimeoutMs_IsClampedOnSet(int input, int expected) + { + var settings = new ServerSettingsV1 { EventDefaultTimeoutMs = input }; + Assert.That(settings.EventDefaultTimeoutMs, Is.EqualTo(expected)); + } +} diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV1.json b/ImmichFrame.WebApi.Tests/Resources/TestV1.json index e6c49102..9d7a5c56 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV1.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV1.json @@ -56,6 +56,9 @@ "WeatherLatLong": "WeatherLatLong_TEST", "Language": "Language_TEST", "Webhook": "Webhook_TEST", + "EventHostEnabled": true, + "EventPollingIntervalSeconds": 7, + "EventDefaultTimeoutMs": 7, "Account2.ImmichServerUrl": "Account2.ImmichServerUrl_TEST", "Account2.ApiKey": "Account2.ApiKey_TEST", "Account2.ImagesFromDate": "Account2.ImagesFromDate_TEST" diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.json b/ImmichFrame.WebApi.Tests/Resources/TestV2.json index 4d603dc9..0dac4dac 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.json +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.json @@ -35,7 +35,10 @@ "ImagePan": true, "ImageFill": true, "PlayAudio": true, - "Layout": "Layout_TEST" + "Layout": "Layout_TEST", + "EventHostEnabled": true, + "EventPollingIntervalSeconds": 7, + "EventDefaultTimeoutMs": 7 }, "Accounts": [ { diff --git a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml index 47f45947..5500228c 100644 --- a/ImmichFrame.WebApi.Tests/Resources/TestV2.yml +++ b/ImmichFrame.WebApi.Tests/Resources/TestV2.yml @@ -35,6 +35,9 @@ General: ImageFill: true PlayAudio: true Layout: Layout_TEST + EventHostEnabled: true + EventPollingIntervalSeconds: 7 + EventDefaultTimeoutMs: 7 Accounts: - ImmichServerUrl: Account1.ImmichServerUrl_TEST ApiKey: Account1.ApiKey_TEST diff --git a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs index 076f36da..2b14fa5c 100644 --- a/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs +++ b/ImmichFrame.WebApi/Helpers/Config/ServerSettingsV1.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using ImmichFrame.Core.Interfaces; namespace ImmichFrame.WebApi.Helpers; @@ -56,6 +57,23 @@ public class ServerSettingsV1 : IConfigSettable public bool ImageFill { get; set; } = false; public bool PlayAudio { get; set; } = false; public string Layout { get; set; } = "splitview"; + public bool EventHostEnabled { get; set; } = false; + + private int _eventPollingIntervalSeconds = 2; + [Range(1, 3600)] + public int EventPollingIntervalSeconds + { + get => _eventPollingIntervalSeconds; + set => _eventPollingIntervalSeconds = Math.Clamp(value, 1, 3600); + } + + private int _eventDefaultTimeoutMs = 15000; + [Range(100, 300_000)] + public int EventDefaultTimeoutMs + { + get => _eventDefaultTimeoutMs; + set => _eventDefaultTimeoutMs = Math.Clamp(value, 100, 300_000); + } } /// @@ -135,6 +153,11 @@ class GeneralSettingsV1Adapter(ServerSettingsV1 _delegate) : IGeneralSettings public bool PlayAudio => _delegate.PlayAudio; public string Layout => _delegate.Layout; public string Language => _delegate.Language; + public bool EventHostEnabled => _delegate.EventHostEnabled; + [Range(1, 3600)] + public int EventPollingIntervalSeconds => _delegate.EventPollingIntervalSeconds; + [Range(100, 300_000)] + public int EventDefaultTimeoutMs => _delegate.EventDefaultTimeoutMs; public void Validate() { } } diff --git a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs index 5e408fe9..fb35b1c9 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -32,4 +32,7 @@ public class ClientSettingsDto(IClientSettings settings) : IClientSettings public bool PlayAudio => settings.PlayAudio; public string Layout => settings.Layout; public string Language => settings.Language; + public bool EventHostEnabled => settings.EventHostEnabled; + public int EventPollingIntervalSeconds => settings.EventPollingIntervalSeconds; + public int EventDefaultTimeoutMs => settings.EventDefaultTimeoutMs; } diff --git a/ImmichFrame.WebApi/Models/ServerSettings.cs b/ImmichFrame.WebApi/Models/ServerSettings.cs index 74d0fb8e..3a7296e6 100644 --- a/ImmichFrame.WebApi/Models/ServerSettings.cs +++ b/ImmichFrame.WebApi/Models/ServerSettings.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Serialization; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; using ImmichFrame.Core.Interfaces; using ImmichFrame.WebApi.Helpers; using YamlDotNet.Serialization; @@ -72,6 +73,23 @@ public class GeneralSettings : IGeneralSettings, IConfigSettable public string? WeatherLatLong { get; set; } = "40.7128,74.0060"; public string? Webhook { get; set; } public string? AuthenticationSecret { get; set; } + public bool EventHostEnabled { get; set; } = false; + + private int _eventPollingIntervalSeconds = 2; + [Range(1, 3600)] + public int EventPollingIntervalSeconds + { + get => _eventPollingIntervalSeconds; + set => _eventPollingIntervalSeconds = Math.Clamp(value, 1, 3600); + } + + private int _eventDefaultTimeoutMs = 15000; + [Range(100, 300_000)] + public int EventDefaultTimeoutMs + { + get => _eventDefaultTimeoutMs; + set => _eventDefaultTimeoutMs = Math.Clamp(value, 100, 300_000); + } public void Validate() { } } diff --git a/docker/Settings.example.json b/docker/Settings.example.json index a86a4d00..bcf4bcde 100644 --- a/docker/Settings.example.json +++ b/docker/Settings.example.json @@ -35,7 +35,10 @@ "ImagePan": false, "ImageFill": false, "PlayAudio": false, - "Layout": "splitview" + "Layout": "splitview", + "EventHostEnabled": false, + "EventPollingIntervalSeconds": 2, + "EventDefaultTimeoutMs": 15000 }, "Accounts": [ { diff --git a/docker/Settings.example.yml b/docker/Settings.example.yml index 173b31a5..553e9d5e 100644 --- a/docker/Settings.example.yml +++ b/docker/Settings.example.yml @@ -34,6 +34,9 @@ General: ImageFill: false PlayAudio: false Layout: splitview + EventHostEnabled: false + EventPollingIntervalSeconds: 2 + EventDefaultTimeoutMs: 15000 Accounts: - ImmichServerUrl: REQUIRED # Exactly one of ApiKey or ApiKeyFile must be set. From 8c4a696dc242403fd9e7412d65ede1c86d1acf58 Mon Sep 17 00:00:00 2001 From: Ryan Gregg Date: Mon, 20 Jul 2026 11:42:25 -0700 Subject: [PATCH 4/5] feat(events): add frontend polling service and overlays Add a client-side event polling service and PopupText and Banner overlays, hosted by EventOverlayHost and wired into the home page, with supporting config store and API client updates. --- .../components/events/BannerOverlay.svelte | 76 ++++++ .../components/events/EventOverlayHost.svelte | 33 +++ .../components/events/PopupTextOverlay.svelte | 151 +++++++++++ .../lib/components/home-page/home-page.svelte | 235 +++++++++++++++++- .../src/lib/events/event-service.ts | 149 +++++++++++ immichFrame.Web/src/lib/immichFrameApi.ts | 3 + .../src/lib/stores/config.store.ts | 23 +- 7 files changed, 664 insertions(+), 6 deletions(-) create mode 100644 immichFrame.Web/src/lib/components/events/BannerOverlay.svelte create mode 100644 immichFrame.Web/src/lib/components/events/EventOverlayHost.svelte create mode 100644 immichFrame.Web/src/lib/components/events/PopupTextOverlay.svelte create mode 100644 immichFrame.Web/src/lib/events/event-service.ts diff --git a/immichFrame.Web/src/lib/components/events/BannerOverlay.svelte b/immichFrame.Web/src/lib/components/events/BannerOverlay.svelte new file mode 100644 index 00000000..0ad6a69c --- /dev/null +++ b/immichFrame.Web/src/lib/components/events/BannerOverlay.svelte @@ -0,0 +1,76 @@ + + + + + diff --git a/immichFrame.Web/src/lib/components/events/EventOverlayHost.svelte b/immichFrame.Web/src/lib/components/events/EventOverlayHost.svelte new file mode 100644 index 00000000..03d1cb2f --- /dev/null +++ b/immichFrame.Web/src/lib/components/events/EventOverlayHost.svelte @@ -0,0 +1,33 @@ + + +{#if popupEvent} + {#key popupEvent.id} + {#if popupEvent.mode === 'PopupText'} + + {/if} + {/key} +{/if} + +{#if bannerEvent} + {#key bannerEvent.id} + {#if bannerEvent.mode === 'Banner'} + + {/if} + {/key} +{/if} diff --git a/immichFrame.Web/src/lib/components/events/PopupTextOverlay.svelte b/immichFrame.Web/src/lib/components/events/PopupTextOverlay.svelte new file mode 100644 index 00000000..a1f1b24c --- /dev/null +++ b/immichFrame.Web/src/lib/components/events/PopupTextOverlay.svelte @@ -0,0 +1,151 @@ + + + + + + + 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 fa91b97e..14e7e7b9 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -15,6 +15,17 @@ import { page } from '$app/state'; import { ProgressBarLocation, ProgressBarStatus } from '../elements/progress-bar.types'; import { isImageAsset, isVideoAsset } from '$lib/constants/asset-type'; + import { + activePopupEvent, + activeBannerEvent, + acknowledgeEvent, + clearActivePopupEvent, + clearActiveBannerEvent, + startEventPolling, + stopEventPolling + } from '$lib/events/event-service'; + import type { FrameEvent, FrameEventAckStatus } from '$lib/events/event-service'; + import EventOverlayHost from '$lib/components/events/EventOverlayHost.svelte'; interface AssetsState { assets: [string, api.AssetResponseDto, api.AssetFaceResponseDto[], api.AlbumResponseDto[]][]; @@ -73,6 +84,20 @@ let refreshInterval: number; let cursorVisible = $state(true); + const deviceId = $derived.by(() => getCurrentDeviceId()); + let pollingDeviceId: string | null = $state(null); + let hasMounted = $state(false); + let currentPopup: FrameEvent | null = $state(null); + let currentBanner: FrameEvent | null = $state(null); + let lastPopupId: string | null = $state(null); + let lastBannerId: string | null = $state(null); + let popupTimeoutHandle: ReturnType | null = null; + let bannerTimeoutHandle: ReturnType | null = null; + let popupPausedSlideshow = $state(false); + let popupShownAcked = $state(false); + let bannerShownAcked = $state(false); + let unsubscribePopupEvent: (() => void) | undefined; + let unsubscribeBannerEvent: (() => void) | undefined; const clientIdentifier = page.url.searchParams.get('client'); const authsecret = page.url.searchParams.get('authsecret'); @@ -86,6 +111,14 @@ api.init(); } + function getCurrentDeviceId(): string { + const storeId = typeof $clientIdentifierStore === 'string' ? $clientIdentifierStore.trim() : ''; + const queryId = typeof clientIdentifier === 'string' ? clientIdentifier.trim() : ''; + return storeId || queryId || 'default'; + } + + // Event polling is started in onMount after config is loaded + const hideCursor = () => { cursorVisible = false; }; @@ -105,6 +138,161 @@ timeoutId = window.setTimeout(hideCursor, CURSOR_HIDE_MS); }; + function clearPopupTimer() { + if (popupTimeoutHandle) { + clearTimeout(popupTimeoutHandle); + popupTimeoutHandle = null; + } + } + + function clearBannerTimer() { + if (bannerTimeoutHandle) { + clearTimeout(bannerTimeoutHandle); + bannerTimeoutHandle = null; + } + } + + function markPopupShownOnce() { + if (!($configStore.eventHostEnabled ?? false)) return; + if (!currentPopup || popupShownAcked || !deviceId) return; + popupShownAcked = true; + void acknowledgeEvent(deviceId, currentPopup.id, 'Shown'); + } + + function markBannerShownOnce() { + if (!($configStore.eventHostEnabled ?? false)) return; + if (!currentBanner || bannerShownAcked || !deviceId) return; + bannerShownAcked = true; + void acknowledgeEvent(deviceId, currentBanner.id, 'Shown'); + } + + async function dismissPopup(status: FrameEventAckStatus, explicitEvent: FrameEvent | null = null) { + if (!($configStore.eventHostEnabled ?? false)) return; + const target = explicitEvent ?? currentPopup; + if (!target) return; + + clearPopupTimer(); + clearActivePopupEvent(); + if (!deviceId) return; + + try { + await acknowledgeEvent(deviceId, target.id, status); + } catch (error) { + console.error('failed to acknowledge popup event', error); + } + } + + async function dismissBanner(status: FrameEventAckStatus, explicitEvent: FrameEvent | null = null) { + if (!($configStore.eventHostEnabled ?? false)) return; + const target = explicitEvent ?? currentBanner; + if (!target) return; + + clearBannerTimer(); + clearActiveBannerEvent(); + if (!deviceId) return; + + try { + await acknowledgeEvent(deviceId, target.id, status); + } catch (error) { + console.error('failed to acknowledge banner event', error); + } + } + + function resetPopupState() { + clearPopupTimer(); + currentPopup = null; + popupShownAcked = false; + lastPopupId = null; + if (popupPausedSlideshow && progressBar) { + void progressBar.play(); + } + popupPausedSlideshow = false; + } + + function handlePopupEvent(event: FrameEvent | null) { + if (!($configStore.eventHostEnabled ?? false)) { + resetPopupState(); + return; + } + clearPopupTimer(); + + if (!event) { + resetPopupState(); + return; + } + + if (event.mode === 'Close') { + void dismissPopup('Closed', event); + return; + } + + currentPopup = event; + const isNewEvent = event.id !== lastPopupId; + if (isNewEvent) { + lastPopupId = event.id; + popupShownAcked = false; + if (progressBar && progressBarStatus !== ProgressBarStatus.Paused) { + void progressBar.pause(); + popupPausedSlideshow = true; + } else if (progressBarStatus === ProgressBarStatus.Paused) { + popupPausedSlideshow = false; + } + } + + markPopupShownOnce(); + + const fallbackTimeout = $configStore.eventDefaultTimeoutMs ?? 0; + const timeoutMs = event.timeoutMs ?? fallbackTimeout; + if (timeoutMs && timeoutMs > 0) { + popupTimeoutHandle = setTimeout(() => { + void dismissPopup('Timeout'); + }, timeoutMs); + } + } + + function resetBannerState() { + clearBannerTimer(); + currentBanner = null; + bannerShownAcked = false; + lastBannerId = null; + } + + function handleBannerEvent(event: FrameEvent | null) { + if (!($configStore.eventHostEnabled ?? false)) { + resetBannerState(); + return; + } + clearBannerTimer(); + + if (!event) { + resetBannerState(); + return; + } + + if (event.mode === 'Close') { + void dismissBanner('Closed', event); + return; + } + + currentBanner = event; + const isNewEvent = event.id !== lastBannerId; + if (isNewEvent) { + lastBannerId = event.id; + bannerShownAcked = false; + // Banners never pause the slideshow. + } + + markBannerShownOnce(); + + const fallbackTimeout = $configStore.eventDefaultTimeoutMs ?? 0; + const timeoutMs = event.timeoutMs ?? fallbackTimeout; + if (timeoutMs && timeoutMs > 0) { + bannerTimeoutHandle = setTimeout(() => { + void dismissBanner('Timeout'); + }, timeoutMs); + } + } + async function updateAssetPromises() { for (let asset of displayingAssets) { if (!(asset.id in assetPromisesDict)) { @@ -426,6 +614,17 @@ } onMount(() => { + hasMounted = true; + unsubscribePopupEvent = activePopupEvent.subscribe(handlePopupEvent); + unsubscribeBannerEvent = activeBannerEvent.subscribe(handleBannerEvent); + + // Start event polling unconditionally — startEventPolling checks eventHostEnabled internally. + const id = getCurrentDeviceId(); + if (id) { + startEventPolling(id); + pollingDeviceId = id; + } + window.addEventListener('mousemove', showCursor); window.addEventListener('click', showCursor); @@ -469,6 +668,18 @@ window.clearTimeout(timeoutId); window.clearTimeout(videoStallTimeout); window.clearTimeout(watchdogTimer); + if (unsubscribePopupEvent) { + unsubscribePopupEvent(); + unsubscribePopupEvent = undefined; + } + if (unsubscribeBannerEvent) { + unsubscribeBannerEvent(); + unsubscribeBannerEvent = undefined; + } + clearPopupTimer(); + clearBannerTimer(); + stopEventPolling(); + hasMounted = false; }; }); @@ -481,6 +692,19 @@ unsubscribeStop(); } + if (unsubscribePopupEvent) { + unsubscribePopupEvent(); + unsubscribePopupEvent = undefined; + } + if (unsubscribeBannerEvent) { + unsubscribeBannerEvent(); + unsubscribeBannerEvent = undefined; + } + clearPopupTimer(); + clearBannerTimer(); + stopEventPolling(); + hasMounted = false; + const revokes = Object.values(assetPromisesDict).map(async (p) => { try { const [url] = await p; @@ -561,6 +785,15 @@ + {#if $configStore.eventHostEnabled} + + {/if} + { await handleDone(false, true); @@ -597,7 +830,7 @@ }} bind:status={progressBarStatus} bind:infoVisible - overlayVisible={cursorVisible} + overlayVisible={cursorVisible && !currentPopup} /> | null; + actions: FrameEventAction[]; + input: FrameEventInput; + postedAt: string; +} + +const activePopupStore = writable(null); +const activeBannerStore = writable(null); + +export const activePopupEvent = { + subscribe: activePopupStore.subscribe +}; + +export const activeBannerEvent = { + subscribe: activeBannerStore.subscribe +}; + +export function clearActivePopupEvent() { + activePopupStore.set(null); +} + +export function clearActiveBannerEvent() { + activeBannerStore.set(null); +} + +let pollingController: AbortController | null = null; + +export function startEventPolling(deviceId: string) { + stopEventPolling(); + pollingController = new AbortController(); + void pollLoop(deviceId, pollingController); +} + +export function stopEventPolling() { + pollingController?.abort(); + pollingController = null; +} + +async function pollLoop(deviceId: string, controller: AbortController) { + const settings = get(configStore); + const intervalMs = Math.max(500, (settings.eventPollingIntervalSeconds ?? 2) * 1000); + + while (!controller.signal.aborted) { + if (!get(configStore).eventHostEnabled) { + activePopupStore.set(null); + activeBannerStore.set(null); + await delay(intervalMs, controller.signal); + continue; + } + + await Promise.all([ + pollOne(deviceId, 'PopupText', activePopupStore, controller.signal), + pollOne(deviceId, 'Banner', activeBannerStore, controller.signal) + ]); + + await delay(intervalMs, controller.signal); + } +} + +async function pollOne( + deviceId: string, + mode: FrameEventMode, + store: typeof activePopupStore, + signal: AbortSignal +) { + try { + const url = `/api/events/next?deviceId=${encodeURIComponent(deviceId)}&mode=${mode}`; + const response = await fetch(url, { method: 'GET', signal }); + + if (response.status === 200) { + const payload = (await response.json()) as FrameEvent; + store.set(payload); + } else if (response.status === 204) { + store.set(null); + } else if (!response.ok) { + console.error(`event poll failed (mode=${mode}): HTTP ${response.status} ${response.statusText}`); + } + } catch (error) { + if ((error as Error).name !== 'AbortError') { + console.error(`event poll failed (mode=${mode})`, error); + } + } +} + +export async function acknowledgeEvent(deviceId: string, eventId: string, status: FrameEventAckStatus) { + if (!get(configStore).eventHostEnabled) { + return; + } + try { + const response = await fetch(`/api/events/${encodeURIComponent(eventId)}/ack?deviceId=${encodeURIComponent(deviceId)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }) + }); + if (!response.ok) { + console.error(`failed to acknowledge event ${eventId}: HTTP ${response.status} ${response.statusText}`); + } + } catch (error) { + console.error('failed to acknowledge event', error); + } +} + +async function delay(durationMs: number, signal: AbortSignal) { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + + const onAbort = () => { + clearTimeout(timeout); + resolve(); + }; + + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, durationMs); + + signal.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/immichFrame.Web/src/lib/immichFrameApi.ts b/immichFrame.Web/src/lib/immichFrameApi.ts index 85fa762b..cb47acbf 100644 --- a/immichFrame.Web/src/lib/immichFrameApi.ts +++ b/immichFrame.Web/src/lib/immichFrameApi.ts @@ -228,6 +228,9 @@ export type ClientSettingsDto = { playAudio?: boolean; layout?: string | null; language?: string | null; + eventHostEnabled?: boolean; + eventPollingIntervalSeconds?: number; + eventDefaultTimeoutMs?: number; }; export type IWeather = { location?: string | null; diff --git a/immichFrame.Web/src/lib/stores/config.store.ts b/immichFrame.Web/src/lib/stores/config.store.ts index d90cca35..3279298d 100644 --- a/immichFrame.Web/src/lib/stores/config.store.ts +++ b/immichFrame.Web/src/lib/stores/config.store.ts @@ -1,14 +1,27 @@ import type { ClientSettingsDto } from '$lib/immichFrameApi'; import { writable } from 'svelte/store'; -function createConfigStore(settings: ClientSettingsDto) { - const { subscribe, set } = writable(settings); +export type ClientSettingsWithUx = ClientSettingsDto & { + eventPollingIntervalSeconds?: number; + eventDefaultTimeoutMs?: number; + eventHostEnabled?: boolean; +}; + +function createConfigStore(settings: ClientSettingsWithUx) { + const { subscribe, set, update } = writable(settings); function ps(settings: ClientSettingsDto) { - set(settings); + set({ + ...settings, + eventHostEnabled: settings.eventHostEnabled ?? false + } as ClientSettingsWithUx); + } + + function patch(partial: Partial) { + update((current) => ({ ...current, ...partial })); } - return { subscribe, ps } + return { subscribe, ps, patch }; } -export const configStore = createConfigStore({}); \ No newline at end of file +export const configStore = createConfigStore({} as ClientSettingsWithUx); \ No newline at end of file From e5f0a91a0e80b644ae822d017270871c8cc0c8be Mon Sep 17 00:00:00 2001 From: Ryan Gregg Date: Mon, 20 Jul 2026 11:42:25 -0700 Subject: [PATCH 5/5] docs(events): document event notification configuration Document the event notification settings and Banner mode, including a curl example, in the configuration guide. --- docs/docs/getting-started/configuration.md | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/docs/getting-started/configuration.md b/docs/docs/getting-started/configuration.md index 7378c7d1..0228ac8d 100644 --- a/docs/docs/getting-started/configuration.md +++ b/docs/docs/getting-started/configuration.md @@ -176,6 +176,42 @@ A webhook to notify an external service is available. This is only enabled when A client can be identified by the `ClientIdentifier`. You can set/overwrite the `ClientIdentifier` by adding `?client=MyClient` to your ImmichFrame-URL. This only needs to be called once and is persisted. Delete the cache to reset the `ClientIdentifier`. +#### Event Host +ImmichFrame can receive real-time notifications (events) from external services via the `/api/events` endpoint. Enable the event host with the following settings: + +- `EventHostEnabled` — Set to `true` to enable the event host. +- `EventPollingIntervalSeconds` — How often the client polls for new events (default: 2 seconds). Minimum 0.5 seconds. +- `EventDefaultTimeoutMs` — Default timeout for events if `timeoutMs` is not specified (default: 15000 milliseconds). + +##### Posting frame events + +ImmichFrame's event host accepts two notification modes: + +- **PopupText** — full-screen modal that pauses the slideshow until dismissed (or its `timeoutMs` elapses). +- **Banner** — passive top-of-screen notification that does *not* pause the slideshow. Tapping the banner dismisses it. Newer banners with the same `category` replace the older one. + +Example: post a banner via `curl`: + +```bash +curl -X POST http://:8080/api/events \ + -H 'Content-Type: application/json' \ + -d '{ + "deviceId": "", + "id": "calendar-reminder-2026-05-18T14", + "type": "frame.ui.banner", + "mode": "Banner", + "message": "Coffee with Sam in 15 minutes", + "category": "banner.calendar", + "timeoutMs": 8000 + }' +``` + +Both modes share the same `POST /api/events` schema; only `mode` differs. + +:::note +If you have `AuthenticationSecret` set in your Settings, include `Authorization: Bearer ` as a request header — without it the request will be rejected as unauthenticated. +::: + #### Events Events will always contain a `Name`, `ClientIdentifier` and a `DateTime` to differentiate, but can contain more information.