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/Interfaces/IServerSettings.cs b/ImmichFrame.Core/Interfaces/IServerSettings.cs index fea6c442..4760352f 100644 --- a/ImmichFrame.Core/Interfaces/IServerSettings.cs +++ b/ImmichFrame.Core/Interfaces/IServerSettings.cs @@ -66,6 +66,9 @@ public interface IGeneralSettings public bool PlayAudio { get; } public string Layout { get; } public string Language { get; } + public bool EventHostEnabled { get; } + public int EventPollingIntervalSeconds { get; } + public int EventDefaultTimeoutMs { get; } public void Validate(); } 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); + } + } +} 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.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/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/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 ff0f9e75..1c9bf45d 100644 --- a/ImmichFrame.WebApi/Models/ClientSettingsDto.cs +++ b/ImmichFrame.WebApi/Models/ClientSettingsDto.cs @@ -32,6 +32,9 @@ public class ClientSettingsDto public bool PlayAudio { get; set; } public string Layout { get; set; } public string Language { get; set; } + public bool EventHostEnabled { get; set; } + public int EventPollingIntervalSeconds { get; set; } + public int EventDefaultTimeoutMs { get; set; } public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSettings) { @@ -64,6 +67,9 @@ public static ClientSettingsDto FromGeneralSettings(IGeneralSettings generalSett dto.PlayAudio = generalSettings.PlayAudio; dto.Layout = generalSettings.Layout; dto.Language = generalSettings.Language; + dto.EventHostEnabled = generalSettings.EventHostEnabled; + dto.EventPollingIntervalSeconds = generalSettings.EventPollingIntervalSeconds; + dto.EventDefaultTimeoutMs = generalSettings.EventDefaultTimeoutMs; return dto; } } \ No newline at end of file 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/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/ImmichFrame.WebApi/Program.cs b/ImmichFrame.WebApi/Program.cs index f2d68244..85f05379 100644 --- a/ImmichFrame.WebApi/Program.cs +++ b/ImmichFrame.WebApi/Program.cs @@ -1,11 +1,14 @@ 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.Config; +using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); //log the version number @@ -67,6 +70,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 + }; + } +} 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. 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. diff --git a/docs/superpowers/plans/2026-05-18-banner-notifications.md b/docs/superpowers/plans/2026-05-18-banner-notifications.md new file mode 100644 index 00000000..a707eb47 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-banner-notifications.md @@ -0,0 +1,1229 @@ +# Banner Notifications Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `FrameEventMode.Banner` — a top-of-screen passive notification that coexists with `PopupText`, doesn't pause the slideshow, and is dismissable by tap or auto-timeout. End-to-end across server queue, REST API, client polling, and UI. + +**Architecture:** Add a third value to the existing `FrameEventMode` enum. Extend `IFrameEventQueue.PeekNextAsync` with an optional `mode` filter; the in-memory queue tracks an active event per mode so popups and banners don't shadow each other. The frontend splits its single `activeEvent` store into `activePopupEvent` and `activeBannerEvent`, polls both modes each tick, and renders a new `BannerOverlay` alongside the existing `PopupTextOverlay`. Only popups pause the slideshow. + +**Tech Stack:** C# / .NET 8 (WebApi backend), NUnit + Moq (tests), Svelte 5 with runes (frontend), Tailwind CSS, fetch-based polling. + +**Commit hygiene:** No Claude/Anthropic attribution in commit messages. + +**Reference spec:** `docs/superpowers/specs/2026-05-18-banner-notifications-design.md` + +--- + +## Task 1: Add `Banner` value to `FrameEventMode` enum + +**Files:** +- Modify: `ImmichFrame.Core/Events/FrameEventMode.cs` + +- [ ] **Step 1: Add the enum value** + +Open `ImmichFrame.Core/Events/FrameEventMode.cs` and update it to: + +```csharp +using System.Text.Json.Serialization; + +namespace ImmichFrame.Core.Events; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FrameEventMode +{ + PopupText, + Close, + Banner +} +``` + +- [ ] **Step 2: Build to confirm no compile errors** + +Run: `dotnet build ImmichFrame.Core/ImmichFrame.Core.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add ImmichFrame.Core/Events/FrameEventMode.cs +git commit -m "feat(events): add Banner value to FrameEventMode" +``` + +--- + +## Task 2: Extend `IFrameEventQueue.PeekNextAsync` with optional mode filter + +**Files:** +- Modify: `ImmichFrame.Core/Interfaces/IFrameEventQueue.cs` + +- [ ] **Step 1: Update the interface** + +Replace the `PeekNextAsync` signature so callers can optionally request a single mode. The default-parameter form keeps existing callers compiling unchanged. + +```csharp +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); +} +``` + +- [ ] **Step 2: Build (will fail in the queue impl — expected for next task)** + +Run: `dotnet build ImmichFrame.Core/ImmichFrame.Core.csproj` +Expected: Compile error in `InMemoryFrameEventQueue.cs` because its `PeekNextAsync` signature no longer matches the interface. We fix that in Task 3. + +- [ ] **Step 3: Do NOT commit yet** — the tree won't build. Move on to Task 3, then commit together. + +--- + +## Task 3: Per-mode active tracking in `InMemoryFrameEventQueue` (TDD) + +**Files:** +- Test: `ImmichFrame.Core.Tests/Events/InMemoryFrameEventQueueTests.cs` +- Modify: `ImmichFrame.Core/Services/InMemoryFrameEventQueue.cs` + +- [ ] **Step 1: Write the failing tests** + +Open `ImmichFrame.Core.Tests/Events/InMemoryFrameEventQueueTests.cs`. The file already defines a `MakeEvent` helper that defaults `Mode = FrameEventMode.PopupText`. Add an overload that takes a mode, and add the new tests below. + +Add this helper near the existing `MakeEvent` method: + +```csharp +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 + }; +} +``` + +Add these three tests at the bottom of the test class: + +```csharp +[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 PeekNext_NoModeFilter_ReturnsHighestPriorityRegardlessOfMode() +{ + // Higher Priority value sorts first per existing EventEntryComparer. + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.PopupText, "popup-low", priority: 0)); + await _queue.EnqueueAsync(MakeEvent(FrameEventMode.Banner, "banner-high", priority: 10)); + + var top = await _queue.PeekNextAsync("device-1"); + + Assert.That(top, Is.Not.Null); + Assert.That(top!.Id, Is.EqualTo("banner-high")); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ImmichFrame.Core.Tests/ImmichFrame.Core.Tests.csproj --filter "FullyQualifiedName~InMemoryFrameEventQueueTests"` +Expected: The four new tests fail (most likely with a compile error because the new `PeekNextAsync` signature exists on the interface but not on the impl yet — that's the expected red state). + +- [ ] **Step 3: Update `InMemoryFrameEventQueue` to support per-mode active tracking** + +In `ImmichFrame.Core/Services/InMemoryFrameEventQueue.cs`, change the outer `PeekNextAsync` signature and the inner `DeviceQueue.PeekNext` to accept a mode filter, and switch `_activeEventId` from a single value to a per-mode dictionary. + +Replace the outer `PeekNextAsync` method: + +```csharp +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)); +} +``` + +Inside the `DeviceQueue` class: + +1. Replace the field `private string? _activeEventId;` with: + +```csharp +private readonly Dictionary _activeEventIdByMode = new(); +``` + +2. Replace the `PeekNext` method: + +```csharp +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; + } +} +``` + +3. Update `Ack` to clear the per-mode slot for the acked event: + +```csharp +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; + } +} +``` + +4. Update the `Clear` method: + +```csharp +private void Clear() +{ + _entries.Clear(); + _byId.Clear(); + _byCategory.Clear(); + _activeEventIdByMode.Clear(); +} +``` + +Note: `_entries` is a `SortedSet` sorted by `EventEntryComparer` (priority desc, then PostedAt asc, then Id). `FirstOrDefault` walks in sort order, so the filtered branch correctly picks the highest-priority entry of that mode. + +- [ ] **Step 4: Build the whole solution** + +Run: `dotnet build` +Expected: Build succeeded. + +- [ ] **Step 5: Run the new tests to verify they pass** + +Run: `dotnet test ImmichFrame.Core.Tests/ImmichFrame.Core.Tests.csproj --filter "FullyQualifiedName~InMemoryFrameEventQueueTests"` +Expected: All tests pass (existing + 4 new). + +- [ ] **Step 6: Commit** + +```bash +git add ImmichFrame.Core/Interfaces/IFrameEventQueue.cs \ + ImmichFrame.Core/Services/InMemoryFrameEventQueue.cs \ + ImmichFrame.Core.Tests/Events/InMemoryFrameEventQueueTests.cs +git commit -m "feat(events): track active event per mode in queue" +``` + +--- + +## Task 4: Accept `Banner` mode in `FrameEventValidator` (TDD) + +**Files:** +- Test: `ImmichFrame.WebApi.Tests/Events/FrameEventValidatorTests.cs` +- Modify: `ImmichFrame.WebApi/Services/FrameEventValidator.cs` + +- [ ] **Step 1: Write failing tests** + +Open `ImmichFrame.WebApi.Tests/Events/FrameEventValidatorTests.cs`. Reuse the existing `MakeValidPopupText` shape; add a Banner helper and two tests. + +Add a helper near the existing `MakeValidPopupText`: + +```csharp +private static FrameEventRequestDto MakeValidBanner() +{ + return new FrameEventRequestDto + { + DeviceId = "device-1", + Id = "evt-banner-1", + Type = "frame.ui.banner", + Mode = FrameEventMode.Banner, + Message = "banner text" + }; +} +``` + +Add these two tests at the bottom of the test class: + +```csharp +[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); + + // Validator accepts them; the client chooses to ignore Title/Actions for Banner mode. + Assert.That(domain.Title, Is.EqualTo("banner title")); + Assert.That(domain.Actions, Has.Count.EqualTo(1)); +} +``` + +Add `using System.Collections.Generic;` to the test file's `using` block if not already present. + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: `dotnet test ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj --filter "FullyQualifiedName~FrameEventValidatorTests"` +Expected: `Validate_BannerWithMessage_Succeeds` fails with `ValidationException: mode 'Banner' is not supported` (the current default branch rejects it). `Validate_BannerWithoutMessage_Throws` may pass accidentally for the same reason — that's fine, it's still verifying the "no message → throws" property. + +- [ ] **Step 3: Add the `Banner` case to the validator** + +In `ImmichFrame.WebApi/Services/FrameEventValidator.cs`, change the `switch (dto.Mode)` block: + +```csharp +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"); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test ImmichFrame.WebApi.Tests/ImmichFrame.WebApi.Tests.csproj --filter "FullyQualifiedName~FrameEventValidatorTests"` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add ImmichFrame.WebApi/Services/FrameEventValidator.cs \ + ImmichFrame.WebApi.Tests/Events/FrameEventValidatorTests.cs +git commit -m "feat(events): accept Banner mode in validator" +``` + +--- + +## Task 5: Pass `?mode=` query param to queue in `EventsController` + +**Files:** +- Modify: `ImmichFrame.WebApi/Controllers/EventsController.cs` + +- [ ] **Step 1: Update the `GetNext` action to accept and forward `mode`** + +Replace the existing `GetNext` method in `ImmichFrame.WebApi/Controllers/EventsController.cs`: + +```csharp +[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)); +} +``` + +Note: `FrameEventMode` is a string-serialised enum (`[JsonStringEnumConverter]`), and ASP.NET model-binding handles `?mode=PopupText` and `?mode=Banner` case-insensitively out of the box. Invalid values return 400 automatically via `[ApiController]` model validation. + +The `using ImmichFrame.Core.Events;` directive is already present at the top of `EventsController.cs` (via `IFrameEventQueue` types). No additional `using` needed. + +- [ ] **Step 2: Build** + +Run: `dotnet build` +Expected: Build succeeded. + +- [ ] **Step 3: Smoke-test the API locally** + +Start the API: `dotnet run --project ImmichFrame.WebApi/ImmichFrame.WebApi.csproj` (or use your existing dev workflow). + +In another shell: + +```bash +# No mode filter — backwards compatible +curl -s -o /dev/null -w "no-filter: HTTP %{http_code}\n" \ + "http://localhost:8080/api/events/next?deviceId=test" + +# Banner filter +curl -s -o /dev/null -w "banner: HTTP %{http_code}\n" \ + "http://localhost:8080/api/events/next?deviceId=test&mode=Banner" + +# Bad filter — should be 400 +curl -s -o /dev/null -w "bad: HTTP %{http_code}\n" \ + "http://localhost:8080/api/events/next?deviceId=test&mode=Bogus" +``` + +Expected: `no-filter: HTTP 204`, `banner: HTTP 204`, `bad: HTTP 400`. + +- [ ] **Step 4: Commit** + +```bash +git add ImmichFrame.WebApi/Controllers/EventsController.cs +git commit -m "feat(events): support mode filter on GET /api/events/next" +``` + +--- + +## Task 6: Add `Banner` to client `FrameEventMode` union and split active stores + +**Files:** +- Modify: `immichFrame.Web/src/lib/events/event-service.ts` + +- [ ] **Step 1: Update the type union and replace the single store with two** + +Replace the top portion of `immichFrame.Web/src/lib/events/event-service.ts` (lines 1 through approximately 55, up to but not including the `pollLoop` function). Final state of that region: + +```ts +import { get, writable } from 'svelte/store'; +import { configStore } from '$lib/stores/config.store'; + +export type FrameEventMode = 'PopupText' | 'Close' | 'Banner'; + +export type FrameEventAckStatus = 'Shown' | 'Closed' | 'Timeout' | 'Error' | 'Dismissed'; + +export interface FrameEventAction { + id: string; + label: string; + kind?: string | null; +} + +export interface FrameEventInput { + allowTouchDismiss: boolean; + allowKeyboardDismiss: boolean; +} + +export interface FrameEvent { + id: string; + type: string; + mode: FrameEventMode; + message?: string | null; + timeoutMs?: number | null; + priority: number; + category?: string | null; + title?: string | null; + meta?: Record | 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; +} +``` + +Notes: +- `Dismissed` is added to `FrameEventAckStatus` — the banner uses this to differentiate tap dismissal from popup `Closed`. +- The old `activeEvent` / `clearActiveEvent` exports are gone. Consumers (`home-page.svelte`) get updated in Task 9. + +- [ ] **Step 2: Replace `pollLoop` to poll both modes** + +Replace the existing `pollLoop` function with: + +```ts +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); + } + } catch (error) { + if ((error as Error).name !== 'AbortError') { + console.error(`event poll failed (mode=${mode})`, error); + } + } +} +``` + +Leave `acknowledgeEvent` and `delay` exactly as they are. + +- [ ] **Step 3: Type-check the frontend** + +Run: `cd immichFrame.Web && npm run check` +Expected: Errors in `home-page.svelte` and `EventOverlayHost.svelte` referencing the removed `activeEvent` / `clearActiveEvent`. That's expected — fixed in Tasks 8 and 9. + +- [ ] **Step 4: Do NOT commit yet** — frontend still won't type-check until Tasks 7, 8, 9 land. Continue. + +--- + +## Task 7: Create `BannerOverlay.svelte` + +**Files:** +- Create: `immichFrame.Web/src/lib/components/events/BannerOverlay.svelte` + +- [ ] **Step 1: Write the component** + +Create `immichFrame.Web/src/lib/components/events/BannerOverlay.svelte` with this content: + +```svelte + + + + + +``` + +Notes: +- Uses Tailwind utility classes consistent with `PopupTextOverlay.svelte`. +- `z-[160]` sits above `PopupTextOverlay`'s `z-[150]` — visually banners overlay even atop popups (rare overlap; expected). +- `pointer-events-none` on the wrapper and `pointer-events-auto` on the inner button ensures the banner doesn't block clicks on the rest of the page when allowTouchDismiss is true (the only interactive zone is the banner itself). +- Uses a ` + + + 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..9837e4b7 --- /dev/null +++ b/immichFrame.Web/src/lib/components/events/PopupTextOverlay.svelte @@ -0,0 +1,148 @@ + + + + + + + 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..8cb1bf41 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.AlbumResponseDto[]][]; @@ -63,7 +74,21 @@ let refreshInterval: number; let cursorVisible = $state(true); - let timeoutId: number; + let timeoutId: ReturnType; + 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'); @@ -77,6 +102,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; }; @@ -96,6 +129,161 @@ timeoutId = setTimeout(hideCursor, 2000); }; + 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)) { @@ -386,6 +574,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); @@ -426,6 +625,18 @@ window.removeEventListener('mousemove', showCursor); window.removeEventListener('click', showCursor); window.clearInterval(refreshInterval); + if (unsubscribePopupEvent) { + unsubscribePopupEvent(); + unsubscribePopupEvent = undefined; + } + if (unsubscribeBannerEvent) { + unsubscribeBannerEvent(); + unsubscribeBannerEvent = undefined; + } + clearPopupTimer(); + clearBannerTimer(); + stopEventPolling(); + hasMounted = false; }; }); @@ -438,6 +649,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; @@ -488,6 +712,15 @@ + {#if $configStore.eventHostEnabled} + + {/if} + { await handleDone(false, true); @@ -524,7 +757,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 e8dae934..6cd902b4 100644 --- a/immichFrame.Web/src/lib/immichFrameApi.ts +++ b/immichFrame.Web/src/lib/immichFrameApi.ts @@ -214,6 +214,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