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/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.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); + } + } +} 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 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/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 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 + }; + } +} 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/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