From 1d0e759d68b83b67ce9999df5d45c5ac0f69ea6c Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:09:15 -0300 Subject: [PATCH 01/39] Add source refresh on startup if OBS connects --- SubathonManager.Integration/OBSService.cs | 89 ++++++++++++++++++++--- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/SubathonManager.Integration/OBSService.cs b/SubathonManager.Integration/OBSService.cs index c225fd74..76f6e8c7 100644 --- a/SubathonManager.Integration/OBSService.cs +++ b/SubathonManager.Integration/OBSService.cs @@ -38,6 +38,10 @@ public class OBSService : IAppService new(TimeSpan.FromSeconds(3), maxRetries: 1000, maxBackoff: TimeSpan.FromSeconds(10), infiniteRetries: true); public bool Connected => _obs.IsConnected; + private readonly Lock _scriptCheckLock = new(); + private Task? _scriptCheckTask; + private DateTime _lastScriptCheckedAt = DateTime.MinValue; + private static readonly TimeSpan ScriptCheckMinInterval = TimeSpan.FromSeconds(5); private const string HelperHotkeyName = "subathonmanager_apply_tweaks"; private const string HelperVersionHotkeyPrefix = "subathonmanager_version_"; @@ -102,6 +106,8 @@ public Task StopAsync(CancellationToken cancellationToken = default) private volatile bool _stopRequested; + private int _startupRefreshDone; + public void TryConnect() { var host = _config.Get("OBS", "Host", "localhost")!; @@ -150,7 +156,45 @@ private void OnConnected(object? sender, EventArgs e) Status = true }); - _ = Task.Run(CheckHelperScriptAsync); + _ = Task.Run(async () => + { + await CheckHelperScriptAsync(force: true); + await RunStartupBrowserSourceRefreshAsync(); + }); + } + + private async Task RunStartupBrowserSourceRefreshAsync() + { + if (Interlocked.Exchange(ref _startupRefreshDone, 1) != 0) return; + + try + { + await Task.Delay(TimeSpan.FromSeconds(5)); + if (_stopRequested || !_obs.IsConnected) + { + Interlocked.Exchange(ref _startupRefreshDone, 0); + return; + } + + var serverPort = _config.Get("Server", "Port", "14040")!; + var cards = await GetOverlayBrowserSourcesAsync(serverPort); + + int refreshed = 0; + foreach (var sourceName in cards.Select(c => c.SourceName).Distinct(StringComparer.Ordinal)) + { + if (!_obs.IsConnected) break; + RefreshBrowserSource(sourceName); + refreshed++; + await Task.Delay(100); + } + + if (refreshed > 0) + _logger?.LogInformation("[OBSService] Refreshed {Count} overlay browser source(s) on startup", refreshed); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "[OBSService] Startup browser source refresh failed"); + } } private void OnDisconnected(object? sender, ObsDisconnectionInfo e) @@ -161,6 +205,7 @@ private void OnDisconnected(object? sender, ObsDisconnectionInfo e) HelperScriptVersion = null; HelperScriptStatusChanged?.Invoke(false); } + BroadcastHelperScriptStatus(); IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection @@ -252,8 +297,21 @@ public bool SaveConfig(string host, string port, string password, bool forceSave _secureStorage.GetOrDefault(StorageKeys.OBSWebSocketPassword, string.Empty)! ); } - - private async Task CheckHelperScriptAsync() + + private Task CheckHelperScriptAsync(bool force = false) + { + lock (_scriptCheckLock) + { + if (_scriptCheckTask is { IsCompleted: false } inFlight) return inFlight; + if (!force && DateTime.UtcNow - _lastScriptCheckedAt < ScriptCheckMinInterval) + return Task.CompletedTask; + + _scriptCheckTask = Task.Run(RunHelperScriptCheckAsync); + return _scriptCheckTask; + } + } + + private async Task RunHelperScriptCheckAsync() { EnsureScriptFileOnDisk(); await Task.Delay(250); @@ -261,8 +319,9 @@ private async Task CheckHelperScriptAsync() bool active = false; string? version = null; - try - { + bool obsStillStarting = false; + + try { var response = _obs.SendRequest("GetHotkeyList"); var hotkeys = response?["hotkeys"] as JArray; var names = hotkeys?.Select(h => h?.ToString()?.Trim()).ToList(); @@ -272,8 +331,7 @@ private async Task CheckHelperScriptAsync() .FirstOrDefault(n => n != null && n.StartsWith(HelperVersionHotkeyPrefix, StringComparison.Ordinal))? [HelperVersionHotkeyPrefix.Length..]; - if (active) - { + if (active) { if (version != null && !string.Equals(version, ExpectedHelperScriptVersion, StringComparison.Ordinal)) _logger?.LogWarning( "[OBSService] Helper script detected but outdated (v{Loaded} loaded, v{Expected} available). Reload it in OBS Tools -> Scripts", @@ -285,8 +343,7 @@ private async Task CheckHelperScriptAsync() else _logger?.LogInformation("[OBSService] Helper script detected (v{Version})", version); } - else - { + else { _logger?.LogWarning( "[OBSService] Helper script not loaded in OBS ({Count} hotkeys reported). Add it once via OBS Tools -> Scripts: {Path}", hotkeys?.Count ?? -1, ScriptPath); @@ -294,10 +351,18 @@ private async Task CheckHelperScriptAsync() hotkeys != null ? string.Join(", ", hotkeys.Select(h => h?.ToString())) : ""); } } - catch (Exception ex) - { + catch (ErrorResponseException ex) when (ex.ErrorCode == 201) { + obsStillStarting = true; + _logger?.LogDebug(ex, "[OBSService] Waiting for OBS to startup successfully before checking again"); + } + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] Helper script check failed"); } + finally { + lock (_scriptCheckLock) _lastScriptCheckedAt = DateTime.UtcNow; + } + + if (obsStillStarting) return; HelperScriptActive = active; HelperScriptVersion = active ? version : null; @@ -320,7 +385,7 @@ private void BroadcastHelperScriptStatus() public void RecheckHelperScript() { if (!_obs.IsConnected) return; - _ = Task.Run(CheckHelperScriptAsync); + _ = CheckHelperScriptAsync(); } private static string? ReadEmbeddedScriptContent() From 6d777880b745f0a07c145aac4794b44dc34798c5 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:23:26 -0300 Subject: [PATCH 02/39] fix coverage script --- .github/scripts/coverage.ps1 | 2 +- SubathonManager.Core/Utils.cs | 3 +++ SubathonManager.Data/OverlayPorter.cs | 1 + SubathonManager.Server/AspNetContextAdapter.cs | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/scripts/coverage.ps1 b/.github/scripts/coverage.ps1 index 226331d0..00f8d485 100644 --- a/.github/scripts/coverage.ps1 +++ b/.github/scripts/coverage.ps1 @@ -5,7 +5,7 @@ try { Remove-Item -Path "SubathonManager.Tests\TestResults\*" -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path "coverage-local" -Recurse -Force -ErrorAction SilentlyContinue - dotnet test SubathonManager.Tests\SubathonManager.Tests.csproj ` + dotnet test --project SubathonManager.Tests\SubathonManager.Tests.csproj ` --results-directory coverage-local ` --coverage ` --coverage-output-format cobertura ` diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index 1759e031..3e6d54af 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Security.Cryptography; @@ -307,6 +308,8 @@ public void Dispose() } } + + [ExcludeFromCodeCoverage] public static class SingleInstanceHelper { public const int WM_SHOWAPP = 0x0400 + 1; diff --git a/SubathonManager.Data/OverlayPorter.cs b/SubathonManager.Data/OverlayPorter.cs index 54629a6e..1fac1028 100644 --- a/SubathonManager.Data/OverlayPorter.cs +++ b/SubathonManager.Data/OverlayPorter.cs @@ -684,6 +684,7 @@ public static ImportResult Fail(string reason) => new ImportResult { Failed = true, FailReason = reason }; } + [ExcludeFromCodeCoverage] private record PackReference(string PackId, string Version, string Entry, string ZipPath); private class ExportPlan diff --git a/SubathonManager.Server/AspNetContextAdapter.cs b/SubathonManager.Server/AspNetContextAdapter.cs index 5dffe02a..72f73d21 100644 --- a/SubathonManager.Server/AspNetContextAdapter.cs +++ b/SubathonManager.Server/AspNetContextAdapter.cs @@ -66,6 +66,8 @@ private static void AddCorsHeaders(HttpResponse response) } } + +[ExcludeFromCodeCoverage] internal sealed class NoopHostLifetime : IHostLifetime { public Task WaitForStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; From 7ba0f45b3266d63161648ff0c9014b7e039ee0ac Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:26:23 -0300 Subject: [PATCH 03/39] update gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index a700706a..baef5923 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ presets/** !.idea/inspectionProfiles/ !.idea/runConfigurations/ +*cobertura.xml + resources/HONSE/* ## Ignore Visual Studio temporary files, build results, and From efb3d0dec1a5bb8a33007725c36a8d0c081c04ec Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:52:43 -0300 Subject: [PATCH 04/39] add forced timeout for twitch hypetrains, start codestyle enforcement --- SubathonManager.Integration/TwitchService.cs | 722 ++++++++----------- 1 file changed, 301 insertions(+), 421 deletions(-) diff --git a/SubathonManager.Integration/TwitchService.cs b/SubathonManager.Integration/TwitchService.cs index 082cbc16..d84fc742 100644 --- a/SubathonManager.Integration/TwitchService.cs +++ b/SubathonManager.Integration/TwitchService.cs @@ -1,77 +1,132 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; -using TwitchLib.Api; -using TwitchLib.Api.Core.Enums; -using TwitchLib.Client; -using TwitchLib.Client.Models; -using TwitchLib.EventSub.Core.EventArgs.Channel; -using TwitchLib.EventSub.Websockets.Core.Models; -using TwitchLib.EventSub.Websockets; using SubathonManager.Core; -using SubathonManager.Core.Models; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; using SubathonManager.Services; +using TwitchLib.Api; +using TwitchLib.Api.Auth; +using TwitchLib.Api.Core.Enums; +using TwitchLib.Api.Helix.Models.EventSub; +using TwitchLib.Api.Helix.Models.Users.GetUsers; +using TwitchLib.Client; using TwitchLib.Client.Events; +using TwitchLib.Client.Models; +using TwitchLib.EventSub.Core.EventArgs.Channel; using TwitchLib.EventSub.Core.EventArgs.Stream; +using TwitchLib.EventSub.Websockets; using TwitchLib.EventSub.Websockets.Core.EventArgs; +using TwitchLib.EventSub.Websockets.Core.Models; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; -public class TwitchService(ILogger? logger, IConfig config, ISecureStorage secureStorage) - : IDisposable, IAppService -{ +public class TwitchService( + ILogger? logger, + IConfig config, + ISecureStorage secureStorage, + ITimerService? timerService = null) + : IDisposable, IAppService { + private static int _hypeTrainLevel; + + private readonly Utils.ServiceReconnectState _chatReconnect = + new(TimeSpan.FromSeconds(5), 200, TimeSpan.FromMinutes(2)); + + private readonly Utils.ServiceReconnectState _eventSubReconnect = + new(TimeSpan.FromSeconds(2.5), 200, TimeSpan.FromMinutes(5)); + + private readonly TimeSpan _hypeTrainLevelDuration = TimeSpan.FromSeconds(5 * 60 + 15); // 5m + buffer time + internal readonly string _oAuthURl = "https://oauth.subathonmanager.app/auth/twitch/login"; private TwitchAPI? _api; private TwitchClient? _chat; + private bool _disposed; private EventSubWebsocketClient? _eventSub; - private static int _hypeTrainLevel = 0; - private bool _disposed = false; - internal readonly string _oAuthURl = "https://oauth.subathonmanager.app/auth/twitch/login"; - - internal Uri? EventSubUrl = null; - + private volatile bool _isConnected; + private DateTime _lastChatDisconnectLog = DateTime.MinValue; - private volatile bool _isConnected = false; - - private readonly Utils.ServiceReconnectState _chatReconnect = - new(TimeSpan.FromSeconds(5), maxRetries: 200, maxBackoff: TimeSpan.FromMinutes(2)); - private readonly Utils.ServiceReconnectState _eventSubReconnect = - new(TimeSpan.FromSeconds(2.5), maxRetries: 200, maxBackoff: TimeSpan.FromMinutes(5)); + internal Uri? EventSubUrl = null; + internal string? Login = string.Empty; + + internal Action OpenBrowser = + url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); public string? UserName { get; private set; } = string.Empty; - internal string? Login = string.Empty; private string? UserId { get; set; } private string? AccessToken => secureStorage.GetOrDefault(StorageKeys.TwitchAccessToken, string.Empty); - internal Action OpenBrowser = url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + [ExcludeFromCodeCoverage] + public async Task StartAsync(CancellationToken ct = default) { + if (HasTokenFile()) { + bool tokenValid = await ValidateTokenAsync(); + if (!tokenValid) { + RevokeTokenFile(); + logger?.LogWarning("Twitch token expired - deleting token file"); + } + else { + logger?.LogInformation("Twitch Service starting up..."); + await InitializeAsync(ct); + } + } + } - public bool HasTokenFile() - { - return secureStorage.Exists(StorageKeys.TwitchAccessToken) && + public async Task StopAsync(CancellationToken ct = default) { + // api has no disconnect? + OnTeardown(); + if (_chat != null) await _chat.DisconnectAsync(); + if (_eventSub != null) await _eventSub.DisconnectAsync(); + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.Twitch, + Service = "API", + Name = UserName ?? "", + Status = false, + Configured = HasTokenFile() + }); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.Twitch, + Service = "EventSub", + Name = UserName ?? "", + Status = false, + Configured = HasTokenFile() + }); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.Twitch, + Service = "Chat", + Name = UserName ?? "", + Status = false, + Configured = HasTokenFile() + }); + } + + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + public bool HasTokenFile() { + return secureStorage.Exists(StorageKeys.TwitchAccessToken) && !string.IsNullOrWhiteSpace(AccessToken); } - public void RevokeTokenFile() - { + public void RevokeTokenFile() { secureStorage.Delete(StorageKeys.TwitchAccessToken); } - public async Task ValidateTokenAsync() - { + public async Task ValidateTokenAsync() { if (!HasTokenFile()) return false; var api = new TwitchAPI(); - try - { - var validation = await api.Auth.ValidateAccessTokenAsync(AccessToken); + try { + ValidateAccessTokenResponse? validation = await api.Auth.ValidateAccessTokenAsync(AccessToken); if (validation.ClientId != Config.TwitchClientId) return false; @@ -79,50 +134,23 @@ public async Task ValidateTokenAsync() logger?.LogInformation($"Twitch Token Valid for Scopes: {string.Join(',', validation.Scopes)}"); return true; } - catch (HttpRequestException ex) - { + catch (HttpRequestException ex) { logger?.LogError(ex, "Could not validate token. Internet connection may be down."); - // return true so we don't delete file return true; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, "Twitch Token Validation Error"); ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), "Twitch Token could not be validated", DateTime.Now.ToLocalTime()); return false; } } - - [ExcludeFromCodeCoverage] - public async Task StartAsync(CancellationToken ct = default) - { - if (HasTokenFile()) - { - var tokenValid = await ValidateTokenAsync(); - if (!tokenValid) - { - RevokeTokenFile(); - logger?.LogWarning("Twitch token expired - deleting token file"); - } - else - { - logger?.LogInformation("Twitch Service starting up..."); - await InitializeAsync(ct); - } - } - } - + [ExcludeFromCodeCoverage] - public async Task InitializeAsync(CancellationToken ct = default) - { - if (string.IsNullOrEmpty(AccessToken)) - { - await StartOAuthFlowAsync(); - } + public async Task InitializeAsync(CancellationToken ct = default) { + if (string.IsNullOrEmpty(AccessToken)) await StartOAuthFlowAsync(); - try - { + try { await InitializeApiAsync(); logger?.LogDebug("Twitch Initialized API"); await InitializeChatAsync(); @@ -130,78 +158,65 @@ public async Task InitializeAsync(CancellationToken ct = default) await InitializeEventSubAsync(); logger?.LogDebug("Twitch Initialized EventSub"); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, "TwitchService Initialization Error"); ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), $"Error initializing Twitch Service: {ex.Message}. " + $"Please try reconnecting twitch or restarting the application", DateTime.Now); } } - - private async Task StartOAuthFlowAsync() - { + + private async Task StartOAuthFlowAsync() { Utils.PendingOAuthCallback = null; logger?.LogDebug("Opening Twitch OAuth..."); OpenBrowser(_oAuthURl); - var token = await WaitForProtocolCallbackAsync(); - if (!string.IsNullOrWhiteSpace(token)) - { - secureStorage.Set(StorageKeys.TwitchAccessToken, token); - } + string? token = await WaitForProtocolCallbackAsync(); + if (!string.IsNullOrWhiteSpace(token)) secureStorage.Set(StorageKeys.TwitchAccessToken, token); } - - private async Task WaitForProtocolCallbackAsync(CancellationToken ct = default) - { - var timeout = DateTime.Now.AddMinutes(15); - while (DateTime.Now < timeout && !ct.IsCancellationRequested) - { - var cb = Utils.PendingOAuthCallback; - if (cb?.Provider == "twitch" && !string.IsNullOrEmpty(cb.AccessToken)) - { + + private async Task WaitForProtocolCallbackAsync(CancellationToken ct = default) { + DateTime timeout = DateTime.Now.AddMinutes(15); + while (DateTime.Now < timeout && !ct.IsCancellationRequested) { + OAuthCallback? cb = Utils.PendingOAuthCallback; + if (cb?.Provider == "twitch" && !string.IsNullOrEmpty(cb.AccessToken)) { logger?.LogInformation("Twitch OAuth Callback received"); - var token = cb.AccessToken; + string? token = cb.AccessToken; Utils.PendingOAuthCallback = null; return token; } + await Task.Delay(250, ct); } + return null; } - + [ExcludeFromCodeCoverage] - private async Task InitializeApiAsync() - { - _api = new TwitchAPI - { - Settings = - { + private async Task InitializeApiAsync() { + _api = new TwitchAPI { + Settings = { ClientId = Config.TwitchClientId, AccessToken = AccessToken } }; - var user = (await _api.Helix.Users.GetUsersAsync()).Users.FirstOrDefault(); - if (user != null) - { + User? user = (await _api.Helix.Users.GetUsersAsync()).Users.FirstOrDefault(); + if (user != null) { UserName = user.DisplayName; Login = user.Login; UserId = user.Id; logger?.LogDebug($"Authenticated as {UserName}"); - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "API", Name = UserName!, Status = true }); } - else - { - Login = string.Empty; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + else { + Login = string.Empty; + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "API", Name = "", @@ -211,29 +226,25 @@ private async Task InitializeApiAsync() } } - + [ExcludeFromCodeCoverage] - private async Task InitializeChatAsync() - { + private async Task InitializeChatAsync() { _chatReconnect.Reset(); var credentials = new ConnectionCredentials(UserName!, $"oauth:{AccessToken}"); _chat = new TwitchClient(); - + _chat.OnMessageReceived += HandleMessageCmdReceived; _chat.OnDisconnected += HandleChatDisconnect; _chat.OnReconnected += HandleChatReconnect; _chat.OnConnected += HandleChatConnect; - - try - { - _chat.Initialize(credentials, channel: UserName); + + try { + _chat.Initialize(credentials, UserName); logger?.LogDebug("[Twitch] Authenticated Chat as {UserName}", UserName); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, ex.Message); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, @@ -241,14 +252,12 @@ private async Task InitializeChatAsync() Configured = HasTokenFile() }); } - + await _chat.ConnectAsync(); } - private Task HandleChatConnect(object? sender, OnConnectedEventArgs e) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private Task HandleChatConnect(object? sender, OnConnectedEventArgs e) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, @@ -258,14 +267,11 @@ private Task HandleChatConnect(object? sender, OnConnectedEventArgs e) } [ExcludeFromCodeCoverage] - private Task HandleChatDisconnect(object? sender, OnDisconnectedArgs onDisconnectedArgs) - { - if ((DateTime.Now - _lastChatDisconnectLog).TotalSeconds > 60) - { + private Task HandleChatDisconnect(object? sender, OnDisconnectedArgs onDisconnectedArgs) { + if ((DateTime.Now - _lastChatDisconnectLog).TotalSeconds > 60) { logger?.LogWarning("Twitch Chat Disconnected. Attempting Reconnect..."); - _lastChatDisconnectLog = DateTime.Now; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + _lastChatDisconnectLog = DateTime.Now; + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, @@ -273,46 +279,41 @@ private Task HandleChatDisconnect(object? sender, OnDisconnectedArgs onDisconnec Configured = HasTokenFile() }); } + _ = Task.Run(TryReconnectChatAsync); return Task.CompletedTask; } - - + + [ExcludeFromCodeCoverage] - private async Task TryReconnectChatAsync() - { + private async Task TryReconnectChatAsync() { if (_chat == null) return; if (!await _chatReconnect.Lock.WaitAsync(0)) return; - - try - { + + try { _chatReconnect.Cts?.Cancel(); _chatReconnect.Cts = new CancellationTokenSource(); - var token = _chatReconnect.Cts.Token; - + CancellationToken token = _chatReconnect.Cts.Token; + - while (!token.IsCancellationRequested && _chat.IsConnected == false) - { + while (!token.IsCancellationRequested && !_chat.IsConnected) { _chatReconnect.Retries++; - var delay = _chatReconnect.Backoff; + TimeSpan delay = _chatReconnect.Backoff; logger?.LogDebug( "[Twitch Chat] Reconnect attempt {Attempt} in {Delay}s", _chatReconnect.Retries, delay.TotalSeconds); - - try - { + + try { await Task.Delay(delay, token); - if (_chat.IsConnected) - { + if (_chat.IsConnected) { logger?.LogDebug("Twitch Chat reconnect successful."); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, @@ -323,12 +324,10 @@ private async Task TryReconnectChatAsync() await _chat.ReconnectAsync(); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "Twitch Chat reconnect failed"); } @@ -338,21 +337,18 @@ private async Task TryReconnectChatAsync() _chatReconnect.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _chatReconnect.Lock.Release(); } } [ExcludeFromCodeCoverage] - private Task HandleChatReconnect(object? _, OnConnectedEventArgs onConnectedEventArgs) - { + private Task HandleChatReconnect(object? _, OnConnectedEventArgs onConnectedEventArgs) { logger?.LogInformation("Twitch Chat Reconnected"); _chatReconnect.Cts?.Cancel(); _chatReconnect.Reset(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, @@ -360,36 +356,30 @@ private Task HandleChatReconnect(object? _, OnConnectedEventArgs onConnectedEven }); return Task.CompletedTask; } - - - private Task HandleMessageCmdReceived(object? s, OnMessageReceivedArgs e) - { - if (!e.ChatMessage.Channel.Equals(Login, StringComparison.InvariantCultureIgnoreCase) && + + + private Task HandleMessageCmdReceived(object? s, OnMessageReceivedArgs e) { + if (!e.ChatMessage.Channel.Equals(Login, StringComparison.InvariantCultureIgnoreCase) && !e.ChatMessage.Channel.Equals(UserName, StringComparison.InvariantCultureIgnoreCase)) return Task.CompletedTask; - + string message = e.ChatMessage.Message; bool isMod = e.ChatMessage.UserDetail.IsModerator; bool isBroadcaster = e.ChatMessage.IsBroadcaster; bool isVip = e.ChatMessage.UserDetail.IsVip; if (!string.IsNullOrWhiteSpace(message) && message.StartsWith('!')) - { CommandService.ChatCommandRequest(SubathonEventSource.Twitch, message, e.ChatMessage.Username, // DisplayName isBroadcaster, isMod, isVip, DateTime.Now); - } else if (e.ChatMessage.DisplayName.Equals("blerp", StringComparison.InvariantCultureIgnoreCase) && config.GetBool("Extensions", "Blerp.Enabled", true)) - { BlerpChatService.ParseMessage(e.ChatMessage.Message, SubathonEventSource.Twitch); - } return Task.CompletedTask; } - - private async Task InitializeEventSubAsync() - { + + private async Task InitializeEventSubAsync() { _eventSubReconnect.Reset(); _eventSub = new EventSubWebsocketClient(); @@ -412,23 +402,19 @@ private async Task InitializeEventSubAsync() await _eventSub.ConnectAsync(EventSubUrl); } - - private bool IsEventSubConnected() - { + + private bool IsEventSubConnected() { return _eventSub != null && !string.IsNullOrEmpty(_eventSub.SessionId) && _isConnected; } - - private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) - { - bool hasError = false; + + private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) { + var hasError = false; logger?.LogInformation("Connected to EventSub WebSocket, session ID: " - + _eventSub?.SessionId + ", isReconnect: " + e.IsRequestedReconnect); - if (!e.IsRequestedReconnect) - { + + _eventSub?.SessionId + ", isReconnect: " + e.IsRequestedReconnect); + if (!e.IsRequestedReconnect) { // todo allow override from local in case of deprecation, thanks twitch - var eventTypes = new[] - { + var eventTypes = new[] { "stream.offline", "stream.online", "channel.follow", @@ -444,26 +430,21 @@ private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) "channel.charity_campaign.donate" }; - foreach (var type in eventTypes) - { - try - { - - var condition = new Dictionary - { + foreach (string type in eventTypes) + try { + var condition = new Dictionary { { "broadcaster_user_id", UserId! }, { "to_broadcaster_user_id", UserId! }, { "moderator_user_id", UserId! }, { "user_id", UserId! } }; if (_api == null) continue; - var x = await _api.Helix.EventSub.CreateEventSubSubscriptionAsync(type, + CreateEventSubSubscriptionResponse? x = await _api.Helix.EventSub.CreateEventSubSubscriptionAsync( + type, type.Contains("follow") || type.Contains("hype_train") ? "2" : "1", condition, EventSubTransportMethod.Websocket, _eventSub?.SessionId, clientId: Config.TwitchClientId, accessToken: AccessToken); - } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, $"Failed to subscribe to {type}: {ex.Message}"); ErrorMessageEvents.RaiseErrorEvent( "ERROR", @@ -473,18 +454,16 @@ private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) RevokeTokenFile(); hasError = true; } - } } + _isConnected = !hasError; - if (_isConnected) - { + if (_isConnected) { _eventSubReconnect.Cts?.Cancel(); _eventSubReconnect.Reset(); } - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "EventSub", Name = UserName!, @@ -495,8 +474,7 @@ private async Task HandleEventSubConnect(object? s, WebsocketConnectedArgs e) [ExcludeFromCodeCoverage] - private Task HandleEventSubReconnect(object? s, WebsocketReconnectedArgs e) - { + private Task HandleEventSubReconnect(object? s, WebsocketReconnectedArgs e) { logger?.LogInformation("Reconnected EventSub WebSocket."); if (_eventSubReconnect.Retries >= 1) ErrorMessageEvents.RaiseErrorEvent("INFO", nameof(SubathonEventSource.Twitch), @@ -505,30 +483,25 @@ private Task HandleEventSubReconnect(object? s, WebsocketReconnectedArgs e) _eventSubReconnect.Reset(); _isConnected = true; if (_chat is { IsConnected: true }) - { // eventsub disconnect can false-disconnect chat sometimes. - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "Chat", Name = UserName!, Status = true }); - } return Task.CompletedTask; } - + [ExcludeFromCodeCoverage] - private Task HandleEventSubDisconnect(object? s, WebsocketDisconnectedArgs e) - { + private Task HandleEventSubDisconnect(object? s, WebsocketDisconnectedArgs e) { logger?.LogWarning("Disconnected EventSub WebSocket."); - + ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventSource.Twitch), "Twitch EventSub has disconnected", DateTime.Now.ToLocalTime()); - + _isConnected = false; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Twitch, Service = "EventSub", Name = UserName!, @@ -538,27 +511,23 @@ private Task HandleEventSubDisconnect(object? s, WebsocketDisconnectedArgs e) _ = Task.Run(TryReconnectEventSubAsync); return Task.CompletedTask; } - + [ExcludeFromCodeCoverage] - private async Task TryReconnectEventSubAsync() - { + private async Task TryReconnectEventSubAsync() { if (_eventSub == null) return; if (!await _eventSubReconnect.Lock.WaitAsync(0)) return; - - try - { + + try { _eventSubReconnect.Cts?.Cancel(); _eventSubReconnect.Cts = new CancellationTokenSource(); - var token = _eventSubReconnect.Cts.Token; + CancellationToken token = _eventSubReconnect.Cts.Token; - while (!token.IsCancellationRequested && !IsEventSubConnected()) - { + while (!token.IsCancellationRequested && !IsEventSubConnected()) { if (_eventSubReconnect.MaxRetries > 0 && - _eventSubReconnect.Retries >= _eventSubReconnect.MaxRetries) - { + _eventSubReconnect.Retries >= _eventSubReconnect.MaxRetries) { ErrorMessageEvents.RaiseErrorEvent( "ERROR", nameof(SubathonEventSource.Twitch), @@ -569,26 +538,25 @@ private async Task TryReconnectEventSubAsync() return; } - if (!await ValidateTokenAsync()) - { + if (!await ValidateTokenAsync()) { logger?.LogError("EventSub reconnect aborted: Twitch token invalid."); ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), - "Twitch EventSub could not be reconnected - Twitch Token is invalid", DateTime.Now.ToLocalTime()); + "Twitch EventSub could not be reconnected - Twitch Token is invalid", + DateTime.Now.ToLocalTime()); RevokeTokenFile(); return; } _eventSubReconnect.Retries++; - var delay = _eventSubReconnect.Backoff; + TimeSpan delay = _eventSubReconnect.Backoff; logger?.LogWarning( "[Twitch EventSub] Reconnect attempt {Attempt} in {Delay}s", _eventSubReconnect.Retries, delay.TotalSeconds); - try - { + try { await Task.Delay(delay, token); if (IsEventSubConnected()) @@ -596,12 +564,10 @@ private async Task TryReconnectEventSubAsync() await _eventSub.ReconnectAsync(); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "EventSub reconnect failed"); } @@ -611,19 +577,15 @@ private async Task TryReconnectEventSubAsync() _eventSubReconnect.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _eventSubReconnect.Lock.Release(); } } - private Task HandleChannelOnline(object? s, StreamOnlineArgs e) - { - if (config.GetBool("Twitch", "ResumeOnStart", false)) - { - SubathonEvent subathonEvent = new SubathonEvent - { + private Task HandleChannelOnline(object? s, StreamOnlineArgs e) { + if (config.GetBool("Twitch", "ResumeOnStart")) { + var subathonEvent = new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = SubathonCommandType.Resume, Value = $"{SubathonCommandType.Resume}", @@ -636,10 +598,8 @@ private Task HandleChannelOnline(object? s, StreamOnlineArgs e) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - if (config.GetBool("Twitch", "UnlockOnStart", false)) - { - SubathonEvent subathonEvent = new SubathonEvent - { + if (config.GetBool("Twitch", "UnlockOnStart")) { + var subathonEvent = new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = SubathonCommandType.Unlock, Value = $"{SubathonCommandType.Unlock}", @@ -655,12 +615,9 @@ private Task HandleChannelOnline(object? s, StreamOnlineArgs e) return Task.CompletedTask; } - private Task HandleChannelOffline(object? s, StreamOfflineArgs e) - { - if (config.GetBool("Twitch", "PauseOnEnd", false)) - { - SubathonEvent subathonEvent = new SubathonEvent - { + private Task HandleChannelOffline(object? s, StreamOfflineArgs e) { + if (config.GetBool("Twitch", "PauseOnEnd")) { + var subathonEvent = new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = SubathonCommandType.Pause, Value = $"{SubathonCommandType.Pause}", @@ -673,10 +630,8 @@ private Task HandleChannelOffline(object? s, StreamOfflineArgs e) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - if (config.GetBool("Twitch", "LockOnEnd", false)) - { - SubathonEvent subathonEvent = new SubathonEvent - { + if (config.GetBool("Twitch", "LockOnEnd")) { + var subathonEvent = new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = SubathonCommandType.Lock, Value = $"{SubathonCommandType.Lock}", @@ -691,14 +646,12 @@ private Task HandleChannelOffline(object? s, StreamOfflineArgs e) return Task.CompletedTask; } - - private Task HandleChannelFollow(object? s, ChannelFollowArgs e) - { + + private Task HandleChannelFollow(object? s, ChannelFollowArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchFollow, @@ -710,17 +663,15 @@ private Task HandleChannelFollow(object? s, ChannelFollowArgs e) return Task.CompletedTask; } - - private Task HandleSubGift(object? s, ChannelSubscriptionGiftArgs e) - { + + private Task HandleSubGift(object? s, ChannelSubscriptionGiftArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - var user = e.Payload.Event.UserName; + string? user = e.Payload.Event.UserName; if (e.Payload.Event.IsAnonymous || string.IsNullOrWhiteSpace(user)) user = "Anonymous"; - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, Currency = "sub", @@ -734,16 +685,14 @@ private Task HandleSubGift(object? s, ChannelSubscriptionGiftArgs e) return Task.CompletedTask; } - private Task HandleChannelSubscribe(object? s, ChannelSubscribeArgs e) - { + private Task HandleChannelSubscribe(object? s, ChannelSubscribeArgs e) { if (e.Payload.Event.IsGift) return Task.CompletedTask; var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, Currency = "sub", @@ -756,16 +705,14 @@ private Task HandleChannelSubscribe(object? s, ChannelSubscribeArgs e) return Task.CompletedTask; } - - private Task HandleSubscriptionMsg(object? s, ChannelSubscriptionMessageArgs e) - { + + private Task HandleSubscriptionMsg(object? s, ChannelSubscriptionMessageArgs e) { // int duration = e.Payload.Event.DurationMonths; // Do we want to take this into account and multiply? - no, people can reshare and it is read in var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, Currency = "sub", @@ -777,16 +724,14 @@ private Task HandleSubscriptionMsg(object? s, ChannelSubscriptionMessageArgs e) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return Task.CompletedTask; } - - private Task HandleBitsUse(object? s, ChannelBitsUseArgs e) - { + + private Task HandleBitsUse(object? s, ChannelBitsUseArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); - var user = e.Payload.Event.UserName; + Guid.TryParse(eventMeta!.MessageId, out Guid mId); + string user = e.Payload.Event.UserName; if (string.IsNullOrWhiteSpace(user)) user = "Anonymous"; if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchCheer, @@ -797,21 +742,17 @@ private Task HandleBitsUse(object? s, ChannelBitsUseArgs e) }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); if (e.Payload.Event.Type.ToLower() != "cheer") - { logger?.LogInformation($"TwitchCheer Event {subathonEvent.Id} " + - $"source was: {e.Payload.Event.Type} {e.Payload.Event.PowerUp?.Type}"); - } + $"source was: {e.Payload.Event.Type} {e.Payload.Event.PowerUp?.Type}"); return Task.CompletedTask; } - private Task HandleChannelRaid(object? s, ChannelRaidArgs e) - { + private Task HandleChannelRaid(object? s, ChannelRaidArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchRaid, @@ -823,13 +764,15 @@ private Task HandleChannelRaid(object? s, ChannelRaidArgs e) return Task.CompletedTask; } - private Task HandleHypeTrainBeginV2(object? s, ChannelHypeTrainBeginV2Args e) - { + private void ClearHypeTrainTimeout() { + SimulateHypeTrainEnd(_hypeTrainLevel); + } + + private Task HandleHypeTrainBeginV2(object? s, ChannelHypeTrainBeginV2Args e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchHypeTrain, @@ -840,18 +783,17 @@ private Task HandleHypeTrainBeginV2(object? s, ChannelHypeTrainBeginV2Args e) }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); _hypeTrainLevel = 1; + timerService?.Register("twitch-hype-train", _hypeTrainLevelDuration, ClearHypeTrainTimeout); return Task.CompletedTask; } - - private Task HandleHypeTrainProgressV2(object? s, ChannelHypeTrainProgressV2Args e) - { + + private Task HandleHypeTrainProgressV2(object? s, ChannelHypeTrainProgressV2Args e) { if (e.Payload.Event.Level <= _hypeTrainLevel) return Task.CompletedTask; var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchHypeTrain, @@ -862,16 +804,15 @@ private Task HandleHypeTrainProgressV2(object? s, ChannelHypeTrainProgressV2Args }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); _hypeTrainLevel = subathonEvent.Amount; + timerService?.Register("twitch-hype-train", _hypeTrainLevelDuration, ClearHypeTrainTimeout); return Task.CompletedTask; } - - private Task HandleHypeTrainEndV2(object? s, ChannelHypeTrainEndV2Args e) - { + + private Task HandleHypeTrainEndV2(object? s, ChannelHypeTrainEndV2Args e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchHypeTrain, @@ -882,22 +823,22 @@ private Task HandleHypeTrainEndV2(object? s, ChannelHypeTrainEndV2Args e) }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); _hypeTrainLevel = 0; + + timerService?.Unregister("twitch-hype-train"); return Task.CompletedTask; } - - private Task HandleCharityEvent(object? s, ChannelCharityCampaignDonateArgs e ) - { + + private Task HandleCharityEvent(object? s, ChannelCharityCampaignDonateArgs e) { var eventMeta = e.Metadata as WebsocketEventSubMetadata; - Guid.TryParse(eventMeta!.MessageId, out var mId); + Guid.TryParse(eventMeta!.MessageId, out Guid mId); if (mId == Guid.Empty) mId = Guid.NewGuid(); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = mId, Source = SubathonEventSource.Twitch, EventType = SubathonEventType.TwitchCharityDonation, User = e.Payload.Event.UserName, Value = Math.Round( - e.Payload.Event.Amount.Value + e.Payload.Event.Amount.Value / (decimal)Math.Pow(10, e.Payload.Event.Amount.DecimalPlaces), 2 ).ToString("0.00"), @@ -908,43 +849,8 @@ private Task HandleCharityEvent(object? s, ChannelCharityCampaignDonateArgs e ) return Task.CompletedTask; } - public async Task StopAsync(CancellationToken ct = default) - { - // api has no disconnect? - OnTeardown(); - if (_chat != null) await _chat.DisconnectAsync(); - if (_eventSub != null) await _eventSub.DisconnectAsync(); - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { - Source = SubathonEventSource.Twitch, - Service = "API", - Name = UserName ?? "", - Status = false, - Configured = HasTokenFile() - }); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { - Source = SubathonEventSource.Twitch, - Service = "EventSub", - Name = UserName ?? "", - Status = false, - Configured = HasTokenFile() - }); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { - Source = SubathonEventSource.Twitch, - Service = "Chat", - Name = UserName ?? "", - Status = false, - Configured = HasTokenFile() - }); - } - - public static void SimulateRaid(int viewers=50) - { - SubathonEvent subathonEvent = new SubathonEvent - { + public static void SimulateRaid(int viewers = 50) { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchRaid, User = "SYSTEM", @@ -952,11 +858,9 @@ public static void SimulateRaid(int viewers=50) }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateCheer(int bitsCount=100) - { - SubathonEvent subathonEvent = new SubathonEvent - { + + public static void SimulateCheer(int bitsCount = 100) { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchCheer, User = "SYSTEM", @@ -966,12 +870,10 @@ public static void SimulateCheer(int bitsCount=100) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public static void SimulateSubscription(string tier) - { + public static void SimulateSubscription(string tier) { if (tier != "1000" && tier != "2000" && tier != "3000") return; - - SubathonEvent subathonEvent = new SubathonEvent - { + + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, Currency = "sub", EventType = SubathonEventType.TwitchSub, @@ -982,12 +884,10 @@ public static void SimulateSubscription(string tier) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public static void SimulateGiftSubscriptions(string tier, int amount) - { + public static void SimulateGiftSubscriptions(string tier, int amount) { if (tier != "1000" && tier != "2000" && tier != "3000") return; - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, Currency = "sub", EventType = SubathonEventType.TwitchGiftSub, @@ -997,105 +897,86 @@ public static void SimulateGiftSubscriptions(string tier, int amount) Amount = amount }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); - } - public static void SimulateFollow() - { - SubathonEvent subathonEvent = new SubathonEvent - { + } + + public static void SimulateFollow() { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchFollow, - User = "SYSTEM", + User = "SYSTEM" }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public static void SimulateCharityDonation(string value = "10.00", string currency = "USD") - { - SubathonEvent subathonEvent = new SubathonEvent - { + + public static void SimulateCharityDonation(string value = "10.00", string currency = "USD") { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchCharityDonation, Value = value, Currency = currency, - User = "SYSTEM", + User = "SYSTEM" }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateHypeTrainStart() - { - SubathonEvent subathonEvent = new SubathonEvent - { + + public static void SimulateHypeTrainStart() { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchHypeTrain, Value = "start", Amount = 1, - User = "SYSTEM", + User = "SYSTEM" }; _hypeTrainLevel = 1; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateHypeTrainProgress(int level = 7) - { + + public static void SimulateHypeTrainProgress(int level = 7) { if (level <= _hypeTrainLevel) return; - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchHypeTrain, Value = "progress", Amount = level, - User = "SYSTEM", + User = "SYSTEM" }; _hypeTrainLevel = level; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateHypeTrainEnd(int level = 10) - { - SubathonEvent subathonEvent = new SubathonEvent - { + + public static void SimulateHypeTrainEnd(int level = 10) { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TwitchHypeTrain, Value = "end", Amount = level, - User = "SYSTEM", + User = "SYSTEM" }; _hypeTrainLevel = 0; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { + protected virtual void Dispose(bool disposing) { + if (!_disposed) { + if (disposing) { _chatReconnect.Dispose(); _eventSubReconnect.Dispose(); OnTeardown(); } + _disposed = true; } } - - private void OnTeardown() - { - if (_chat != null) - { + + private void OnTeardown() { + if (_chat != null) { _chat.OnMessageReceived -= HandleMessageCmdReceived; _chat.OnDisconnected -= HandleChatDisconnect; _chat.OnReconnected -= HandleChatReconnect; _chat.OnConnected -= HandleChatConnect; } - if (_eventSub != null) - { + if (_eventSub != null) { _eventSub.WebsocketConnected -= HandleEventSubConnect; _eventSub.WebsocketReconnected -= HandleEventSubReconnect; _eventSub.WebsocketDisconnected -= HandleEventSubDisconnect; @@ -1114,5 +995,4 @@ private void OnTeardown() _eventSub.ChannelCharityCampaignDonate -= HandleCharityEvent; } } -} - +} \ No newline at end of file From 912ef2fc934baaae54b6a12ef60d063ba3e308db Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:58:09 -0300 Subject: [PATCH 05/39] stage some formatting changes --- .gitignore | 1 + SubathonManager.Core/Enums/EnumExtensions.cs | 99 +++--- SubathonManager.Core/Enums/GoalsType.cs | 3 +- .../Enums/MakeShipProductType.cs | 5 +- SubathonManager.Core/Enums/OrderTypeModes.cs | 3 +- .../Enums/ProtocolMessageType.cs | 3 +- .../Enums/SubathonCommandType.cs | 90 ++--- .../Enums/SubathonEventSource.cs | 141 +++++--- .../Enums/SubathonEventSubType.cs | 61 ++-- .../Enums/SubathonEventType.cs | 191 ++++++----- .../Enums/SubathonPromptType.cs | 159 +++++---- .../Enums/SubathonSourceGroup.cs | 34 +- .../Enums/WebsocketClientMessageType.cs | 20 +- SubathonManager.Core/Enums/WheelSpinEnums.cs | 50 +-- SubathonManager.Core/Enums/WidgetType.cs | 34 +- .../Enums/WidgetVariableType.cs | 183 +++++----- .../Events/ErrorMessageEvents.cs | 13 +- .../Events/IntegrationEvents.cs | 12 +- SubathonManager.Core/Events/OverlayEvents.cs | 20 +- SubathonManager.Core/Events/SettingsEvents.cs | 17 +- SubathonManager.Core/Events/SubathonEvents.cs | 107 +++--- SubathonManager.Core/Events/TimerEvents.cs | 9 +- .../Events/WebServerEvents.cs | 14 +- SubathonManager.Core/Events/WheelEvents.cs | 44 +-- SubathonManager.Core/Events/WidgetEvents.cs | 27 +- .../Interfaces/IAppService.cs | 4 +- SubathonManager.Core/Interfaces/IConfig.cs | 20 +- .../Interfaces/ITimerService.cs | 5 +- .../Interfaces/IWebhookIntegration.cs | 8 +- .../Interfaces/IWidgetFileSystem.cs | 40 ++- SubathonManager.Core/Models/GoAffProStore.cs | 8 +- SubathonManager.Core/Models/JuniperStore.cs | 29 +- .../Models/MakeShipTracking.cs | 12 +- SubathonManager.Core/Models/MultiplierData.cs | 26 +- SubathonManager.Core/Models/Route.cs | 27 +- SubathonManager.Core/Models/StateValue.cs | 10 +- SubathonManager.Core/Models/SubathonData.cs | 52 ++- SubathonManager.Core/Models/SubathonEvent.cs | 72 ++-- SubathonManager.Core/Models/SubathonGoal.cs | 16 +- .../Models/SubathonGoalSet.cs | 7 +- SubathonManager.Core/Models/SubathonPrompt.cs | 55 +-- .../Models/SubathonPromptSet.cs | 23 +- SubathonManager.Core/Models/SubathonValue.cs | 66 ++-- .../Models/WheelSpinTrigger.cs | 16 +- SubathonManager.Core/Models/Wheels.cs | 50 +-- SubathonManager.Core/Models/Widget.cs | 312 ++++++++---------- .../Models/WidgetCatalogEntry.cs | 8 +- .../Objects/IntegrationConnection.cs | 7 +- SubathonManager.Core/Objects/OAuthCallback.cs | 3 +- .../Objects/SubathonTotals.cs | 6 +- .../Objects/SubscriptionTotals.cs | 8 +- SubathonManager.Core/Objects/WidgetMeta.cs | 18 +- .../Security/AesFileSecureStorage.cs | 114 +++---- .../Security/DpapiSecureStorage.cs | 94 +++--- .../Security/Interfaces/ISecureStorage.cs | 5 +- .../Security/SignatureVerification.cs | 32 +- SubathonManager.Core/Security/StorageKeys.cs | 11 +- 57 files changed, 1246 insertions(+), 1258 deletions(-) diff --git a/.gitignore b/.gitignore index baef5923..9feeb1ff 100644 --- a/.gitignore +++ b/.gitignore @@ -229,6 +229,7 @@ obj TestResults/ coverage-report/ +coverage-local/ # Stream Deck plugin packaging stuff *.streamDeckPlugin diff --git a/SubathonManager.Core/Enums/EnumExtensions.cs b/SubathonManager.Core/Enums/EnumExtensions.cs index dd2da853..f7965de2 100644 --- a/SubathonManager.Core/Enums/EnumExtensions.cs +++ b/SubathonManager.Core/Enums/EnumExtensions.cs @@ -1,57 +1,49 @@ -using System.Reflection; -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Reflection; // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Core.Enums; -public static class EnumExtensions -{ - public static string GetDescription(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); +public static class EnumExtensions { + public static string GetDescription(this Enum value) { + FieldInfo? field = value.GetType().GetField(value.ToString()); if (field == null) return value.ToString(); var attr = field.GetCustomAttribute(); return attr?.Description ?? value.ToString(); } - - public static string GetLabel(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); + + public static string GetLabel(this Enum value) { + FieldInfo? field = value.GetType().GetField(value.ToString()); if (field == null) return value.ToString(); var attr = field.GetCustomAttribute(); return attr?.Label ?? value.ToString(); } - - public static bool IsDisabled(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); + + public static bool IsDisabled(this Enum value) { + FieldInfo? field = value.GetType().GetField(value.ToString()); if (field == null) return true; var attr = field.GetCustomAttribute(); return !attr?.Enabled ?? true; - } - - public static bool IsEnabled(this Enum value) - { + } + + public static bool IsEnabled(this Enum value) { return !IsDisabled(value); } - - public static int GetOrderNumber(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); + + public static int GetOrderNumber(this Enum value) { + FieldInfo? field = value.GetType().GetField(value.ToString()); if (field == null) return 99999; var attr = field.GetCustomAttribute(); return attr?.Order ?? 99999; - } - + } } [AttributeUsage(AttributeTargets.Field)] -public class EnumMetaAttribute : Attribute -{ +public class EnumMetaAttribute : Attribute { public virtual string? Description { get; set; } = ""; public virtual string? Label { get; init; } public int Order { get; init; } @@ -59,11 +51,13 @@ public class EnumMetaAttribute : Attribute public bool Enabled { get; init; } = true; } -public class EventSourceMetaAttribute : EnumMetaAttribute -{ - public override string? Label => SourceGroup is (SubathonSourceGroup.UseSource or SubathonSourceGroup.Unknown) ? ToString() : SourceGroup.GetLabel(); +public class EventSourceMetaAttribute : EnumMetaAttribute { + public override string? Label => SourceGroup is SubathonSourceGroup.UseSource or SubathonSourceGroup.Unknown + ? ToString() + : SourceGroup.GetLabel(); + public SubathonSourceGroup SourceGroup { get; init; } = SubathonSourceGroup.UseSource; - + public bool Visible { get; init; } = true; public SubathonEventSource TrueSource { get; init; } = SubathonEventSource.Unknown; public int SourceOrder { get; init; } = 99999; @@ -71,23 +65,25 @@ public class EventSourceMetaAttribute : EnumMetaAttribute public bool IsExternalSource { get; init; } = false; } -public class EventTypeMetaAttribute : EnumMetaAttribute -{ - public override string? Description => Source is not (SubathonEventSource.Command or SubathonEventSource.Unknown) ? $"{Source.ToString()} {Label}".Trim( ): Label; +public class EventTypeMetaAttribute : EnumMetaAttribute { + public override string? Description => Source is not (SubathonEventSource.Command or SubathonEventSource.Unknown) + ? $"{Source.ToString()} {Label}".Trim() + : Label; + public bool IsCurrencyDonation { get; init; } public bool IsGift { get; init; } public bool IsMembership { get; init; } public bool IsSubscription { get; init; } public bool IsSubscriptionLike => IsSubscription || IsGift || IsMembership; public bool IsExternal { get; init; } - + public bool IsExtension { get; init; } public bool IsToken { get; init; } public bool IsRaid { get; init; } public bool IsTrain { get; init; } public bool IsFollow { get; init; } public bool IsOrder { get; init; } - + public bool IsGenericEvent { get; init; } public bool IsCommand { get; init; } public bool IsOther { get; init; } @@ -97,43 +93,36 @@ public class EventTypeMetaAttribute : EnumMetaAttribute } [ExcludeFromCodeCoverage] -public class GoAffProTypeMetaAttribute : EventTypeMetaAttribute -{ - public override string? Description => Label; +public class GoAffProTypeMetaAttribute : EventTypeMetaAttribute { + public override string? Description => Label; + // maintaining for legacy data public int LegacySiteId { get; init; } = -1; } -public class CommandMetaAttribute : EnumMetaAttribute -{ +public class CommandMetaAttribute : EnumMetaAttribute { public bool RequiresParameter { get; init; } public bool IsControlType { get; init; } } [ExcludeFromCodeCoverage] -public class WheelSpinActionMetaAttribute : EnumMetaAttribute -{ +public class WheelSpinActionMetaAttribute : EnumMetaAttribute { public bool IsCommand { get; init; } = false; public bool HasAction { get; init; } = true; public bool IsDoneImmediately { get; init; } = false; } - -public static class EnumMetaCache -{ +public static class EnumMetaCache { private static readonly ConcurrentDictionary> Cache = new(); - public static T? Get(Enum value) where T : EnumMetaAttribute - { - var type = value.GetType(); + public static T? Get(Enum value) where T : EnumMetaAttribute { + Type type = value.GetType(); - var map = Cache.GetOrAdd(type, t => - { + Dictionary map = Cache.GetOrAdd(type, t => { var dict = new Dictionary(); - foreach (var field in t.GetFields(BindingFlags.Public | BindingFlags.Static)) - { - var enumValue = field.GetValue(null)!; + foreach (FieldInfo field in t.GetFields(BindingFlags.Public | BindingFlags.Static)) { + object enumValue = field.GetValue(null)!; var attr2 = field.GetCustomAttribute(); dict[enumValue] = attr2; @@ -142,7 +131,7 @@ public static class EnumMetaCache return dict; }); - if (!map.TryGetValue(value, out var attr)) + if (!map.TryGetValue(value, out EnumMetaAttribute? attr)) return null; return attr as T; diff --git a/SubathonManager.Core/Enums/GoalsType.cs b/SubathonManager.Core/Enums/GoalsType.cs index 82146207..dabcf89a 100644 --- a/SubathonManager.Core/Enums/GoalsType.cs +++ b/SubathonManager.Core/Enums/GoalsType.cs @@ -1,7 +1,6 @@ namespace SubathonManager.Core.Enums; -public enum GoalsType -{ +public enum GoalsType { Points, Money } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/MakeShipProductType.cs b/SubathonManager.Core/Enums/MakeShipProductType.cs index bc3412d1..5f362971 100644 --- a/SubathonManager.Core/Enums/MakeShipProductType.cs +++ b/SubathonManager.Core/Enums/MakeShipProductType.cs @@ -1,9 +1,8 @@ namespace SubathonManager.Core.Enums; -public enum MakeShipProductType -{ +public enum MakeShipProductType { Unknown, Petition, Campaign, Invalid // bad url usually -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/OrderTypeModes.cs b/SubathonManager.Core/Enums/OrderTypeModes.cs index 5e59da2c..99355437 100644 --- a/SubathonManager.Core/Enums/OrderTypeModes.cs +++ b/SubathonManager.Core/Enums/OrderTypeModes.cs @@ -1,7 +1,6 @@ namespace SubathonManager.Core.Enums; -public enum OrderTypeModes -{ +public enum OrderTypeModes { Item, Order, Dollar diff --git a/SubathonManager.Core/Enums/ProtocolMessageType.cs b/SubathonManager.Core/Enums/ProtocolMessageType.cs index b52fd98b..60ec1f9f 100644 --- a/SubathonManager.Core/Enums/ProtocolMessageType.cs +++ b/SubathonManager.Core/Enums/ProtocolMessageType.cs @@ -1,7 +1,6 @@ namespace SubathonManager.Core.Enums; -public enum ProtocolMessageType -{ +public enum ProtocolMessageType { Unknown, OAuth, SmoFile diff --git a/SubathonManager.Core/Enums/SubathonCommandType.cs b/SubathonManager.Core/Enums/SubathonCommandType.cs index 889ff538..3748e3cf 100644 --- a/SubathonManager.Core/Enums/SubathonCommandType.cs +++ b/SubathonManager.Core/Enums/SubathonCommandType.cs @@ -1,66 +1,80 @@ -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Enums; -public enum SubathonCommandType -{ - [CommandMeta(Description="Add Points", RequiresParameter = true)] +public enum SubathonCommandType { + [CommandMeta(Description = "Add Points", RequiresParameter = true)] AddPoints, - [CommandMeta(Description="Remove Points", RequiresParameter = true)] + + [CommandMeta(Description = "Remove Points", RequiresParameter = true)] SubtractPoints, - [CommandMeta(Description="Set Points", RequiresParameter = true, IsControlType = true)] + + [CommandMeta(Description = "Set Points", RequiresParameter = true, IsControlType = true)] SetPoints, - [CommandMeta(Description="Add Time", RequiresParameter = true)] + + [CommandMeta(Description = "Add Time", RequiresParameter = true)] AddTime, - [CommandMeta(Description="Remove Time", RequiresParameter = true)] + + [CommandMeta(Description = "Remove Time", RequiresParameter = true)] SubtractTime, - [CommandMeta(Description="Set Time", RequiresParameter = true, IsControlType = true)] + + [CommandMeta(Description = "Set Time", RequiresParameter = true, IsControlType = true)] SetTime, - [CommandMeta(Description="Lock", IsControlType = true)] + + [CommandMeta(Description = "Lock", IsControlType = true)] Lock, - [CommandMeta(Description="Unlock", IsControlType = true)] + + [CommandMeta(Description = "Unlock", IsControlType = true)] Unlock, - [CommandMeta(Description="Pause Timer", IsControlType = true)] + + [CommandMeta(Description = "Pause Timer", IsControlType = true)] Pause, - [CommandMeta(Description="Resume Timer", IsControlType = true)] + + [CommandMeta(Description = "Resume Timer", IsControlType = true)] Resume, - [CommandMeta(Description="Set Multiplier", RequiresParameter = true, IsControlType = true)] + + [CommandMeta(Description = "Set Multiplier", RequiresParameter = true, IsControlType = true)] SetMultiplier, - [CommandMeta(Description="Stop Multiplier", IsControlType = true)] + + [CommandMeta(Description = "Stop Multiplier", IsControlType = true)] StopMultiplier, - [CommandMeta(Description="None")] - None, - [CommandMeta(Description="Unknown")] - Unknown, - [CommandMeta(Description="Refresh Overlays", IsControlType = true)] + [CommandMeta(Description = "None")] None, + [CommandMeta(Description = "Unknown")] Unknown, + + [CommandMeta(Description = "Refresh Overlays", IsControlType = true)] RefreshOverlays, - [CommandMeta(Description="Add Money", RequiresParameter = true)] + + [CommandMeta(Description = "Add Money", RequiresParameter = true)] AddMoney, - [CommandMeta(Description="Remove Money", RequiresParameter = true)] + + [CommandMeta(Description = "Remove Money", RequiresParameter = true)] SubtractMoney, - [CommandMeta(Description="Set Wheel Spins", RequiresParameter = true)] + + [CommandMeta(Description = "Set Wheel Spins", RequiresParameter = true)] SetSpins, - [CommandMeta(Description="Add Wheel Spins", RequiresParameter = true)] + + [CommandMeta(Description = "Add Wheel Spins", RequiresParameter = true)] AddSpins, - [CommandMeta(Description="Subtract Wheel Spins", RequiresParameter = true)] + + [CommandMeta(Description = "Subtract Wheel Spins", RequiresParameter = true)] SubtractSpins, - [CommandMeta(Description="Spin the Wheel", IsControlType = true)] - SpinWheel, + + [CommandMeta(Description = "Spin the Wheel", IsControlType = true)] + SpinWheel } [ExcludeFromCodeCoverage] -public static class SubathonCommandTypeHelper -{ - private static CommandMetaAttribute? Meta(this SubathonCommandType value) - { +public static class SubathonCommandTypeHelper { + private static CommandMetaAttribute? Meta(this SubathonCommandType value) { var meta = EnumMetaCache.Get(value); return meta; } - public static bool IsParametersRequired(this SubathonCommandType command) => - command.Meta()?.RequiresParameter ?? false; - - public static bool IsControlTypeCommand(this SubathonCommandType command) => - command.Meta()?.IsControlType ?? false; - + public static bool IsParametersRequired(this SubathonCommandType command) { + return command.Meta()?.RequiresParameter ?? false; + } + + public static bool IsControlTypeCommand(this SubathonCommandType command) { + return command.Meta()?.IsControlType ?? false; + } } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonEventSource.cs b/SubathonManager.Core/Enums/SubathonEventSource.cs index 75a8d921..37b60ded 100644 --- a/SubathonManager.Core/Enums/SubathonEventSource.cs +++ b/SubathonManager.Core/Enums/SubathonEventSource.cs @@ -1,84 +1,137 @@ -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; namespace SubathonManager.Core.Enums; -public enum SubathonEventSource -{ +public enum SubathonEventSource { // some may not actually be event sources in the future, but also integration sources - [EventSourceMeta(Description = "Twitch", SourceGroup = SubathonSourceGroup.Stream, SourceOrder=1, Order=10)] + [EventSourceMeta(Description = "Twitch", SourceGroup = SubathonSourceGroup.Stream, SourceOrder = 1, Order = 10)] Twitch, - [EventSourceMeta(Description = "StreamElements", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=21, Order=20)] + + [EventSourceMeta(Description = "StreamElements", SourceGroup = SubathonSourceGroup.StreamExtension, + SourceOrder = 21, Order = 20)] StreamElements, - [EventSourceMeta(Description = "KoFi", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=41, Order=40)] + + [EventSourceMeta(Description = "KoFi", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 41, + Order = 40)] KoFi, - [EventSourceMeta(Description = "YouTube", SourceGroup = SubathonSourceGroup.Stream, SourceOrder=2, Order=11)] + + [EventSourceMeta(Description = "YouTube", SourceGroup = SubathonSourceGroup.Stream, SourceOrder = 2, Order = 11)] YouTube, - [EventSourceMeta(Description = "Commands", SourceGroup = SubathonSourceGroup.Misc, SourceOrder=2000, Order=100)] + + [EventSourceMeta(Description = "Commands", SourceGroup = SubathonSourceGroup.Misc, SourceOrder = 2000, Order = 100)] Command, // can be from any chat - [EventSourceMeta(Description = "Simulated", SourceOrder=8000, Order=199)] + + [EventSourceMeta(Description = "Simulated", SourceOrder = 8000, Order = 199)] Simulated, // buttons to test in UI? - [EventSourceMeta(Description = "Unknown", SourceGroup=SubathonSourceGroup.Misc, SourceOrder=9000, Order=101)] + + [EventSourceMeta(Description = "Unknown", SourceGroup = SubathonSourceGroup.Misc, SourceOrder = 9000, Order = 101)] Unknown, // default - [EventSourceMeta(Description = "StreamLabs", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=22, Order=21)] + + [EventSourceMeta(Description = "StreamLabs", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder = 22, + Order = 21)] StreamLabs, - [EventSourceMeta(Description = "Generic External Services", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=1000, Order=99, IsExternalSource = true)] + + [EventSourceMeta(Description = "Generic External Services", SourceGroup = SubathonSourceGroup.ExternalService, + SourceOrder = 1000, Order = 99, IsExternalSource = true)] External, - [EventSourceMeta(Description = "Blerp", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=81, Order=30, IsExternalSource = true)] + + [EventSourceMeta(Description = "Blerp", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder = 81, + Order = 30, IsExternalSource = true)] Blerp, - [EventSourceMeta(Description = "Picarto", SourceGroup = SubathonSourceGroup.Stream, SourceOrder=3, Order=12)] + + [EventSourceMeta(Description = "Picarto", SourceGroup = SubathonSourceGroup.Stream, SourceOrder = 3, Order = 12)] Picarto, - [EventSourceMeta(Description = "GoAffPro Affiliate Stores", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=61, Order=50, Visible = true)] + + [EventSourceMeta(Description = "GoAffPro Affiliate Stores", SourceGroup = SubathonSourceGroup.ExternalService, + SourceOrder = 61, Order = 50, Visible = true)] GoAffPro, - [EventSourceMeta(Description = "Ko-Fi (Tunnel)", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=42, Visible=false, TrueSource=KoFi, Order=41)] + + [EventSourceMeta(Description = "Ko-Fi (Tunnel)", SourceGroup = SubathonSourceGroup.ExternalService, + SourceOrder = 42, Visible = false, TrueSource = KoFi, Order = 41)] KoFiTunnel, - [EventSourceMeta(Description = "Dev Tunnels", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder=904, Visible=false, Order=903)] + + [EventSourceMeta(Description = "Dev Tunnels", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 904, + Visible = false, Order = 903)] DevTunnels, - [EventSourceMeta(Description="FourthWall", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=62, Order=51)] + + [EventSourceMeta(Description = "FourthWall", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 62, + Order = 51)] FourthWall, - [EventSourceMeta(Description="OBS", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder=901, Order=900, Visible = false)] + + [EventSourceMeta(Description = "OBS", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 901, + Order = 900, Visible = false)] OBS, - [EventSourceMeta(Description="Throne", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=63, Order=52)] + + [EventSourceMeta(Description = "Throne", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 63, + Order = 52)] Throne, - [EventSourceMeta(Description="TipeeeStream", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=23, Order=22)] + + [EventSourceMeta(Description = "TipeeeStream", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder = 23, + Order = 22)] TipeeeStream, - [EventSourceMeta(Description="Wheel Spin", Visible = false, Order = 990, SourceGroup = SubathonSourceGroup.WheelSpin)] + + [EventSourceMeta(Description = "Wheel Spin", Visible = false, Order = 990, + SourceGroup = SubathonSourceGroup.WheelSpin)] WheelSpin, - [EventSourceMeta(Description="Tangia", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=82, Order=31)] + + [EventSourceMeta(Description = "Tangia", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder = 82, + Order = 31)] Tangia, - [EventSourceMeta(Description="Stream Deck", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder=903, Order=902, Visible = false, IsExternalSource = true)] + + [EventSourceMeta(Description = "Stream Deck", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 903, + Order = 902, Visible = false, IsExternalSource = true)] StreamDeck, - [EventSourceMeta(Description="StreamerBot", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder=902, Order=901, Visible = false, IsExternalSource = true)] + + [EventSourceMeta(Description = "StreamerBot", SourceGroup = SubathonSourceGroup.ExternalSoftware, SourceOrder = 902, + Order = 901, Visible = false, IsExternalSource = true)] StreamerBot, - [EventSourceMeta(Description="Pally.GG", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=64, Order=53)] + + [EventSourceMeta(Description = "Pally.GG", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 64, + Order = 53)] PallyGG, - [EventSourceMeta(Description="TreatStream", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder=24, Order=23)] + + [EventSourceMeta(Description = "TreatStream", SourceGroup = SubathonSourceGroup.StreamExtension, SourceOrder = 24, + Order = 23)] TreatStream, - [EventSourceMeta(Description="MakeShip", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=65, Order=54)] + + [EventSourceMeta(Description = "MakeShip", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 65, + Order = 54)] MakeShip, - [EventSourceMeta(Description="Juniper Creates", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder=66, Order=55, Visible = true)] + + [EventSourceMeta(Description = "Juniper Creates", SourceGroup = SubathonSourceGroup.ExternalService, + SourceOrder = 66, Order = 55, Visible = true)] JuniperCreates // like goaffpro, do lookup on product id to find store name as source } [ExcludeFromCodeCoverage] -public static class SubathonEventSourceHelper -{ - public static int GetSourceOrder(SubathonEventSource source) => ((SubathonEventSource?)source).Meta()?.SourceOrder ?? 99999; - - private static EventSourceMetaAttribute? Meta(this SubathonEventSource? value) - { +public static class SubathonEventSourceHelper { + public static int GetSourceOrder(SubathonEventSource source) { + return ((SubathonEventSource?)source).Meta()?.SourceOrder ?? 99999; + } + + private static EventSourceMetaAttribute? Meta(this SubathonEventSource? value) { if (!value.HasValue) return null; var meta = EnumMetaCache.Get(value); return meta; } - - public static bool IsExternalSource(this SubathonEventSource? source) => source.Meta()?.IsExternalSource ?? false; - public static bool IsExternalSource(this SubathonEventSource source) => IsExternalSource((SubathonEventSource?)source); - public static SubathonSourceGroup GetGroup(this SubathonEventSource source) => - ((SubathonEventSource?)source).Meta()?.SourceGroup ?? SubathonSourceGroup.Unknown; + public static bool IsExternalSource(this SubathonEventSource? source) { + return source.Meta()?.IsExternalSource ?? false; + } + + public static bool IsExternalSource(this SubathonEventSource source) { + return IsExternalSource((SubathonEventSource?)source); + } + + public static SubathonSourceGroup GetGroup(this SubathonEventSource source) { + return ((SubathonEventSource?)source).Meta()?.SourceGroup ?? SubathonSourceGroup.Unknown; + } + + public static string GetGroupLabel(this SubathonEventSource source) { + return ((SubathonEventSource?)source).Meta()?.Label ?? source.ToString(); + } - public static string GetGroupLabel(this SubathonEventSource source) => ((SubathonEventSource?)source).Meta()?.Label ?? source.ToString(); - - public static int GetGroupLabelOrder(this SubathonEventSource source) => ((SubathonEventSource?)source).Meta()?.Order ?? 99999; + public static int GetGroupLabelOrder(this SubathonEventSource source) { + return ((SubathonEventSource?)source).Meta()?.Order ?? 99999; + } } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonEventSubType.cs b/SubathonManager.Core/Enums/SubathonEventSubType.cs index 981a24c2..457ed90e 100644 --- a/SubathonManager.Core/Enums/SubathonEventSubType.cs +++ b/SubathonManager.Core/Enums/SubathonEventSubType.cs @@ -1,61 +1,74 @@ -namespace SubathonManager.Core.Enums; +using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.CodeAnalysis; -public enum SubathonEventSubType -{ - [EnumMeta(Description="Unknown",Label="Unknown", Order = 200)] +namespace SubathonManager.Core.Enums; + +public enum SubathonEventSubType { + [EnumMeta(Description = "Unknown", Label = "Unknown", Order = 200)] Unknown, - [EnumMeta(Description="Subscriptions", Label="Subscriptions", Order=1)] + + [EnumMeta(Description = "Subscriptions", Label = "Subscriptions", Order = 1)] SubLike, - [EnumMeta(Description="Gift Subscriptions", Label="Gift Subscriptions", Order=2)] + + [EnumMeta(Description = "Gift Subscriptions", Label = "Gift Subscriptions", Order = 2)] GiftSubLike, - [EnumMeta(Description="Donations", Label="Donations", Order=4)] + + [EnumMeta(Description = "Donations", Label = "Donations", Order = 4)] DonationLike, - [EnumMeta(Description="Tokens/Bits", Label="Tokens/Bits", Order=3)] + + [EnumMeta(Description = "Tokens/Bits", Label = "Tokens/Bits", Order = 3)] TokenLike, - [EnumMeta(Description="Follows", Label="Follows", Order=5)] + + [EnumMeta(Description = "Follows", Label = "Follows", Order = 5)] FollowLike, - [EnumMeta(Description="Raids", Label="Raids" , Order=6)] + + [EnumMeta(Description = "Raids", Label = "Raids", Order = 6)] RaidLike, - [EnumMeta(Description="Hype Trains", Label="Hype Trains", Order = 7)] + + [EnumMeta(Description = "Hype Trains", Label = "Hype Trains", Order = 7)] TrainLike, - [EnumMeta(Description="Commands", Label="Commands", Order = 100)] + + [EnumMeta(Description = "Commands", Label = "Commands", Order = 100)] CommandLike, - [EnumMeta(Description="Store Orders", Label="Store Orders", Order=8)] + + [EnumMeta(Description = "Store Orders", Label = "Store Orders", Order = 8)] OrderLike, - [EnumMeta(Description="Generic Events", Label="Generic Events", Order=9)] - EventLike + + [EnumMeta(Description = "Generic Events", Label = "Generic Events", Order = 9)] + EventLike } [ExcludeFromCodeCoverage] -public static class SubathonEventSubTypeHelper -{ +public static class SubathonEventSubTypeHelper { public static readonly List OrderEventTypes = Enum.GetValues() .Where(e => e.GetSubType() == SubathonEventSubType.OrderLike && e.IsEnabled()) .ToList(); + public static readonly List FollowEventTypes = Enum.GetValues() .Where(e => e.GetSubType() == SubathonEventSubType.FollowLike && e.IsEnabled()) .ToList(); + public static readonly List SubEventTypes = Enum.GetValues() .Where(e => e.GetSubType() is SubathonEventSubType.SubLike or SubathonEventSubType.GiftSubLike && e.IsEnabled()) .ToList(); + public static readonly List TokenEventTypes = Enum.GetValues() .Where(e => e.GetSubType() == SubathonEventSubType.TokenLike && e.IsEnabled()) .ToList(); + public static readonly List DonationEventTypes = Enum.GetValues() .Where(e => e.GetSubType() == SubathonEventSubType.DonationLike && e.IsEnabled()) .ToList(); + public static readonly List GenericEventTypes = Enum.GetValues() .Where(e => e.GetSubType() == SubathonEventSubType.EventLike && e.IsEnabled()) .ToList(); - private static readonly SubathonEventSubType[] NotTrueEvent = - [ + private static readonly SubathonEventSubType[] NotTrueEvent = [ SubathonEventSubType.CommandLike, SubathonEventSubType.Unknown ]; - - public static bool IsTrueEvent(this SubathonEventSubType? eventType) => - eventType.HasValue && !NotTrueEvent.Contains(eventType.Value); - + + public static bool IsTrueEvent(this SubathonEventSubType? eventType) { + return eventType.HasValue && !NotTrueEvent.Contains(eventType.Value); + } } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonEventType.cs b/SubathonManager.Core/Enums/SubathonEventType.cs index 6a105e38..e82f7f7e 100644 --- a/SubathonManager.Core/Enums/SubathonEventType.cs +++ b/SubathonManager.Core/Enums/SubathonEventType.cs @@ -1,8 +1,9 @@ using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Models; + namespace SubathonManager.Core.Enums; -public enum SubathonEventType -{ +public enum SubathonEventType { [EventTypeMeta(Label = "Subscription", Source = SubathonEventSource.Twitch, IsSubscription = true, Order = 1)] TwitchSub, // remember subs can be of Value: 1000, 2000, 3000, Prime iirc... damnit looks like the TwitchLib doesnt separate Prime?? @@ -94,6 +95,7 @@ public enum SubathonEventType [GoAffProTypeMeta(Label = "GamerSupps Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 1, LegacySiteId = 165328, Enabled = false)] GamerSuppsOrder, + [Obsolete] [GoAffProTypeMeta(Label = "UwUMarket Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 2, LegacySiteId = 132230, Enabled = false)] @@ -102,6 +104,7 @@ public enum SubathonEventType [GoAffProTypeMeta(Label = "Orchid Eight Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 3, LegacySiteId = 7142837, Enabled = false)] OrchidEightOrder, + [Obsolete] [GoAffProTypeMeta(Label = "KatDragonz Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 4, LegacySiteId = 7160049, Enabled = false)] @@ -134,10 +137,12 @@ public enum SubathonEventType [EventTypeMeta(Label = "Gift Order", Source = SubathonEventSource.FourthWall, IsOrder = true, IsExternal = true, Order = 4)] FourthWallGiftOrder, + [Obsolete] [GoAffProTypeMeta(Label = "Cheeky Soap Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 5, LegacySiteId = 7138531, Enabled = false)] CheekySoapOrder, + [Obsolete] [GoAffProTypeMeta(Label = "Advanced GG Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 6, LegacySiteId = 105752, Enabled = false)] @@ -157,33 +162,40 @@ public enum SubathonEventType IsGenericEvent = true)] // will come in if contribution fully funds, so purely an alert, not event to add. So we do not allow configuration of it ThroneCrowdGiftComplete, // only config in settings is whether or not to display it + [Obsolete] [GoAffProTypeMeta(Label = "Rogue Energy Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 7, LegacySiteId = 7014645, Enabled = false)] RogueEnergyOrder, + [Obsolete] [GoAffProTypeMeta(Label = "Saucy Biz Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 8, LegacySiteId = 7118656, Enabled = false)] SaucyBizOrder, - + // dynamic GoAffPro order type, meta is site id - [GoAffProTypeMeta(Label="GoAffPro Order", Source=SubathonEventSource.GoAffPro, IsOrder=true, Order=1, Enabled=true)] + [GoAffProTypeMeta(Label = "GoAffPro Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 1, + Enabled = true)] GoAffProOrder, + [Obsolete] [GoAffProTypeMeta(Label = "GFuel Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 9, LegacySiteId = 48808, Enabled = false)] GFuelOrder, + [Obsolete] [GoAffProTypeMeta(Label = "Natura Pine Order", Source = SubathonEventSource.GoAffPro, IsOrder = true, Order = 10, LegacySiteId = 7132796, Enabled = false)] NaturaPineOrder, [EventTypeMeta(Label = "Donation", Source = SubathonEventSource.TipeeeStream, IsCurrencyDonation = true, Order = 1)] - TipeeeStreamDonation, + TipeeeStreamDonation, + [EventTypeMeta(Label = "Tokens", Source = SubathonEventSource.Tangia, IsToken = true, Order = 1)] TangiaTokens, // no way to distinguish twitch bits or tangia coins - [EventTypeMeta(Label = "Donation", Source = SubathonEventSource.PallyGG, IsCurrencyDonation = true, IsExternal = true, + [EventTypeMeta(Label = "Donation", Source = SubathonEventSource.PallyGG, IsCurrencyDonation = true, + IsExternal = true, Order = 1)] // Pally.gg is USD only :/ PallyGGDonation, @@ -194,78 +206,108 @@ public enum SubathonEventType // items only [EventTypeMeta(Label = "Pledge", Source = SubathonEventSource.MakeShip, IsOrder = true, Order = 1)] MakeShipPledge, + // items only [EventTypeMeta(Label = "Campaign Sale", Source = SubathonEventSource.MakeShip, IsOrder = true, Order = 2)] MakeShipSale, - [EventTypeMeta(Label="Merch Sale", Source=SubathonEventSource.JuniperCreates, IsOrder=true, Order=1, Enabled=true)] - JuniperMerchSale, + + [EventTypeMeta(Label = "Merch Sale", Source = SubathonEventSource.JuniperCreates, IsOrder = true, Order = 1, + Enabled = true)] + JuniperMerchSale // any new must be added after the last } [ExcludeFromCodeCoverage] -public static class SubathonEventTypeHelper -{ - - private static EventTypeMetaAttribute? Meta(this SubathonEventType? value) - { +public static class SubathonEventTypeHelper { + private static EventTypeMetaAttribute? Meta(this SubathonEventType? value) { if (!value.HasValue) return null; var meta = EnumMetaCache.Get(value); if (meta?.Source == SubathonEventSource.GoAffPro) return GoAffProMeta(value); return meta; } - - private static GoAffProTypeMetaAttribute? GoAffProMeta(this SubathonEventType? value) - { + + private static GoAffProTypeMetaAttribute? GoAffProMeta(this SubathonEventType? value) { return value != null ? EnumMetaCache.Get(value) : null; } - public static bool IsCurrencyDonation(this SubathonEventType? value) - => value.Meta()?.IsCurrencyDonation == true; - - public static bool IsSubscription(this SubathonEventType? value) - => value.Meta()?.IsSubscriptionLike == true; - - public static bool IsGift(this SubathonEventType? value) - => value.Meta()?.IsGift == true; - - public static bool IsToken(this SubathonEventType? value) - => value.Meta()?.IsToken == true; - - public static bool IsCommand(this SubathonEventType? value) - => value.Meta()?.IsCommand == true; - - public static bool IsExternal(this SubathonEventType? value) - => value.Meta()?.IsExternal == true; - - public static bool IsRaid(this SubathonEventType? value) - => value.Meta()?.IsRaid == true; - public static bool IsFollow(this SubathonEventType? value) - => value.Meta()?.IsFollow == true; - - public static bool IsTrain(this SubathonEventType? value) => value.Meta()?.IsTrain == true; - public static bool IsOrder(this SubathonEventType? value) => value.Meta()?.IsOrder == true; - - public static bool IsEvent(this SubathonEventType? value) => value.Meta()?.IsGenericEvent == true; - public static bool IsExtension(this SubathonEventType? value) => value.Meta()?.IsExtension == true; - public static bool IsOther(this SubathonEventType? value) - => value.Meta()?.IsOther == true; - - public static bool HasNoValueConfig(this SubathonEventType? value) => value.Meta()?.HasValueConfig == true; - - public static SubathonEventSource GetSource(this SubathonEventType value) => - ((SubathonEventType?)value).GetSource(); - public static SubathonEventSource GetSource(this SubathonEventType? value) - => value.Meta()?.Source ?? SubathonEventSource.Unknown; - - public static string GetLabel(this SubathonEventType? value) => value.Meta()?.Label ?? value.ToString() ?? string.Empty; - - public static SubathonEventSubType? GetSubType(this SubathonEventType eventType) => GetSubType((SubathonEventType?)eventType); - public static SubathonEventSubType GetSubType(this SubathonEventType? eventType) - { + public static bool IsCurrencyDonation(this SubathonEventType? value) { + return value.Meta()?.IsCurrencyDonation == true; + } + + public static bool IsSubscription(this SubathonEventType? value) { + return value.Meta()?.IsSubscriptionLike == true; + } + + public static bool IsGift(this SubathonEventType? value) { + return value.Meta()?.IsGift == true; + } + + public static bool IsToken(this SubathonEventType? value) { + return value.Meta()?.IsToken == true; + } + + public static bool IsCommand(this SubathonEventType? value) { + return value.Meta()?.IsCommand == true; + } + + public static bool IsExternal(this SubathonEventType? value) { + return value.Meta()?.IsExternal == true; + } + + public static bool IsRaid(this SubathonEventType? value) { + return value.Meta()?.IsRaid == true; + } + + public static bool IsFollow(this SubathonEventType? value) { + return value.Meta()?.IsFollow == true; + } + + public static bool IsTrain(this SubathonEventType? value) { + return value.Meta()?.IsTrain == true; + } + + public static bool IsOrder(this SubathonEventType? value) { + return value.Meta()?.IsOrder == true; + } + + public static bool IsEvent(this SubathonEventType? value) { + return value.Meta()?.IsGenericEvent == true; + } + + public static bool IsExtension(this SubathonEventType? value) { + return value.Meta()?.IsExtension == true; + } + + public static bool IsOther(this SubathonEventType? value) { + return value.Meta()?.IsOther == true; + } + + public static bool HasNoValueConfig(this SubathonEventType? value) { + return value.Meta()?.HasValueConfig == true; + } + + public static SubathonEventSource GetSource(this SubathonEventType value) { + return ((SubathonEventType?)value).GetSource(); + } + + public static SubathonEventSource GetSource(this SubathonEventType? value) { + return value.Meta()?.Source ?? SubathonEventSource.Unknown; + } + + public static string GetLabel(this SubathonEventType? value) { + return value.Meta()?.Label ?? value.ToString() ?? string.Empty; + } + + public static SubathonEventSubType? GetSubType(this SubathonEventType eventType) { + return GetSubType((SubathonEventType?)eventType); + } + + public static SubathonEventSubType GetSubType(this SubathonEventType? eventType) { if (eventType == null) return SubathonEventSubType.Unknown; if (eventType.IsGift()) return SubathonEventSubType.GiftSubLike; - if (eventType.IsSubscription()) return SubathonEventSubType.SubLike; // important GiftType is above, so it has priority + if (eventType.IsSubscription()) + return SubathonEventSubType.SubLike; // important GiftType is above, so it has priority if (eventType.IsToken()) return SubathonEventSubType.TokenLike; if (eventType.IsCurrencyDonation()) return SubathonEventSubType.DonationLike; if (eventType.IsOrder()) return SubathonEventSubType.OrderLike; @@ -276,29 +318,28 @@ public static SubathonEventSubType GetSubType(this SubathonEventType? eventType) if (eventType.IsEvent()) return SubathonEventSubType.EventLike; return SubathonEventSubType.Unknown; } - - public static string? GetTypeTrueSource(this SubathonEventType eventType, string? meta = null) => GetTypeTrueSource((SubathonEventType?)eventType, meta); - public static string? GetTypeTrueSource(this SubathonEventType? eventType, string? meta = null) - { + public static string? GetTypeTrueSource(this SubathonEventType eventType, string? meta = null) { + return GetTypeTrueSource((SubathonEventType?)eventType, meta); + } + + public static string? GetTypeTrueSource(this SubathonEventType? eventType, string? meta = null) { if (eventType is null or SubathonEventType.Command) return "Manual"; - if (eventType.GetSource() == SubathonEventSource.GoAffPro) - { - var siteId = eventType == SubathonEventType.GoAffProOrder && meta != null && int.TryParse(meta, out var parsed) + if (eventType.GetSource() == SubathonEventSource.GoAffPro) { + int siteId = eventType == SubathonEventType.GoAffProOrder && meta != null && + int.TryParse(meta, out int parsed) ? parsed : eventType.GoAffProMeta()?.LegacySiteId ?? -1; - return GoAffProStoreRegistry.TryGetBySiteId(siteId, out var store) + return GoAffProStoreRegistry.TryGetBySiteId(siteId, out GoAffProStore? store) ? store.InternalName : nameof(SubathonEventSource.GoAffPro); } - if (eventType.GetSource() == SubathonEventSource.JuniperCreates) - { - return JuniperOrderHelper.GetStoreLabel(meta); - } + if (eventType.GetSource() == SubathonEventSource.JuniperCreates) return JuniperOrderHelper.GetStoreLabel(meta); return eventType.GetSource().ToString(); } - public static int GetLegacyGoAffProSiteId(this SubathonEventType eventType) => - ((SubathonEventType?)eventType).GoAffProMeta()?.LegacySiteId ?? -1; -} + public static int GetLegacyGoAffProSiteId(this SubathonEventType eventType) { + return ((SubathonEventType?)eventType).GoAffProMeta()?.LegacySiteId ?? -1; + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonPromptType.cs b/SubathonManager.Core/Enums/SubathonPromptType.cs index cd22b627..df51299d 100644 --- a/SubathonManager.Core/Enums/SubathonPromptType.cs +++ b/SubathonManager.Core/Enums/SubathonPromptType.cs @@ -3,8 +3,7 @@ namespace SubathonManager.Core.Enums; -public enum SubathonPromptType -{ +public enum SubathonPromptType { Points, Money, Orders, @@ -14,23 +13,21 @@ public enum SubathonPromptType Event } -public enum SubathonPromptSubType -{ +public enum SubathonPromptSubType { Default, // count occurances - + Items, // items on orders - + // subs Default is all subs NormalSubs, // Default but subs only GiftSubs, // Default but giftsubs only - + // Event specific which are Subs // default for any ByTier } -public enum SubathonPromptRunStatus -{ +public enum SubathonPromptRunStatus { Active, Completed, Expired, @@ -38,28 +35,29 @@ public enum SubathonPromptRunStatus } [ExcludeFromCodeCoverage] -public static class SubathonPromptTypeExtensions -{ +public static class SubathonPromptTypeExtensions { private static readonly ReadOnlyDictionary _validSubTypes = - new(new Dictionary - { + new(new Dictionary { [SubathonPromptType.Points] = [SubathonPromptSubType.Default], [SubathonPromptType.Money] = [SubathonPromptSubType.Default], [SubathonPromptType.Orders] = [SubathonPromptSubType.Default, SubathonPromptSubType.Items], [SubathonPromptType.Follows] = [SubathonPromptSubType.Default], [SubathonPromptType.Tokens] = [SubathonPromptSubType.Default], - [SubathonPromptType.Subs] = [SubathonPromptSubType.Default, SubathonPromptSubType.NormalSubs, SubathonPromptSubType.GiftSubs], - [SubathonPromptType.Event] = [SubathonPromptSubType.Default, SubathonPromptSubType.Items, SubathonPromptSubType.ByTier], + [SubathonPromptType.Subs] = + [SubathonPromptSubType.Default, SubathonPromptSubType.NormalSubs, SubathonPromptSubType.GiftSubs], + [SubathonPromptType.Event] = + [SubathonPromptSubType.Default, SubathonPromptSubType.Items, SubathonPromptSubType.ByTier] }); - - public static SubathonPromptSubType[] GetValidSubTypes(this SubathonPromptType type) - => _validSubTypes.TryGetValue(type, out var st) ? st : [SubathonPromptSubType.Default]; - - public static bool IsSubTypeValid(this SubathonPromptType type, SubathonPromptSubType subType) - => type.GetValidSubTypes().Contains(subType); - - public static bool IsSelectableForPromptEvent(this SubathonEventType eventType) - { + + public static SubathonPromptSubType[] GetValidSubTypes(this SubathonPromptType type) { + return _validSubTypes.TryGetValue(type, out SubathonPromptSubType[]? st) ? st : [SubathonPromptSubType.Default]; + } + + public static bool IsSubTypeValid(this SubathonPromptType type, SubathonPromptSubType subType) { + return type.GetValidSubTypes().Contains(subType); + } + + public static bool IsSelectableForPromptEvent(this SubathonEventType eventType) { var type = (SubathonEventType?)eventType; if (type.IsRaid() || type.IsCommand() || type == SubathonEventType.Unknown || type.IsTrain()) return false; return !eventType.IsDisabled(); @@ -67,67 +65,63 @@ public static bool IsSelectableForPromptEvent(this SubathonEventType eventType) public static SubathonPromptSubType[] GetValidSubTypes( this SubathonPromptType type, - SubathonEventType? filterEventType) - { + SubathonEventType? filterEventType) { if (type != SubathonPromptType.Event || filterEventType == null) return type.GetValidSubTypes(); - - if (filterEventType.IsSubscription()) - { + + if (filterEventType.IsSubscription()) { if (filterEventType == SubathonEventType.YouTubeGiftMembership) // no tier selection return [SubathonPromptSubType.Default]; return [SubathonPromptSubType.Default, SubathonPromptSubType.ByTier]; } - if (filterEventType.IsOrder()) - { + + if (filterEventType.IsOrder()) { // makeship and juniper only ever track unit counts between polls, but do it diff // makeship is diff between (ignore first on boot) // juniper is timerange fetched if (filterEventType.GetTypeTrueSource() == $"{SubathonEventSource.MakeShip}" || filterEventType?.GetSource() == SubathonEventSource.JuniperCreates) - { return [SubathonPromptSubType.Items]; - } return [SubathonPromptSubType.Default, SubathonPromptSubType.Items]; } return [SubathonPromptSubType.Default]; } - - public static string DisplayName(this SubathonPromptType type) => type switch - { - SubathonPromptType.Points => "Points", - SubathonPromptType.Money => "Money", - SubathonPromptType.Subs => "Subs", - SubathonPromptType.Orders => "Orders", - SubathonPromptType.Follows => "Follows", - SubathonPromptType.Tokens => "Tokens", - SubathonPromptType.Event => "Specific Event", - _ => type.ToString() - }; - - public static string DisplayName(this SubathonPromptSubType subType, SubathonPromptType type) => subType switch - { - SubathonPromptSubType.Default => type switch{ - SubathonPromptType.Points => "Total", - SubathonPromptType.Money => "Units", - SubathonPromptType.Subs => "Any", - SubathonPromptType.Orders => "Total Orders", - SubathonPromptType.Tokens => "Total", - _ => "Count" - }, - SubathonPromptSubType.Items => "Item Count", - SubathonPromptSubType.NormalSubs => "Normal Subs Only", - SubathonPromptSubType.GiftSubs => "Gift Subs Only", - SubathonPromptSubType.ByTier => "By Tier", - _ => subType.ToString() - }; - - public static string TierMetaDisplayName(this SubathonEventType eventType, string meta) => - eventType switch - { - SubathonEventType.TwitchSub or SubathonEventType.TwitchGiftSub => meta switch - { + + public static string DisplayName(this SubathonPromptType type) { + return type switch { + SubathonPromptType.Points => "Points", + SubathonPromptType.Money => "Money", + SubathonPromptType.Subs => "Subs", + SubathonPromptType.Orders => "Orders", + SubathonPromptType.Follows => "Follows", + SubathonPromptType.Tokens => "Tokens", + SubathonPromptType.Event => "Specific Event", + _ => type.ToString() + }; + } + + public static string DisplayName(this SubathonPromptSubType subType, SubathonPromptType type) { + return subType switch { + SubathonPromptSubType.Default => type switch { + SubathonPromptType.Points => "Total", + SubathonPromptType.Money => "Units", + SubathonPromptType.Subs => "Any", + SubathonPromptType.Orders => "Total Orders", + SubathonPromptType.Tokens => "Total", + _ => "Count" + }, + SubathonPromptSubType.Items => "Item Count", + SubathonPromptSubType.NormalSubs => "Normal Subs Only", + SubathonPromptSubType.GiftSubs => "Gift Subs Only", + SubathonPromptSubType.ByTier => "By Tier", + _ => subType.ToString() + }; + } + + public static string TierMetaDisplayName(this SubathonEventType eventType, string meta) { + return eventType switch { + SubathonEventType.TwitchSub or SubathonEventType.TwitchGiftSub => meta switch { "1000" => "T1", "2000" => "T2", "3000" => "T3", @@ -135,17 +129,18 @@ public static string TierMetaDisplayName(this SubathonEventType eventType, strin }, _ => meta }; - - public static string ValueLabel(this SubathonPromptType type, SubathonPromptSubType subType) => type switch - { - SubathonPromptType.Points => "Target Points", - SubathonPromptType.Money => "Target Money (units)", - SubathonPromptType.Follows => "Target Follows", - SubathonPromptType.Tokens => "Target Tokens", - SubathonPromptType.Orders => subType == SubathonPromptSubType.Items ? "Target Items" : "Target Orders", - SubathonPromptType.Subs => "Target Subs", - SubathonPromptType.Event => "Target Count", - _ => "Target Value" - }; -} - \ No newline at end of file + } + + public static string ValueLabel(this SubathonPromptType type, SubathonPromptSubType subType) { + return type switch { + SubathonPromptType.Points => "Target Points", + SubathonPromptType.Money => "Target Money (units)", + SubathonPromptType.Follows => "Target Follows", + SubathonPromptType.Tokens => "Target Tokens", + SubathonPromptType.Orders => subType == SubathonPromptSubType.Items ? "Target Items" : "Target Orders", + SubathonPromptType.Subs => "Target Subs", + SubathonPromptType.Event => "Target Count", + _ => "Target Value" + }; + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/SubathonSourceGroup.cs b/SubathonManager.Core/Enums/SubathonSourceGroup.cs index 5e845824..2ebde5d7 100644 --- a/SubathonManager.Core/Enums/SubathonSourceGroup.cs +++ b/SubathonManager.Core/Enums/SubathonSourceGroup.cs @@ -1,25 +1,27 @@ -using System.ComponentModel; +namespace SubathonManager.Core.Enums; -namespace SubathonManager.Core.Enums; - -public enum SubathonSourceGroup -{ - [EnumMeta(Description="Unknown", Label="Unknown")] +public enum SubathonSourceGroup { + [EnumMeta(Description = "Unknown", Label = "Unknown")] Unknown, - [EnumMeta(Description="Stream Services", Label="Stream Services")] + + [EnumMeta(Description = "Stream Services", Label = "Stream Services")] Stream, - [EnumMeta(Description="Stream Extensions", Label="Stream Extensions")] + + [EnumMeta(Description = "Stream Extensions", Label = "Stream Extensions")] StreamExtension, + // [Description("Chat Extension")] // ChatExtension, - [EnumMeta(Description="Misc", Label="Misc")] + [EnumMeta(Description = "Misc", Label = "Misc")] Misc, - [EnumMeta(Description="External Services", Label="External Services")] + + [EnumMeta(Description = "External Services", Label = "External Services")] ExternalService, - [EnumMeta(Label="")] - UseSource, - [EnumMeta(Description="Wheel Spin", Label="Wheel Spin")] + [EnumMeta(Label = "")] UseSource, + + [EnumMeta(Description = "Wheel Spin", Label = "Wheel Spin")] WheelSpin, - [EnumMeta(Description="External Software", Label="External Software")] - ExternalSoftware, -} + + [EnumMeta(Description = "External Software", Label = "External Software")] + ExternalSoftware +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/WebsocketClientMessageType.cs b/SubathonManager.Core/Enums/WebsocketClientMessageType.cs index d491ae77..55ee72ea 100644 --- a/SubathonManager.Core/Enums/WebsocketClientMessageType.cs +++ b/SubathonManager.Core/Enums/WebsocketClientMessageType.cs @@ -1,8 +1,8 @@ using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Enums; -public enum WebsocketClientMessageType -{ +public enum WebsocketClientMessageType { None, Generic, // an unconfigured websocket client Widget, // consumes all, but is specifically for a widget @@ -15,21 +15,19 @@ public enum WebsocketClientMessageType } [ExcludeFromCodeCoverage] -public static class WebsocketClientTypeHelper -{ - public static readonly WebsocketClientMessageType[] ConsumersList = new[] - { +public static class WebsocketClientTypeHelper { + public static readonly WebsocketClientMessageType[] ConsumersList = new[] { WebsocketClientMessageType.Widget, WebsocketClientMessageType.IntegrationConsumer }; - - public static readonly WebsocketClientMessageType[] ConfigConsumersList = new[] - { + + public static readonly WebsocketClientMessageType[] ConfigConsumersList = new[] { WebsocketClientMessageType.Widget, WebsocketClientMessageType.IntegrationConsumer, WebsocketClientMessageType.ValueConfig }; - public static bool IsConsumer(this WebsocketClientMessageType varMessageType) => - ConsumersList.Contains(varMessageType); + public static bool IsConsumer(this WebsocketClientMessageType varMessageType) { + return ConsumersList.Contains(varMessageType); + } } \ No newline at end of file diff --git a/SubathonManager.Core/Enums/WheelSpinEnums.cs b/SubathonManager.Core/Enums/WheelSpinEnums.cs index b7af708b..02eb9f36 100644 --- a/SubathonManager.Core/Enums/WheelSpinEnums.cs +++ b/SubathonManager.Core/Enums/WheelSpinEnums.cs @@ -2,46 +2,50 @@ namespace SubathonManager.Core.Enums; -public enum WheelSpinActionType -{ - [WheelSpinActionMeta(HasAction = false, Label="Manual / Other")] +public enum WheelSpinActionType { + [WheelSpinActionMeta(HasAction = false, Label = "Manual / Other")] Manual = 0, - [WheelSpinActionMeta(IsCommand = true, IsDoneImmediately = true, Label = "Add Time")] + + [WheelSpinActionMeta(IsCommand = true, IsDoneImmediately = true, Label = "Add Time")] AddTime = SubathonCommandType.AddTime, + [WheelSpinActionMeta(IsCommand = true, IsDoneImmediately = true, Label = "Subtract Time")] SubtractTime = SubathonCommandType.SubtractTime, + [WheelSpinActionMeta(IsCommand = true, Label = "Multiplier")] SetMultiplier = SubathonCommandType.SetMultiplier, + [WheelSpinActionMeta(IsDoneImmediately = true, Label = "Add Rerolls")] Reroll = 1000 } [ExcludeFromCodeCoverage] -public static class WheelSpinActionTypeHelper -{ - public static string GetLabel(this WheelSpinActionType type) - => EnumMetaCache.Get(type)?.Label ?? $"{type}"; - - public static bool HasAction(this WheelSpinActionType type) - => EnumMetaCache.Get(type)?.HasAction ?? true; - - public static bool IsCommand(this WheelSpinActionType type) - => EnumMetaCache.Get(type)?.IsCommand ?? false; - - public static bool IsDoneImmediately(this WheelSpinActionType type) - => EnumMetaCache.Get(type)?.IsDoneImmediately ?? false; - - public static SubathonCommandType ToCommandType(this WheelSpinActionType type) - { +public static class WheelSpinActionTypeHelper { + public static string GetLabel(this WheelSpinActionType type) { + return EnumMetaCache.Get(type)?.Label ?? $"{type}"; + } + + public static bool HasAction(this WheelSpinActionType type) { + return EnumMetaCache.Get(type)?.HasAction ?? true; + } + + public static bool IsCommand(this WheelSpinActionType type) { + return EnumMetaCache.Get(type)?.IsCommand ?? false; + } + + public static bool IsDoneImmediately(this WheelSpinActionType type) { + return EnumMetaCache.Get(type)?.IsDoneImmediately ?? false; + } + + public static SubathonCommandType ToCommandType(this WheelSpinActionType type) { if (!type.IsCommand()) return SubathonCommandType.Unknown; return (SubathonCommandType)(int)type; } } -public enum WheelSpinHistoryStatus -{ +public enum WheelSpinHistoryStatus { Pending, Done, Cancelled -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/WidgetType.cs b/SubathonManager.Core/Enums/WidgetType.cs index c57be4b5..29409fb3 100644 --- a/SubathonManager.Core/Enums/WidgetType.cs +++ b/SubathonManager.Core/Enums/WidgetType.cs @@ -1,14 +1,12 @@ namespace SubathonManager.Core.Enums; -public enum WidgetType -{ +public enum WidgetType { Html = 0, Image = 1, Video = 2 } -public enum WidgetContextAction -{ +public enum WidgetContextAction { ToggleVisibility, Clone, Delete, @@ -16,37 +14,35 @@ public enum WidgetContextAction Refresh } -public enum WidgetDisplayKind -{ - Widget,// - UnpackedWidget,// +public enum WidgetDisplayKind { + Widget, // + UnpackedWidget, // Image, Video } -public enum WidgetCatalogSource -{ +public enum WidgetCatalogSource { Imported, Preset } -public static class WidgetTypeHelper -{ +public static class WidgetTypeHelper { private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".svg" }; private static readonly HashSet VideoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".mp4", ".m4v", ".webm", ".ogm", ".mkv", ".mov" }; - public static WidgetType DetectFromPath(string path) - { - var ext = Path.GetExtension(path); - + public static WidgetType DetectFromPath(string path) { + string ext = Path.GetExtension(path); + if (ImageExtensions.Contains(ext)) return WidgetType.Image; if (VideoExtensions.Contains(ext)) return WidgetType.Video; - + return WidgetType.Html; } - public static bool IsAsset(this WidgetType type) => type != WidgetType.Html; -} + public static bool IsAsset(this WidgetType type) { + return type != WidgetType.Html; + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/WidgetVariableType.cs b/SubathonManager.Core/Enums/WidgetVariableType.cs index 57c94c01..9ed31923 100644 --- a/SubathonManager.Core/Enums/WidgetVariableType.cs +++ b/SubathonManager.Core/Enums/WidgetVariableType.cs @@ -1,8 +1,8 @@ using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Enums; -public enum WidgetVariableType -{ +public enum WidgetVariableType { Int, String, Float, @@ -31,95 +31,99 @@ public enum WidgetVariableType } [ExcludeFromCodeCoverage] -public static class WidgetVariableTypeHelper -{ - private static readonly WidgetVariableType[] FileVariables = - [ +public static class WidgetVariableTypeHelper { + private static readonly WidgetVariableType[] FileVariables = [ WidgetVariableType.AnyFile, WidgetVariableType.ImageFile, WidgetVariableType.VideoFile, WidgetVariableType.SoundFile, WidgetVariableType.FolderPath ]; - - public static readonly WidgetVariableType[] FontVariables = - [ + + public static readonly WidgetVariableType[] FontVariables = [ WidgetVariableType.CdnFont, WidgetVariableType.GoogleFont, - WidgetVariableType.BunnyFont, + WidgetVariableType.BunnyFont ]; - public static bool IsFontVariable(this WidgetVariableType type) => FontVariables.Contains(type); - - public static bool IsFileVariable(this WidgetVariableType? varType) => - varType.HasValue && FileVariables.Contains(varType.Value); - - public static bool IsEnumVariable(this WidgetVariableType? varType) => - varType.HasValue && GetClsSingleType(varType.Value).IsEnum; - - public static List GetFilteredEventTypes(this WidgetVariableType varType) => varType switch - { - WidgetVariableType.OrderEventTypeList => SubathonEventSubTypeHelper.OrderEventTypes, - WidgetVariableType.SubEventTypeList => SubathonEventSubTypeHelper.SubEventTypes, - WidgetVariableType.TokenEventTypeList => SubathonEventSubTypeHelper.TokenEventTypes, - WidgetVariableType.FollowEventTypeList => SubathonEventSubTypeHelper.FollowEventTypes, - WidgetVariableType.DonationEventTypeList => SubathonEventSubTypeHelper.DonationEventTypes, - WidgetVariableType.GenericEventTypeList => SubathonEventSubTypeHelper.GenericEventTypes, - WidgetVariableType.EventTypeList => Enum.GetValues().ToList(), - _ => [] - }; - - public static bool IsListType(this WidgetVariableType varType) => varType switch - { - WidgetVariableType.OrderEventTypeList => true, - WidgetVariableType.SubEventTypeList => true, - WidgetVariableType.TokenEventTypeList => true, - WidgetVariableType.FollowEventTypeList => true, - WidgetVariableType.DonationEventTypeList => true, - WidgetVariableType.EventTypeList => true, - WidgetVariableType.StringList => true, - WidgetVariableType.EventSubTypeList => true, - WidgetVariableType.StringSelect => true, - WidgetVariableType.EventTypeSelect => true, - WidgetVariableType.EventSubTypeSelect => true, - WidgetVariableType.GenericEventTypeList => true, - _ => false - }; - public static Type GetClsSingleType(this WidgetVariableType varType) => varType switch - { - WidgetVariableType.Int => typeof(int), - WidgetVariableType.String => typeof(string), - WidgetVariableType.Float => typeof(float), - WidgetVariableType.Boolean => typeof(bool), - WidgetVariableType.Percent => typeof(int), // short - - WidgetVariableType.StringList => typeof(string), - WidgetVariableType.EventTypeList => typeof(SubathonEventType), - WidgetVariableType.EventSubTypeList => typeof(SubathonEventSubType), - WidgetVariableType.StringSelect => typeof(string), - WidgetVariableType.EventTypeSelect => typeof(SubathonEventType), - WidgetVariableType.EventSubTypeSelect => typeof(SubathonEventSubType), - - WidgetVariableType.AnyFile => typeof(string), - WidgetVariableType.ImageFile => typeof(string), - WidgetVariableType.VideoFile => typeof(string), - WidgetVariableType.SoundFile => typeof(string), - WidgetVariableType.FolderPath => typeof(string), - - WidgetVariableType.OrderEventTypeList => typeof(SubathonEventType), - WidgetVariableType.TokenEventTypeList => typeof(SubathonEventType), - WidgetVariableType.SubEventTypeList => typeof(SubathonEventType), - WidgetVariableType.FollowEventTypeList => typeof(SubathonEventType), - WidgetVariableType.DonationEventTypeList => typeof(SubathonEventType), - - _ => typeof(string) - }; + public static bool IsFontVariable(this WidgetVariableType type) { + return FontVariables.Contains(type); + } + + public static bool IsFileVariable(this WidgetVariableType? varType) { + return varType.HasValue && FileVariables.Contains(varType.Value); + } + + public static bool IsEnumVariable(this WidgetVariableType? varType) { + return varType.HasValue && GetClsSingleType(varType.Value).IsEnum; + } + + public static List GetFilteredEventTypes(this WidgetVariableType varType) { + return varType switch { + WidgetVariableType.OrderEventTypeList => SubathonEventSubTypeHelper.OrderEventTypes, + WidgetVariableType.SubEventTypeList => SubathonEventSubTypeHelper.SubEventTypes, + WidgetVariableType.TokenEventTypeList => SubathonEventSubTypeHelper.TokenEventTypes, + WidgetVariableType.FollowEventTypeList => SubathonEventSubTypeHelper.FollowEventTypes, + WidgetVariableType.DonationEventTypeList => SubathonEventSubTypeHelper.DonationEventTypes, + WidgetVariableType.GenericEventTypeList => SubathonEventSubTypeHelper.GenericEventTypes, + WidgetVariableType.EventTypeList => Enum.GetValues().ToList(), + _ => [] + }; + } + + public static bool IsListType(this WidgetVariableType varType) { + return varType switch { + WidgetVariableType.OrderEventTypeList => true, + WidgetVariableType.SubEventTypeList => true, + WidgetVariableType.TokenEventTypeList => true, + WidgetVariableType.FollowEventTypeList => true, + WidgetVariableType.DonationEventTypeList => true, + WidgetVariableType.EventTypeList => true, + WidgetVariableType.StringList => true, + WidgetVariableType.EventSubTypeList => true, + WidgetVariableType.StringSelect => true, + WidgetVariableType.EventTypeSelect => true, + WidgetVariableType.EventSubTypeSelect => true, + WidgetVariableType.GenericEventTypeList => true, + _ => false + }; + } + + public static Type GetClsSingleType(this WidgetVariableType varType) { + return varType switch { + WidgetVariableType.Int => typeof(int), + WidgetVariableType.String => typeof(string), + WidgetVariableType.Float => typeof(float), + WidgetVariableType.Boolean => typeof(bool), + WidgetVariableType.Percent => typeof(int), // short + + WidgetVariableType.StringList => typeof(string), + WidgetVariableType.EventTypeList => typeof(SubathonEventType), + WidgetVariableType.EventSubTypeList => typeof(SubathonEventSubType), + WidgetVariableType.StringSelect => typeof(string), + WidgetVariableType.EventTypeSelect => typeof(SubathonEventType), + WidgetVariableType.EventSubTypeSelect => typeof(SubathonEventSubType), + + WidgetVariableType.AnyFile => typeof(string), + WidgetVariableType.ImageFile => typeof(string), + WidgetVariableType.VideoFile => typeof(string), + WidgetVariableType.SoundFile => typeof(string), + WidgetVariableType.FolderPath => typeof(string), + + WidgetVariableType.OrderEventTypeList => typeof(SubathonEventType), + WidgetVariableType.TokenEventTypeList => typeof(SubathonEventType), + WidgetVariableType.SubEventTypeList => typeof(SubathonEventType), + WidgetVariableType.FollowEventTypeList => typeof(SubathonEventType), + WidgetVariableType.DonationEventTypeList => typeof(SubathonEventType), + + _ => typeof(string) + }; + } } -public enum WidgetCssVariableType -{ +public enum WidgetCssVariableType { Default, // same as string - String, + String, Color, Alignment, Size, @@ -130,14 +134,17 @@ public enum WidgetCssVariableType } [ExcludeFromCodeCoverage] -public static class WidgetCssVariableTypeHelper -{ - public static List GetOptions(this WidgetCssVariableType varType) => varType switch - { - WidgetCssVariableType.Alignment => ["left", "center", "right"], - WidgetCssVariableType.Size => ["px", "%", "pt", "rem", "em", "vh", "vw", "vmin", "vmax", "cm", "mm", "in", "ch", "ex"], - WidgetCssVariableType.Weight => ["normal", "bold", "100", "200", "300", "400", "500", "600", "700", "800", "900", - "bolder", "lighter", "initial", "inherit"], - _ => [] - }; +public static class WidgetCssVariableTypeHelper { + public static List GetOptions(this WidgetCssVariableType varType) { + return varType switch { + WidgetCssVariableType.Alignment => ["left", "center", "right"], + WidgetCssVariableType.Size => + ["px", "%", "pt", "rem", "em", "vh", "vw", "vmin", "vmax", "cm", "mm", "in", "ch", "ex"], + WidgetCssVariableType.Weight => [ + "normal", "bold", "100", "200", "300", "400", "500", "600", "700", "800", "900", + "bolder", "lighter", "initial", "inherit" + ], + _ => [] + }; + } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/ErrorMessageEvents.cs b/SubathonManager.Core/Events/ErrorMessageEvents.cs index fd9f256a..badd9e52 100644 --- a/SubathonManager.Core/Events/ErrorMessageEvents.cs +++ b/SubathonManager.Core/Events/ErrorMessageEvents.cs @@ -1,18 +1,17 @@ using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class ErrorMessageEvents -{ +public static class ErrorMessageEvents { public static event Action? ErrorEventOccured; public static event Action? SendCustomEvent; - - public static void RaiseErrorEvent(string level, string source, string message, DateTime time) - { + + public static void RaiseErrorEvent(string level, string source, string message, DateTime time) { ErrorEventOccured?.Invoke(level, source, message, time); } - public static void RaiseCustomEvent(string message) - { + + public static void RaiseCustomEvent(string message) { SendCustomEvent?.Invoke(message); } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/IntegrationEvents.cs b/SubathonManager.Core/Events/IntegrationEvents.cs index 85edce9a..5f41bed1 100644 --- a/SubathonManager.Core/Events/IntegrationEvents.cs +++ b/SubathonManager.Core/Events/IntegrationEvents.cs @@ -4,25 +4,21 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class IntegrationEvents -{ +public static class IntegrationEvents { public static event Action? ConnectionUpdated; // status, src, acc name, service public static event Action>? FourthWallMembershipsSynced; public static event Action? DevTunnelLegacyNotification; - public static void RaiseConnectionUpdate(IntegrationConnection connection) - { + public static void RaiseConnectionUpdate(IntegrationConnection connection) { Utils.UpdateConnection(connection); ConnectionUpdated?.Invoke(connection); } - public static void RaiseFourthWallMembershipsSynced(Dictionary synced) - { + public static void RaiseFourthWallMembershipsSynced(Dictionary synced) { FourthWallMembershipsSynced?.Invoke(synced); } - public static void RaiseDevTunnelLegacyNotification() - { + public static void RaiseDevTunnelLegacyNotification() { DevTunnelLegacyNotification?.Invoke(); } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/OverlayEvents.cs b/SubathonManager.Core/Events/OverlayEvents.cs index 5803145d..5fe1957b 100644 --- a/SubathonManager.Core/Events/OverlayEvents.cs +++ b/SubathonManager.Core/Events/OverlayEvents.cs @@ -4,26 +4,26 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class OverlayEvents -{ +public static class OverlayEvents { public static event Action? OverlayRefreshRequested; public static event Action? WidgetRefreshRequested; public static event Action, IEnumerable>? WidgetVarsUpdated; - public static void RaiseOverlayRefreshRequested(Guid routeGuid) - { + public static void RaiseOverlayRefreshRequested(Guid routeGuid) { OverlayRefreshRequested?.Invoke(routeGuid); } - public static void RaiseOverlayRefreshAllRequested() - { + public static void RaiseOverlayRefreshAllRequested() { OverlayRefreshRequested?.Invoke(Guid.Empty); } public static void RaiseWidgetVarsUpdated(Guid widgetId, - IEnumerable cssVars, IEnumerable jsVars) => + IEnumerable cssVars, IEnumerable jsVars) { WidgetVarsUpdated?.Invoke(widgetId, cssVars, jsVars); - - public static void RaiseWidgetRefreshRequested(Guid widgetId, float x, float y, int width, int height, float scaleX, float scaleY) - => WidgetRefreshRequested?.Invoke(widgetId, x, y, width, height, scaleX, scaleY); + } + + public static void RaiseWidgetRefreshRequested(Guid widgetId, float x, float y, int width, int height, float scaleX, + float scaleY) { + WidgetRefreshRequested?.Invoke(widgetId, x, y, width, height, scaleX, scaleY); + } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/SettingsEvents.cs b/SubathonManager.Core/Events/SettingsEvents.cs index 5d24deff..963593b8 100644 --- a/SubathonManager.Core/Events/SettingsEvents.cs +++ b/SubathonManager.Core/Events/SettingsEvents.cs @@ -4,30 +4,25 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class SettingsEvents -{ +public static class SettingsEvents { public static event Action? SettingsUnsavedChanges; public static event Action? EventVisibilityChanged; public static event Action? HotLinkToDevTunnelsRequested; public static event Action? HotLinkToSourceRequested; - public static void RaiseSettingsUnsavedChanges(bool hasPendingChanges) - { + public static void RaiseSettingsUnsavedChanges(bool hasPendingChanges) { SettingsUnsavedChanges?.Invoke(hasPendingChanges); } - public static void RaiseEventVisibilityChanged() - { + public static void RaiseEventVisibilityChanged() { EventVisibilityChanged?.Invoke(); } - public static void RaiseHotLinkToDevTunnelsRequest() - { + public static void RaiseHotLinkToDevTunnelsRequest() { HotLinkToDevTunnelsRequested?.Invoke(); } - public static void RaiseHotLinkToSourceRequest(SubathonEventSource source, string? detail = null) - { + public static void RaiseHotLinkToSourceRequest(SubathonEventSource source, string? detail = null) { HotLinkToSourceRequested?.Invoke(source, detail); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Events/SubathonEvents.cs b/SubathonManager.Core/Events/SubathonEvents.cs index 2b494f33..66df15f8 100644 --- a/SubathonManager.Core/Events/SubathonEvents.cs +++ b/SubathonManager.Core/Events/SubathonEvents.cs @@ -1,26 +1,28 @@ -using SubathonManager.Core.Models; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class SubathonEvents -{ +public static class SubathonEvents { public static event Action? SubathonEventCreated; - public static event Action? SubathonEventProcessed; // Run through queue, processed or not to subathon + + public static event Action? + SubathonEventProcessed; // Run through queue, processed or not to subathon + public static event Action>? SubathonEventsDeleted; public static event Action? SubathonDataUpdate; - + public static event Action, long, GoalsType>? SubathonGoalListUpdated; public static event Action? SubathonGoalCompleted; public static event Action? SubathonValueConfigRequested; public static event Action? SubathonValueConfigUpdatedRemote; - + public static event Action>? SubathonValuesPatched; - + public static event Action? SubathonTotalsUpdated; public static event Action? SubscriptionTotalsUpdated; @@ -30,83 +32,78 @@ public static class SubathonEvents public static event Action? PromptRunCancelRequested; public static event Action? PromptRunNowRequested; - public static void RaiseSubathonTotalsUpdated(SubathonTotals totals) - { + public static void RaiseSubathonTotalsUpdated(SubathonTotals totals) { SubathonTotalsUpdated?.Invoke(totals); } - public static void RaiseSubscriptionTotalsUpdated(SubscriptionTotals totals) - { + public static void RaiseSubscriptionTotalsUpdated(SubscriptionTotals totals) { SubscriptionTotalsUpdated?.Invoke(totals); } - - public static void RaisePromptRunStarted(SubathonPromptRun run, SubathonPrompt? prompt) - => PromptRunStarted?.Invoke(run, prompt); - - public static void RaisePromptRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) - => PromptRunUpdate?.Invoke(run, prompt); - - public static void RaisePromptRunProgressUpdated(SubathonPromptRun run, long progress) - => PromptRunProgressUpdated?.Invoke(run, progress); - - public static void RaisePromptRunNowRequested(Guid guid) - => PromptRunNowRequested?.Invoke(guid); - - - public static void RaiseSubathonValuesPatched(List values) - { + + public static void RaisePromptRunStarted(SubathonPromptRun run, SubathonPrompt? prompt) { + PromptRunStarted?.Invoke(run, prompt); + } + + public static void RaisePromptRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) { + PromptRunUpdate?.Invoke(run, prompt); + } + + public static void RaisePromptRunProgressUpdated(SubathonPromptRun run, long progress) { + PromptRunProgressUpdated?.Invoke(run, progress); + } + + public static void RaisePromptRunNowRequested(Guid guid) { + PromptRunNowRequested?.Invoke(guid); + } + + + public static void RaiseSubathonValuesPatched(List values) { SubathonValuesPatched?.Invoke(values); } - - public static void RaisePromptRunCancelRequested() - => PromptRunCancelRequested?.Invoke(); - + + public static void RaisePromptRunCancelRequested() { + PromptRunCancelRequested?.Invoke(); + } + public static event Action? PromptSetEnabledChanged; - - public static void RaisePromptSetEnabledChanged(bool enabled) - => PromptSetEnabledChanged?.Invoke(enabled); + + public static void RaisePromptSetEnabledChanged(bool enabled) { + PromptSetEnabledChanged?.Invoke(enabled); + } - - public static void RaiseSubathonEventsDeleted(List subathonEvent) - { + public static void RaiseSubathonEventsDeleted(List subathonEvent) { SubathonEventsDeleted?.Invoke(subathonEvent); } - public static void RaiseSubathonEventCreated(SubathonEvent subathonEvent) - { + public static void RaiseSubathonEventCreated(SubathonEvent subathonEvent) { SubathonEventCreated?.Invoke(subathonEvent); } - - public static void RaiseSubathonEventProcessed(SubathonEvent subathonEvent, bool wasEffective) - { + + public static void RaiseSubathonEventProcessed(SubathonEvent subathonEvent, bool wasEffective) { SubathonEventProcessed?.Invoke(subathonEvent, wasEffective); } - public static void RaiseSubathonValueConfigRequested(string jsonData) - { + public static void RaiseSubathonValueConfigRequested(string jsonData) { SubathonValueConfigRequested?.Invoke(jsonData); } - - public static void RaiseSubathonValueConfigUpdatedRemote() - { + + public static void RaiseSubathonValueConfigUpdatedRemote() { SubathonValueConfigUpdatedRemote?.Invoke(); } - - public static void RaiseSubathonDataUpdate(SubathonData data, DateTime time) - { + + public static void RaiseSubathonDataUpdate(SubathonData data, DateTime time) { SubathonDataUpdate?.Invoke(data, time); } - public static void RaiseSubathonGoalCompleted(SubathonGoal goal, long points) - { + public static void RaiseSubathonGoalCompleted(SubathonGoal goal, long points) { // do we want all goals? SubathonGoalCompleted?.Invoke(goal, points); } - public static void RaiseSubathonGoalListUpdated(List goals, long points, GoalsType type = GoalsType.Points) - { + public static void RaiseSubathonGoalListUpdated(List goals, long points, + GoalsType type = GoalsType.Points) { SubathonGoalListUpdated?.Invoke(goals, points, type); } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/TimerEvents.cs b/SubathonManager.Core/Events/TimerEvents.cs index b3fffc2e..ce4ef761 100644 --- a/SubathonManager.Core/Events/TimerEvents.cs +++ b/SubathonManager.Core/Events/TimerEvents.cs @@ -1,13 +1,12 @@ using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class TimerEvents -{ +public static class TimerEvents { public static event Action? TimerTickEvent; - - public static void RaiseTimerTickEvent(TimeSpan time) - { + + public static void RaiseTimerTickEvent(TimeSpan time) { TimerTickEvent?.Invoke(time); } } \ No newline at end of file diff --git a/SubathonManager.Core/Events/WebServerEvents.cs b/SubathonManager.Core/Events/WebServerEvents.cs index 4379202a..153ecef1 100644 --- a/SubathonManager.Core/Events/WebServerEvents.cs +++ b/SubathonManager.Core/Events/WebServerEvents.cs @@ -5,22 +5,18 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class WebServerEvents -{ +public static class WebServerEvents { public static event Action? WebServerStatusChanged; public static event Action? WebSocketIntegrationSourceChange; - - public static void RaiseWebServerStatusChange(bool status) - { + + public static void RaiseWebServerStatusChange(bool status) { WebServerStatusChanged?.Invoke(status); } - public static void RaiseWebSocketIntegrationSourceChange(string integrationSource, bool status) - { + public static void RaiseWebSocketIntegrationSourceChange(string integrationSource, bool status) { WebSocketIntegrationSourceChange?.Invoke(integrationSource, status); if (!Enum.TryParse(integrationSource, out SubathonEventSource subathonEventSource)) return; - IntegrationConnection conn = new IntegrationConnection - { + var conn = new IntegrationConnection { Name = "External", Status = status, Source = subathonEventSource, diff --git a/SubathonManager.Core/Events/WheelEvents.cs b/SubathonManager.Core/Events/WheelEvents.cs index e3cffac0..7182b0cb 100644 --- a/SubathonManager.Core/Events/WheelEvents.cs +++ b/SubathonManager.Core/Events/WheelEvents.cs @@ -4,8 +4,7 @@ namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class WheelEvents -{ +public static class WheelEvents { public static event Action? WheelSpinStarted; public static event Action? WheelSpinResult; public static event Action? WheelSpinStatusChanged; @@ -16,29 +15,36 @@ public static class WheelEvents public static event Action? WheelSpinTriggersChanged; public static event Action? WheelSpinRequested; - public static void RaiseSpinsOwedUpdateFromEvent(int amount) - { + public static void RaiseSpinsOwedUpdateFromEvent(int amount) { OnSpinsOwedUpdateFromEvent?.Invoke(amount); } - public static void RaiseWheelSpinStarted(WheelSet wheel, int delaySeconds) - => WheelSpinStarted?.Invoke(wheel, delaySeconds); + public static void RaiseWheelSpinStarted(WheelSet wheel, int delaySeconds) { + WheelSpinStarted?.Invoke(wheel, delaySeconds); + } - public static void RaiseWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) - => WheelSpinResult?.Invoke(wheel, item, history, spinsOwed); + public static void RaiseWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int spinsOwed) { + WheelSpinResult?.Invoke(wheel, item, history, spinsOwed); + } - public static void RaiseWheelSpinStatusChanged(WheelSpinHistory history, int spinsOwed) - => WheelSpinStatusChanged?.Invoke(history, spinsOwed); + public static void RaiseWheelSpinStatusChanged(WheelSpinHistory history, int spinsOwed) { + WheelSpinStatusChanged?.Invoke(history, spinsOwed); + } - public static void RaiseWheelDataChanged(WheelSet wheel, int spinsOwed) - => WheelDataChanged?.Invoke(wheel, spinsOwed); + public static void RaiseWheelDataChanged(WheelSet wheel, int spinsOwed) { + WheelDataChanged?.Invoke(wheel, spinsOwed); + } - public static void RaiseWheelSpinTriggerFired(WheelSpinTrigger trigger, WheelSpinTriggerHistory history, int newSpinsOwed) - => WheelSpinTriggerFired?.Invoke(trigger, history, newSpinsOwed); + public static void RaiseWheelSpinTriggerFired(WheelSpinTrigger trigger, WheelSpinTriggerHistory history, + int newSpinsOwed) { + WheelSpinTriggerFired?.Invoke(trigger, history, newSpinsOwed); + } - public static void RaiseWheelSpinTriggersChanged() - => WheelSpinTriggersChanged?.Invoke(); + public static void RaiseWheelSpinTriggersChanged() { + WheelSpinTriggersChanged?.Invoke(); + } - public static void RaiseWheelSpinRequested() - => WheelSpinRequested?.Invoke(); -} + public static void RaiseWheelSpinRequested() { + WheelSpinRequested?.Invoke(); + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Events/WidgetEvents.cs b/SubathonManager.Core/Events/WidgetEvents.cs index 360b0f60..8d87328a 100644 --- a/SubathonManager.Core/Events/WidgetEvents.cs +++ b/SubathonManager.Core/Events/WidgetEvents.cs @@ -1,39 +1,34 @@ -using SubathonManager.Core.Enums; +using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; using SubathonManager.Core.Models; -using System.Diagnostics.CodeAnalysis; + namespace SubathonManager.Core.Events; [ExcludeFromCodeCoverage] -public static class WidgetEvents -{ +public static class WidgetEvents { public static event Action? WidgetPositionUpdated; public static event Action? WidgetScaleUpdated; public static event Action? WidgetSizeUpdated; public static event Action? SelectEditorWidget; public static event Action? WidgetActionRequested; - public static void RaiseWidgetAction(Guid widgetId, WidgetContextAction action) - { + public static void RaiseWidgetAction(Guid widgetId, WidgetContextAction action) { WidgetActionRequested?.Invoke(widgetId, action); } - public static void RaisePositionUpdated(Widget widget) - { + public static void RaisePositionUpdated(Widget widget) { WidgetPositionUpdated?.Invoke(widget); } - public static void RaiseSelectEditorWidget(Guid widgetId) - { + public static void RaiseSelectEditorWidget(Guid widgetId) { SelectEditorWidget?.Invoke(widgetId); } - - public static void RaiseScaleUpdated(Widget widget) - { + + public static void RaiseScaleUpdated(Widget widget) { WidgetScaleUpdated?.Invoke(widget); } - - public static void RaiseSizeUpdated(Widget widget) - { + + public static void RaiseSizeUpdated(Widget widget) { WidgetSizeUpdated?.Invoke(widget); } } \ No newline at end of file diff --git a/SubathonManager.Core/Interfaces/IAppService.cs b/SubathonManager.Core/Interfaces/IAppService.cs index ada6b235..7b4118e1 100644 --- a/SubathonManager.Core/Interfaces/IAppService.cs +++ b/SubathonManager.Core/Interfaces/IAppService.cs @@ -1,8 +1,6 @@ namespace SubathonManager.Core.Interfaces; -public interface IAppService -{ +public interface IAppService { Task StartAsync(CancellationToken ct = default); Task StopAsync(CancellationToken ct = default); - } \ No newline at end of file diff --git a/SubathonManager.Core/Interfaces/IConfig.cs b/SubathonManager.Core/Interfaces/IConfig.cs index ecd3f696..f0d5209a 100644 --- a/SubathonManager.Core/Interfaces/IConfig.cs +++ b/SubathonManager.Core/Interfaces/IConfig.cs @@ -1,26 +1,28 @@ -using SubathonManager.Core.Enums; +using IniParser.Model; +using SubathonManager.Core.Enums; namespace SubathonManager.Core.Interfaces; -public interface IConfig -{ +public interface IConfig { string GetDatabasePath(); void Save(); void LoadOrCreateDefault(); - + bool MigrateConfig(); - - IniParser.Model.KeyDataCollection? GetSection(string sectionName); + + KeyDataCollection? GetSection(string sectionName); string? Get(string section, string key, string? defaultValue = ""); bool GetBool(string section, string key, bool defaultValue = false); string? GetFromEncoded(string section, string key, string? defaultValue = ""); - - bool Set(string section, string key, string value); /** Returns true if successfully changed, false if it was already equal **/ + bool Set(string section, string key, string value); + + /** Returns true if successfully changed, false if it was already equal **/ bool SetBool(string section, string key, bool? value); + bool SetEncoded(string section, string key, string value); - + OrderTypeModes GetOrderTypeMode(string section, string orderEnumName, OrderTypeModes modeDefault); bool SetOrderTypeMode(string section, string orderEnumName, OrderTypeModes mode); diff --git a/SubathonManager.Core/Interfaces/ITimerService.cs b/SubathonManager.Core/Interfaces/ITimerService.cs index 7cb6e9cc..990449b4 100644 --- a/SubathonManager.Core/Interfaces/ITimerService.cs +++ b/SubathonManager.Core/Interfaces/ITimerService.cs @@ -1,8 +1,7 @@ namespace SubathonManager.Core.Interfaces; -public interface ITimerService -{ +public interface ITimerService { IDisposable Register(string key, TimeSpan interval, Func callback); IDisposable Register(string key, TimeSpan interval, Action callback); void Unregister(string key); -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Interfaces/IWebhookIntegration.cs b/SubathonManager.Core/Interfaces/IWebhookIntegration.cs index c3872399..9f2bfe82 100644 --- a/SubathonManager.Core/Interfaces/IWebhookIntegration.cs +++ b/SubathonManager.Core/Interfaces/IWebhookIntegration.cs @@ -1,8 +1,8 @@ namespace SubathonManager.Core.Interfaces; -public interface IWebhookIntegration : IAppService -{ +public interface IWebhookIntegration : IAppService { string WebhookPath { get; } - Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, CancellationToken ct = default); -} + Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs b/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs index be1e5920..e962922f 100644 --- a/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs +++ b/SubathonManager.Core/Interfaces/IWidgetFileSystem.cs @@ -1,7 +1,6 @@ namespace SubathonManager.Core.Interfaces; -public interface IWidgetFileSystem -{ +public interface IWidgetFileSystem { bool Exists(string path); string? ReadAllText(string path); byte[]? ReadAllBytes(string path); @@ -10,21 +9,34 @@ public interface IWidgetFileSystem IEnumerable EnumerateFiles(string directory); } -public sealed class DiskWidgetFileSystem : IWidgetFileSystem -{ - public bool Exists(string path) => File.Exists(path); - public string? ReadAllText(string path) => File.Exists(path) ? File.ReadAllText(path) : null; - public byte[]? ReadAllBytes(string path) => File.Exists(path) ? File.ReadAllBytes(path) : null; - public bool IsPacked(string path) => false; - public string? GetRealFilePath(string path) => File.Exists(path) ? path : null; +public sealed class DiskWidgetFileSystem : IWidgetFileSystem { + public bool Exists(string path) { + return File.Exists(path); + } - public IEnumerable EnumerateFiles(string directory) => - Directory.Exists(directory) + public string? ReadAllText(string path) { + return File.Exists(path) ? File.ReadAllText(path) : null; + } + + public byte[]? ReadAllBytes(string path) { + return File.Exists(path) ? File.ReadAllBytes(path) : null; + } + + public bool IsPacked(string path) { + return false; + } + + public string? GetRealFilePath(string path) { + return File.Exists(path) ? path : null; + } + + public IEnumerable EnumerateFiles(string directory) { + return Directory.Exists(directory) ? Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) : []; + } } -public static class WidgetFiles -{ +public static class WidgetFiles { public static IWidgetFileSystem Current { get; set; } = new DiskWidgetFileSystem(); -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/GoAffProStore.cs b/SubathonManager.Core/Models/GoAffProStore.cs index 5c07b7ac..a6ca7f52 100644 --- a/SubathonManager.Core/Models/GoAffProStore.cs +++ b/SubathonManager.Core/Models/GoAffProStore.cs @@ -3,16 +3,16 @@ namespace SubathonManager.Core.Models; -public class GoAffProStore -{ +public class GoAffProStore { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int RowId { get; set; } + public int SiteId { get; init; } public string StoreName { get; set; } = ""; public string EventName { get; set; } = ""; public bool Enabled { get; set; } = true; - public string InternalName => StoreName.Replace(" ", ""); - public string InternalEventName => EventName.Replace(" ", ""); + public string InternalName => StoreName.Replace(" ", ""); + public string InternalEventName => EventName.Replace(" ", ""); } \ No newline at end of file diff --git a/SubathonManager.Core/Models/JuniperStore.cs b/SubathonManager.Core/Models/JuniperStore.cs index af8ac216..e28f77b7 100644 --- a/SubathonManager.Core/Models/JuniperStore.cs +++ b/SubathonManager.Core/Models/JuniperStore.cs @@ -6,34 +6,29 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class JuniperStore -{ - [Key] - public Guid RowId { get; set; } +public class JuniperStore { + [Key] public Guid RowId { get; set; } + public required string StoreName { get; init; } public bool Enabled { get; set; } = true; - + public List Products { get; set; } = new(); - + public DateTime LastFetched { get; set; } = DateTime.UtcNow; - } + [ExcludeFromCodeCoverage] -public class JuniperProduct -{ - [Key] - public required BigInteger ProductId { get; init; } +public class JuniperProduct { + [Key] public required BigInteger ProductId { get; init; } public string ProductName { get; set; } = "Product"; - - [ForeignKey(nameof(Store))] - public required Guid StoreId { get; init; } + + [ForeignKey(nameof(Store))] public required Guid StoreId { get; init; } public required JuniperStore Store { get; init; } - + public DateTime LastFetched { get; set; } = DateTime.UtcNow; public bool Valid { get; set; } = true; - + // i assume we do not care about variants for purposes of subathons... - } \ No newline at end of file diff --git a/SubathonManager.Core/Models/MakeShipTracking.cs b/SubathonManager.Core/Models/MakeShipTracking.cs index 38a5f65e..583264bb 100644 --- a/SubathonManager.Core/Models/MakeShipTracking.cs +++ b/SubathonManager.Core/Models/MakeShipTracking.cs @@ -4,10 +4,9 @@ namespace SubathonManager.Core.Models; -public class MakeShipTracking -{ - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +public class MakeShipTracking { + [Key] public Guid Id { get; set; } = Guid.NewGuid(); + public string Url { get; set; } = ""; public string Name { get; set; } = ""; public string ShopifyProductId { get; set; } = ""; @@ -16,6 +15,5 @@ public class MakeShipTracking public int Sales { get; set; } public int Orders { get; set; } - [NotMapped] - public bool PollFailing { get; set; } -} + [NotMapped] public bool PollFailing { get; set; } +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/MultiplierData.cs b/SubathonManager.Core/Models/MultiplierData.cs index ee09bc05..7d884175 100644 --- a/SubathonManager.Core/Models/MultiplierData.cs +++ b/SubathonManager.Core/Models/MultiplierData.cs @@ -5,26 +5,24 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class MultiplierData -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); - +public class MultiplierData { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); + public double Multiplier { get; set; } = 1; - - public TimeSpan? Duration {get; set;} = null; + + public TimeSpan? Duration { get; set; } = null; public DateTime? Started { get; set; } = null; - - [ForeignKey("SubathonData")] - public Guid? SubathonId { get; set; } - public SubathonData? LinkedSubathon { get; set; } - + + [ForeignKey("SubathonData")] public Guid? SubathonId { get; set; } + + public SubathonData? LinkedSubathon { get; set; } + public bool ApplyToSeconds { get; set; } = true; public bool ApplyToPoints { get; set; } = true; - + public bool FromHypeTrain { get; set; } = false; - public bool IsRunning() - { + public bool IsRunning() { return (ApplyToSeconds || ApplyToPoints) && !Multiplier.Equals(1); } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/Route.cs b/SubathonManager.Core/Models/Route.cs index 39180088..33795ca4 100644 --- a/SubathonManager.Core/Models/Route.cs +++ b/SubathonManager.Core/Models/Route.cs @@ -1,31 +1,26 @@ -using SubathonManager.Core.Interfaces; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Interfaces; namespace SubathonManager.Core.Models; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; [ExcludeFromCodeCoverage] -public class Route -{ - - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +public class Route { + [Key] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = ""; public List Widgets { get; set; } = new(); public int Width { get; set; } = 1920; public int Height { get; set; } = 1080; - + public DateTime CreatedTimestamp { get; set; } = DateTime.UtcNow; public DateTime UpdatedTimestamp { get; set; } = DateTime.UtcNow; - public string GetRouteUrl(IConfig config, bool editMode = false) - { + public string GetRouteUrl(IConfig config, bool editMode = false) { string qString = editMode ? "?edit=true" : ""; return $"http://localhost:{config.Get("Server", "Port", "14040")}/route/{Id}{qString}"; } - + /* public JsonElement ToJson() { @@ -50,12 +45,12 @@ public JsonElement ToJson() return JsonSerializer.SerializeToElement(obj); } - + public static Route? FromJson(JsonElement json) { var name = json.GetProperty("name").GetString() ?? string.Empty; if (string.IsNullOrWhiteSpace(name)) return null; - + var route = new Route { Name = name @@ -69,7 +64,7 @@ public JsonElement ToJson() { route.Widgets.Add(widget); } - + return route; } */ diff --git a/SubathonManager.Core/Models/StateValue.cs b/SubathonManager.Core/Models/StateValue.cs index 24caa1c6..c71f0374 100644 --- a/SubathonManager.Core/Models/StateValue.cs +++ b/SubathonManager.Core/Models/StateValue.cs @@ -4,19 +4,17 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class StateValue -{ +public class StateValue { [Key] public string Name { get; set; } = ""; public string Value { get; set; } = ""; public string TypeName { get; set; } = "String"; } -public static class StateKeys -{ +public static class StateKeys { public const string WheelSpinsOwed = "WheelSpinsOwed"; - + public const string WidgetBrowserCollapsed = "WidgetBrowserCollapsed"; public const string WidgetBrowserSkipDeleteConfirm = "WidgetBrowserSkipDeleteConfirm"; public const string WidgetBrowserAllVersions = "WidgetBrowserAllVersions"; public const string EditorPreviewLightBg = "EditorPreviewLightBg"; -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonData.cs b/SubathonManager.Core/Models/SubathonData.cs index 1473c145..dcf5c72d 100644 --- a/SubathonManager.Core/Models/SubathonData.cs +++ b/SubathonManager.Core/Models/SubathonData.cs @@ -5,39 +5,36 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonData -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class SubathonData { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "New Subathon"; - + public long MillisecondsCumulative { get; set; } = 0; public long MillisecondsElapsed { get; set; } = 0; - public int Points { get; set; } = 0; - + public int Points { get; set; } = 0; + public bool IsPaused { get; set; } = true; public bool IsActive { get; set; } = true; - + // continue existing but do not accept new events public bool IsLocked { get; set; } = true; - + // proper power hour value is here public MultiplierData Multiplier { get; set; } = new(); - + public string? Currency { get; set; } = ""; // load from default if not set - public double? MoneySum { get; set; } = 0; - + public double? MoneySum { get; set; } = 0; + public bool? ReversedTime { get; set; } = false; - + public DateTime? CapDateTime { get; set; } = null; - public bool IsSubathonReversed() - { + public bool IsSubathonReversed() { return ReversedTime ?? false; } - public bool IsCapInEffect() - { + public bool IsCapInEffect() { if (CapDateTime == null) return false; long value = 0; if (IsSubathonReversed()) @@ -47,43 +44,38 @@ public bool IsCapInEffect() return CapDateTime.Value < DateTime.Now + TimeSpan.FromMilliseconds(value); } - public long MillisecondsRemaining() - { + public long MillisecondsRemaining() { long value = 0; if (IsSubathonReversed()) value = MillisecondsCumulative + MillisecondsElapsed; else value = MillisecondsCumulative - MillisecondsElapsed; - if (CapDateTime != null) - { + if (CapDateTime != null) { double timeUntilCap = 0; - var x = DateTime.Now; + DateTime x = DateTime.Now; if (CapDateTime.Value > x) timeUntilCap = (CapDateTime.Value - x).TotalMilliseconds; value = Math.Min(value, (long)timeUntilCap); } + return value; } - public TimeSpan TimeRemaining() - { + public TimeSpan TimeRemaining() { return TimeSpan.FromMilliseconds(MillisecondsRemaining()); } - public TimeSpan TimeRemainingRounded() - { + public TimeSpan TimeRemainingRounded() { double totalSeconds = TimeRemaining().TotalSeconds; return TimeSpan.FromSeconds(Math.Floor(totalSeconds)); } - public long GetRoundedMoneySum() - { + public long GetRoundedMoneySum() { return (long)Math.Floor(MoneySum ?? 0); } - - public double GetRoundedMoneySumWithCents() - { + + public double GetRoundedMoneySumWithCents() { return Math.Round(MoneySum ?? 0, 2); } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonEvent.cs b/SubathonManager.Core/Models/SubathonEvent.cs index a1d72b73..03cd060e 100644 --- a/SubathonManager.Core/Models/SubathonEvent.cs +++ b/SubathonManager.Core/Models/SubathonEvent.cs @@ -1,22 +1,22 @@ -using SubathonManager.Core.Enums; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Enums; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonEvent -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class SubathonEvent { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); // override with a guid from actual original source if possible, like message-id. // If not valid guid, hash the value into a valid guid // we can use this to ensure there aren't double read events - + // do we want a bool in here for "added to timer success y/n?" - [Key, Column(Order = 1)] public SubathonEventSource Source { get; set; } = SubathonEventSource.Unknown; + [Key] [Column(Order = 1)] public SubathonEventSource Source { get; set; } = SubathonEventSource.Unknown; public DateTime EventTimestamp { get; set; } = DateTime.Now; @@ -30,54 +30,62 @@ public class SubathonEvent public int? PointsValue { get; set; } = 0; public string? User { get; set; } = ""; public string Value { get; set; } = ""; - + public string SecondaryValue { get; set; } = ""; // often for commission and currency amounts public string TertiaryValue { get; set; } = ""; // often for item names public SubathonCommandType Command { get; set; } = SubathonCommandType.None; public int Amount { get; set; } = 1; // how many times to multiply everything for amount, only used for giftsubs - + public bool ProcessedToSubathon { get; set; } = false; - + public Guid? SubathonId { get; set; } - public SubathonData? LinkedSubathon { get; set; } - + public SubathonData? LinkedSubathon { get; set; } + [CurrencyValidation] public string? Currency { get; set; } = ""; // For determining if power hour (or negative power hour) is enabled, the multiplier. // when adding time to timer, take SecondsValue and always multiply by Multiplier public double MultiplierPoints { get; set; } = 1; public double MultiplierSeconds { get; set; } = 1; - + public bool WasReversed { get; set; } = false; - + public string? EventTypeMeta { get; set; } - + // do we want to later finetune power hour to be for selectable events? - private int GetAmountMultiplier() - { + private int GetAmountMultiplier() { return EventType.IsOrder() ? 1 : Amount; } - - public double GetFinalSecondsValue() => Math.Ceiling(GetFinalSecondsValueRaw()); - public double GetFinalSecondsValueRaw() => GetAmountMultiplier() * SecondsValue * (Source == SubathonEventSource.Command ? 1 : MultiplierSeconds) ?? 0; - public double GetFinalPointsValue() => Math.Floor(GetAmountMultiplier() * PointsValue * (Source == SubathonEventSource.Command ? 1 : Math.Round(MultiplierPoints+0.001)) ?? 0); - - public SubathonEvent ShallowClone() - { - return (SubathonEvent)this.MemberwiseClone(); + + public double GetFinalSecondsValue() { + return Math.Ceiling(GetFinalSecondsValueRaw()); + } + + public double GetFinalSecondsValueRaw() { + return GetAmountMultiplier() * SecondsValue * (Source == SubathonEventSource.Command ? 1 : MultiplierSeconds) ?? + 0; + } + + public double GetFinalPointsValue() { + return Math.Floor(GetAmountMultiplier() * PointsValue * + (Source == SubathonEventSource.Command ? 1 : Math.Round(MultiplierPoints + 0.001)) ?? 0); + } + + public SubathonEvent ShallowClone() { + return (SubathonEvent)MemberwiseClone(); } } [ExcludeFromCodeCoverage] -public class CurrencyValidationAttribute : ValidationAttribute -{ - protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) - { +public class CurrencyValidationAttribute : ValidationAttribute { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) { if (value == null) return ValidationResult.Success; - string s = value.ToString()!; - var allowed = new HashSet(StringComparer.OrdinalIgnoreCase) { "bits", "sub", "beets", "kudos", "", "???", - "order", "items", "member", "viewers" }; + var s = value.ToString()!; + var allowed = new HashSet(StringComparer.OrdinalIgnoreCase) { + "bits", "sub", "beets", "kudos", "", "???", + "order", "items", "member", "viewers" + }; if (allowed.Contains(s)) return ValidationResult.Success; diff --git a/SubathonManager.Core/Models/SubathonGoal.cs b/SubathonManager.Core/Models/SubathonGoal.cs index 1d981671..e81ee846 100644 --- a/SubathonManager.Core/Models/SubathonGoal.cs +++ b/SubathonManager.Core/Models/SubathonGoal.cs @@ -5,15 +5,13 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonGoal -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); - +public class SubathonGoal { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); + public string Text { get; set; } = "New Goal"; public long Points { get; set; } = 1; - - [ForeignKey("SubathonGoalSet")] - public Guid? GoalSetId { get; set; } - public SubathonGoalSet? LinkedGoalSet { get; set; } - + + [ForeignKey("SubathonGoalSet")] public Guid? GoalSetId { get; set; } + + public SubathonGoalSet? LinkedGoalSet { get; set; } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonGoalSet.cs b/SubathonManager.Core/Models/SubathonGoalSet.cs index f059e06d..8081b655 100644 --- a/SubathonManager.Core/Models/SubathonGoalSet.cs +++ b/SubathonManager.Core/Models/SubathonGoalSet.cs @@ -6,12 +6,11 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonGoalSet -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class SubathonGoalSet { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "New Goal List"; public bool IsActive { get; set; } = true; - + public List Goals { get; set; } = new(); public GoalsType? Type { get; set; } = GoalsType.Points; diff --git a/SubathonManager.Core/Models/SubathonPrompt.cs b/SubathonManager.Core/Models/SubathonPrompt.cs index 9440909c..374c8502 100644 --- a/SubathonManager.Core/Models/SubathonPrompt.cs +++ b/SubathonManager.Core/Models/SubathonPrompt.cs @@ -6,10 +6,9 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonPrompt -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); - +public class SubathonPrompt { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); + public string Text { get; set; } = "New Prompt"; public long Value { get; set; } = 10; public TimeSpan CompletionDuration { get; set; } = TimeSpan.FromMinutes(5); @@ -23,47 +22,49 @@ public class SubathonPrompt public SubathonEventType? FilterEventType { get; set; } = null; public string? FilterMeta { get; set; } = null; public SubathonEventSubType? FilterSubType { get; set; } = null; - - [ForeignKey("SubathonPromptSet")] - public Guid? SetId { get; set; } - public SubathonPromptSet? LinkedSet { get; set; } - + + [ForeignKey("SubathonPromptSet")] public Guid? SetId { get; set; } + + public SubathonPromptSet? LinkedSet { get; set; } + public int Index { get; set; } = 0; - - public bool HasStock() => IsInfinite || Quantity > 0; - public bool IsPickable() => Enabled && HasStock(); + + public bool HasStock() { + return IsInfinite || Quantity > 0; + } + + public bool IsPickable() { + return Enabled && HasStock(); + } } [ExcludeFromCodeCoverage] -public class SubathonPromptRun -{ +public class SubathonPromptRun { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - - [ForeignKey(nameof(SubathonPrompt))] - public Guid PromptId { get; set; } + + [ForeignKey(nameof(SubathonPrompt))] public Guid PromptId { get; set; } + public SubathonPrompt? LinkedPrompt { get; set; } - + [ForeignKey(nameof(SubathonPromptSet))] public Guid SetId { get; set; } + public SubathonPromptSet? LinkedSet { get; set; } - + public DateTime StartedAt { get; set; } = DateTime.Now; public DateTime ExpiresAt { get; set; } public DateTime? EndedAt { get; set; } - + public SubathonPromptRunStatus Status { get; set; } = SubathonPromptRunStatus.Active; public long SnapshotTargetValue { get; set; } public long BaselineCount { get; set; } = 0; - + public bool IsActive => Status == SubathonPromptRunStatus.Active; public bool IsExpired => DateTime.Now >= ExpiresAt && Status == SubathonPromptRunStatus.Active; - - public TimeSpan TimeRemaining() - { + + public TimeSpan TimeRemaining() { if (Status != SubathonPromptRunStatus.Active) return TimeSpan.Zero; - var remaining = ExpiresAt - DateTime.Now; + TimeSpan remaining = ExpiresAt - DateTime.Now; return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining; } - - } \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonPromptSet.cs b/SubathonManager.Core/Models/SubathonPromptSet.cs index 84cb2f73..c3d06f96 100644 --- a/SubathonManager.Core/Models/SubathonPromptSet.cs +++ b/SubathonManager.Core/Models/SubathonPromptSet.cs @@ -5,26 +5,25 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonPromptSet -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class SubathonPromptSet { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "My Prompts"; public bool Enabled { get; set; } = true; public bool IsActive { get; set; } = true; - + public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(20); public TimeSpan RandomOffset { get; set; } = TimeSpan.Zero; public TimeSpan Cooldown { get; set; } = TimeSpan.FromMinutes(20); // time after one ends before a new one can start - + public List Prompts { get; set; } = []; - - - public void ClampRandomOffset() - { + + + public void ClampRandomOffset() { if (RandomOffset > Interval) RandomOffset = Interval; } - - public IEnumerable PickablePrompts() - => Prompts.Where(p => p.IsPickable()); + + public IEnumerable PickablePrompts() { + return Prompts.Where(p => p.IsPickable()); + } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/SubathonValue.cs b/SubathonManager.Core/Models/SubathonValue.cs index fc59b8cb..a676407f 100644 --- a/SubathonManager.Core/Models/SubathonValue.cs +++ b/SubathonManager.Core/Models/SubathonValue.cs @@ -1,30 +1,27 @@ -using SubathonManager.Core.Enums; -using System.Text.Json.Serialization; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using SubathonManager.Core.Enums; namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class SubathonValue -{ - [Key, Column(Order = 0)] public SubathonEventType EventType { get; set; } - [Key, Column(Order = 1)] public string Meta { get; set; } = ""; - +public class SubathonValue { + [Key] [Column(Order = 0)] public SubathonEventType EventType { get; set; } + [Key] [Column(Order = 1)] public string Meta { get; set; } = ""; + // EventType + Meta for settings. Most will have empty Meta. // Meta will be for like TwitchSub and TwitchGiftSub, where it will be the Tier (from Value field). string 1000 2000 3000 - + // in future, may want to add a condition column to compare stuff with? i.e., raids of min viewer count - - public double Seconds { get; set; } = 0; - public double Points { get; set; } = 0; - - - public SubathonValueDto ToObject() - { - return new SubathonValueDto - { + + public double Seconds { get; set; } + public double Points { get; set; } + + + public SubathonValueDto ToObject() { + return new SubathonValueDto { EventType = EventType, Source = ((SubathonEventType?)EventType).GetSource(), Meta = Meta, @@ -35,9 +32,8 @@ public SubathonValueDto ToObject() }; } - public bool PatchByObject(SubathonValueDto dto) - { - bool modified = false; + public bool PatchByObject(SubathonValueDto dto) { + var modified = false; if (dto.EventType != EventType) return false; if (dto.Source != ((SubathonEventType?)EventType).GetSource()) @@ -46,16 +42,14 @@ public bool PatchByObject(SubathonValueDto dto) return false; if (dto.Seconds == null && dto.Points == null) return false; - - if (dto.Seconds is >= 0 && !Seconds.Equals(dto.Seconds)) - { - Seconds = (double) dto.Seconds; + + if (dto.Seconds is >= 0 && !Seconds.Equals(dto.Seconds)) { + Seconds = (double)dto.Seconds; modified = true; } - if (dto.Points is >= 0 && !Points.Equals(dto.Points)) - { - Points = (double) dto.Points; + if (dto.Points is >= 0 && !Points.Equals(dto.Points)) { + Points = (double)dto.Points; modified = true; } @@ -63,13 +57,13 @@ public bool PatchByObject(SubathonValueDto dto) } } -public class SubathonValueDto -{ +public class SubathonValueDto { [JsonConverter(typeof(JsonStringEnumConverter))] public SubathonEventType EventType { get; set; } - + [JsonConverter(typeof(JsonStringEnumConverter))] public SubathonEventSource Source { get; set; } + public string Meta { get; set; } = ""; public double? Seconds { get; set; } public double? Points { get; set; } @@ -77,15 +71,13 @@ public class SubathonValueDto public string? Key { get; set; } public string? Label { get; set; } - public override string ToString() - { + public override string ToString() { return $"{Source} {EventType} [{Meta}]: {Seconds}s, {Points}pts"; } - public string ToValueString() - { - var secondsStr = Seconds == null ? string.Empty : $"{Seconds}s"; - var pointsStr = Points == null ? string.Empty : $"{Points}pts"; + public string ToValueString() { + string secondsStr = Seconds == null ? string.Empty : $"{Seconds}s"; + string pointsStr = Points == null ? string.Empty : $"{Points}pts"; return $"{secondsStr} {pointsStr}".Trim(); } } \ No newline at end of file diff --git a/SubathonManager.Core/Models/WheelSpinTrigger.cs b/SubathonManager.Core/Models/WheelSpinTrigger.cs index 0be8b657..1db6a9f1 100644 --- a/SubathonManager.Core/Models/WheelSpinTrigger.cs +++ b/SubathonManager.Core/Models/WheelSpinTrigger.cs @@ -6,8 +6,7 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class WheelSpinTrigger -{ +public class WheelSpinTrigger { [Key] public Guid Id { get; set; } = Guid.NewGuid(); public bool IsEnabled { get; set; } = true; public int SpinsToAdd { get; set; } = 1; @@ -16,25 +15,26 @@ public class WheelSpinTrigger // For sub/membership events, matches ev.Value (meta tier) public string? TierValue { get; set; } + // by item or reg count like subs or tokens public int? CountThreshold { get; set; } + // by money only public double? MoneyThreshold { get; set; } public string? Currency { get; set; } public List History { get; set; } = []; - + // for future thought, consider a "every" progress mode, useful for say, // 100 followers, or non gift subs. But then attribution doesn't make sense } [ExcludeFromCodeCoverage] -public class WheelSpinTriggerHistory -{ +public class WheelSpinTriggerHistory { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [ForeignKey("WheelSpinTrigger")] - public Guid TriggerId { get; set; } + [ForeignKey("WheelSpinTrigger")] public Guid TriggerId { get; set; } + public WheelSpinTrigger? Trigger { get; set; } public DateTime TriggeredAt { get; set; } = DateTime.Now; @@ -45,4 +45,4 @@ public class WheelSpinTriggerHistory // Loose reference to the source event, survives event deletion public Guid? SubathonEventId { get; set; } public SubathonEventType? SubathonEventType { get; set; } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/Wheels.cs b/SubathonManager.Core/Models/Wheels.cs index 2fa1e29b..2b50e3ec 100644 --- a/SubathonManager.Core/Models/Wheels.cs +++ b/SubathonManager.Core/Models/Wheels.cs @@ -6,23 +6,22 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class WheelSet -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class WheelSet { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = "My Wheel"; public int SpinCount { get; set; } = 0; public bool IsActive { get; set; } = true; public List WheelItems { get; set; } = []; - public IEnumerable SpinnableItems() - => WheelItems.Where(i => i.IsSpinnable()); + public IEnumerable SpinnableItems() { + return WheelItems.Where(i => i.IsSpinnable()); + } } [ExcludeFromCodeCoverage] -public class WheelItem -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class WheelItem { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public string Text { get; set; } = "New Item"; public int Weight { get; set; } = 1; @@ -31,44 +30,47 @@ public class WheelItem public bool Enabled { get; set; } = false; public int Index { get; set; } = 0; - [ForeignKey("WheelSet")] - public Guid? WheelId { get; set; } + [ForeignKey("WheelSet")] public Guid? WheelId { get; set; } + public WheelSet? LinkedWheel { get; set; } public WheelSpinAction? Action { get; set; } - public bool HasStock() => IsInfinite || Quantity > 0; - public bool IsSpinnable() => Enabled && HasStock(); + public bool HasStock() { + return IsInfinite || Quantity > 0; + } + + public bool IsSpinnable() { + return Enabled && HasStock(); + } } [ExcludeFromCodeCoverage] -public class WheelSpinAction -{ - [Key, Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); +public class WheelSpinAction { + [Key] [Column(Order = 0)] public Guid Id { get; set; } = Guid.NewGuid(); public WheelSpinActionType ActionType { get; set; } = WheelSpinActionType.Manual; public string Parameter { get; set; } = ""; - [ForeignKey("WheelItem")] - public Guid WheelItemId { get; set; } + [ForeignKey("WheelItem")] public Guid WheelItemId { get; set; } + public WheelItem? LinkedItem { get; set; } } [ExcludeFromCodeCoverage] -public class WheelSpinHistory -{ +public class WheelSpinHistory { [Key] public Guid Id { get; set; } = Guid.NewGuid(); - [ForeignKey("WheelSet")] - public Guid WheelId { get; set; } + [ForeignKey("WheelSet")] public Guid WheelId { get; set; } + public WheelSet? LinkedWheel { get; set; } - [ForeignKey("WheelItem")] - public Guid WheelItemId { get; set; } + [ForeignKey("WheelItem")] public Guid WheelItemId { get; set; } + public WheelItem? LinkedItem { get; set; } public DateTime CreatedAt { get; set; } = DateTime.Now; public DateTime UpdatedAt { get; set; } = DateTime.Now; public WheelSpinHistoryStatus Status { get; set; } = WheelSpinHistoryStatus.Pending; -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/Widget.cs b/SubathonManager.Core/Models/Widget.cs index 0ba46370..49bf7280 100644 --- a/SubathonManager.Core/Models/Widget.cs +++ b/SubathonManager.Core/Models/Widget.cs @@ -1,63 +1,56 @@ using System.ComponentModel; -using SubathonManager.Core.Enums; -using SubathonManager.Core.Interfaces; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Interfaces; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class CssVariable : INotifyPropertyChanged -{ +public class CssVariable : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; - private void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? name = null) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); - - [Key] - public int Id { get; set; } + + private void OnPropertyChanged([CallerMemberName] string? name = null) { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } + + [Key] public int Id { get; set; } public string Name { get; set; } = string.Empty; - + // to assist the live change for editing private string _value = string.Empty; - public string Value - { + + public string Value { get => _value; - set - { + set { if (_value == value) return; _value = value; - if (string.IsNullOrWhiteSpace(_value)) - { + if (string.IsNullOrWhiteSpace(_value)) { if (Type is WidgetCssVariableType.Int or WidgetCssVariableType.Float) - { _value = "0"; - } - else if (Type is WidgetCssVariableType.Size) - { - _value = "0px"; - } + else if (Type is WidgetCssVariableType.Size) _value = "0px"; } + OnPropertyChanged(); } } - - - [ForeignKey("Widget")] - public Guid WidgetId { get; set; } + + + [ForeignKey("Widget")] public Guid WidgetId { get; set; } public Widget Widget { get; set; } = null!; - - public WidgetCssVariableType Type { get; set; } = WidgetCssVariableType.Default; + + public WidgetCssVariableType Type { get; set; } = WidgetCssVariableType.Default; public string Description { get; set; } = string.Empty; - - public CssVariable Clone(Guid newWidgetId) - { - return new CssVariable - { + + public CssVariable Clone(Guid newWidgetId) { + return new CssVariable { Name = Name, Value = Value, WidgetId = newWidgetId, @@ -65,24 +58,20 @@ public CssVariable Clone(Guid newWidgetId) Description = Description }; } - - public static CssVariable? FromJson(JsonElement json, Guid widgetId) - { - var name = json.GetProperty("name").GetString() ?? string.Empty; - var value = json.GetProperty("value").GetString() ?? string.Empty; + + public static CssVariable? FromJson(JsonElement json, Guid widgetId) { + string name = json.GetProperty("name").GetString() ?? string.Empty; + string value = json.GetProperty("value").GetString() ?? string.Empty; if (string.IsNullOrEmpty(name)) return null; - return new CssVariable - { + return new CssVariable { Name = name, Value = value, WidgetId = widgetId }; } - - public JsonElement ToJson() - { - var obj = new - { + + public JsonElement ToJson() { + var obj = new { name = Name, value = Value }; @@ -92,61 +81,57 @@ public JsonElement ToJson() } [ExcludeFromCodeCoverage] -public class JsVariable -{ - [Key] - public int Id { get; set; } +public class JsVariable { + [Key] public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Value { get; set; } = string.Empty; - public WidgetVariableType Type { get; set; } = WidgetVariableType.String; - - [ForeignKey("Widget")] - public Guid WidgetId { get; set; } + public WidgetVariableType Type { get; set; } = WidgetVariableType.String; + + [ForeignKey("Widget")] public Guid WidgetId { get; set; } public Widget Widget { get; set; } = null!; - + public string Description { get; set; } = string.Empty; - public string GetInjectLine() - { + public string GetInjectLine() { StringBuilder sb = new(); - if (Type.IsFontVariable()) - { - var fnName = Type switch - { + if (Type.IsFontVariable()) { + string fnName = Type switch { WidgetVariableType.GoogleFont => "loadGoogleFont", WidgetVariableType.CdnFont => "loadCdnFont", WidgetVariableType.BunnyFont => "loadBunnyFont", _ => "" }; if (string.IsNullOrWhiteSpace(Value) || string.IsNullOrWhiteSpace(fnName)) return "\n"; - foreach (var font in Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { + foreach (string font in Value.Split(',', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) sb.Append($"{fnName}(\"{font.Trim()}\");\n"); - } return sb.ToString(); } + sb.Append($"const {Name.Replace(' ', '_')} = "); - if (string.IsNullOrEmpty(Value) || string.IsNullOrWhiteSpace(Value)) + if (string.IsNullOrEmpty(Value) || string.IsNullOrWhiteSpace(Value)) { sb.Append("\"\""); - else if (Type == WidgetVariableType.Boolean && bool.TryParse(Value, out var b)) + } + else if (Type == WidgetVariableType.Boolean && bool.TryParse(Value, out bool b)) { sb.Append($"{b.ToString().ToLower()}"); - else if (Type == WidgetVariableType.Float && float.TryParse(Value, out var f)) + } + else if (Type == WidgetVariableType.Float && float.TryParse(Value, out float f)) { sb.Append($"{f}"); - else if (Type == WidgetVariableType.Int && Int32.TryParse(Value, out var intValue)) + } + else if (Type == WidgetVariableType.Int && int.TryParse(Value, out int intValue)) { sb.Append($"{intValue}"); - else if (Type == WidgetVariableType.Percent && Int16.TryParse(Value, out var pctValue)) + } + else if (Type == WidgetVariableType.Percent && short.TryParse(Value, out short pctValue)) { sb.Append($"{Math.Clamp((int)pctValue, 0, 100)}"); - else if (Type == WidgetVariableType.StringSelect) - { + } + else if (Type == WidgetVariableType.StringSelect) { string val = Value.Split(',')[0].Trim(); sb.Append($"\"{val}\""); } else if (Type is WidgetVariableType.EventTypeList or WidgetVariableType.StringList or - WidgetVariableType.EventSubTypeList) - { - string val = string.Join(",", Value.Split(',').Select(s => - { + WidgetVariableType.EventSubTypeList) { + string val = string.Join(",", Value.Split(',').Select(s => { s = s.Trim(); if (!s.StartsWith('"')) s = '"' + s; if (!s.EndsWith('"')) s += '"'; @@ -154,32 +139,26 @@ WidgetVariableType.StringList or })); sb.Append($"[{val}]"); } - else if (((WidgetVariableType?)Type).IsFileVariable()) - { + else if (((WidgetVariableType?)Type).IsFileVariable()) { if (ResourcePaths.IsResourceUrl(Value)) - { sb.Append($"\"{Value}\""); - } else if (!Value.StartsWith("./") && !string.IsNullOrWhiteSpace(Value)) - { sb.Append($"\"externalPath/{Value}\""); - } else - { sb.Append($"\"{Value}\""); - } } else // default as always string. Incl TypeSelects + { sb.Append($"\"{Value}\""); + } + sb.Append(";\n"); return sb.ToString(); } - public JsVariable Clone(Guid newWidgetId) - { - return new JsVariable - { + public JsVariable Clone(Guid newWidgetId) { + return new JsVariable { Name = Name, Value = Value, Type = Type, @@ -187,35 +166,25 @@ public JsVariable Clone(Guid newWidgetId) Description = Description }; } - - public static JsVariable? FromJson(JsonElement json, Guid widgetId) - { - var name = json.GetProperty("name").GetString() ?? string.Empty; + + public static JsVariable? FromJson(JsonElement json, Guid widgetId) { + string name = json.GetProperty("name").GetString() ?? string.Empty; if (string.IsNullOrEmpty(name)) return null; - - var typeEl = json.GetProperty("type"); + + JsonElement typeEl = json.GetProperty("type"); WidgetVariableType type; - + var description = string.Empty; - json.TryGetProperty("description", out var descriptEl); + json.TryGetProperty("description", out JsonElement descriptEl); + + if (descriptEl.ValueKind == JsonValueKind.String) description = descriptEl.GetString(); - if (descriptEl.ValueKind == JsonValueKind.String) - { - description = descriptEl.GetString(); - } - if (typeEl.ValueKind == JsonValueKind.Number - && typeEl.TryGetInt32(out var typeInt) + && typeEl.TryGetInt32(out int typeInt) && Enum.IsDefined(typeof(WidgetVariableType), typeInt)) - { type = (WidgetVariableType)typeInt; - } - else if (!Enum.TryParse(typeEl.GetString() ?? string.Empty, out type)) - { - return null; - } - return new JsVariable - { + else if (!Enum.TryParse(typeEl.GetString() ?? string.Empty, out type)) return null; + return new JsVariable { Name = name, Value = json.GetProperty("value").GetString() ?? string.Empty, Type = type, @@ -223,11 +192,9 @@ public JsVariable Clone(Guid newWidgetId) Description = description ?? string.Empty }; } - - public JsonElement ToJson() - { - var obj = new - { + + public JsonElement ToJson() { + var obj = new { name = Name, value = Value, type = Type, @@ -239,28 +206,25 @@ public JsonElement ToJson() } [ExcludeFromCodeCoverage] -public partial class Widget -{ - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +public partial class Widget { + [Key] public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } public string HtmlPath { get; set; } - - public float X { get; set; } = 0; - public float Y { get; set; } = 0; - public int Z { get; set; } = 0; + + public float X { get; set; } + public float Y { get; set; } + public int Z { get; set; } public int Width { get; set; } = 400; public int Height { get; set; } = 400; - + public float ScaleX { get; set; } = 1; public float ScaleY { get; set; } = 1; public List CssVariables { get; set; } = new(); public List JsVariables { get; set; } = new(); - - [ForeignKey("Route")] - public Guid RouteId { get; set; } + + [ForeignKey("Route")] public Guid RouteId { get; set; } public Route Route { get; set; } = null!; public bool Visibility { get; set; } = true; @@ -269,15 +233,13 @@ public partial class Widget public WidgetType Type { get; set; } = WidgetType.Html; - public Widget(string name, string htmlPath) - { + public Widget(string name, string htmlPath) { Name = name; HtmlPath = htmlPath; } - public Widget Clone(Guid? routeId, string? newName, int? newZ) - { - Widget widget = new Widget(string.IsNullOrEmpty(newName) ? Name : newName, HtmlPath); + public Widget Clone(Guid? routeId, string? newName, int? newZ) { + var widget = new Widget(string.IsNullOrEmpty(newName) ? Name : newName, HtmlPath); widget.RouteId = routeId ?? RouteId; widget.Visibility = Visibility; @@ -290,33 +252,32 @@ public Widget Clone(Guid? routeId, string? newName, int? newZ) widget.ScaleY = ScaleY; widget.Type = Type; - foreach (var jsVariable in JsVariables) + foreach (JsVariable jsVariable in JsVariables) widget.JsVariables.Add(jsVariable.Clone(widget.Id)); - foreach (var cssVariable in CssVariables) + foreach (CssVariable cssVariable in CssVariables) widget.CssVariables.Add(cssVariable.Clone(widget.Id)); return widget; } - - public string GetPath() => Path.GetDirectoryName(HtmlPath) ?? HtmlPath; - public void ScanCssVariables() - { + public string GetPath() { + return Path.GetDirectoryName(HtmlPath) ?? HtmlPath; + } + + public void ScanCssVariables() { CssVariables = ExtractCssVariablesFromFiles(); } - - public List GetLinkedCssPaths() - { + + public List GetLinkedCssPaths() { var paths = new List(); - var html = WidgetFiles.Current.ReadAllText(HtmlPath); + string? html = WidgetFiles.Current.ReadAllText(HtmlPath); if (html == null) return paths; string baseDir = Path.GetDirectoryName(HtmlPath)!; - foreach (Match match in CssLinkMatches(html)) - { + foreach (Match match in CssLinkMatches(html)) { string cssFile = match.Groups[1].Value; string cssPath = Path.IsPathRooted(cssFile) ? cssFile @@ -333,63 +294,57 @@ public List GetLinkedCssPaths() return paths; } - public static MatchCollection CssLinkMatches(string htmlContent) => CssLinkRegex().Matches(htmlContent); + public static MatchCollection CssLinkMatches(string htmlContent) { + return CssLinkRegex().Matches(htmlContent); + } - public List ExtractCssVariablesFromFiles() - { + public List ExtractCssVariablesFromFiles() { var extractedVars = new List(); - foreach (var cssPath in GetLinkedCssPaths()) - { + foreach (string cssPath in GetLinkedCssPaths()) { string? cssContent = WidgetFiles.Current.ReadAllText(cssPath); if (cssContent == null) continue; - var varMatches = CssVarRegex().Matches(cssContent); + MatchCollection varMatches = CssVarRegex().Matches(cssContent); string metaPath = cssPath + ".json"; Dictionary>? varTypes = null; - var metaJson = WidgetFiles.Current.ReadAllText(metaPath); + string? metaJson = WidgetFiles.Current.ReadAllText(metaPath); if (metaJson != null) - { varTypes = JsonSerializer.Deserialize>>(metaJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = false }); - } - foreach (Match varMatch in varMatches) - { + foreach (Match varMatch in varMatches) { string name = varMatch.Groups[1].Value.Trim(); string value = varMatch.Groups[2].Value.Trim(); - string? description = string.Empty; + var description = string.Empty; if (extractedVars.Any(v => v.Name == name)) continue; - WidgetCssVariableType cssType = WidgetCssVariableType.Default; + var cssType = WidgetCssVariableType.Default; Dictionary? metaValue = null; if (varTypes is { Count: > 0 }) varTypes.TryGetValue(name, out metaValue); - - if (metaValue != null) - { - metaValue.TryGetValue("type", out var typeName); - Enum.TryParse(typeName, ignoreCase: true, out cssType); + + if (metaValue != null) { + metaValue.TryGetValue("type", out string? typeName); + Enum.TryParse(typeName, true, out cssType); metaValue.TryGetValue("description", out description); } - - extractedVars.Add(new CssVariable - { + + extractedVars.Add(new CssVariable { Name = name, Value = value, WidgetId = Id, Type = cssType, Description = string.IsNullOrWhiteSpace(description) ? string.Empty : description }); - } } + return extractedVars; } - public override string ToString() - { + public override string ToString() { return $"Widget {Name} (Instance {Id})"; } @@ -399,29 +354,24 @@ public override string ToString() [GeneratedRegex(@"--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);")] private static partial Regex CssVarRegex(); - public JsonElement ToJson(string htmlRelPath) - { - var obj = new - { + public JsonElement ToJson(string htmlRelPath) { + var obj = new { name = Name, htmlPath = htmlRelPath, type = Type.ToString(), - position = new - { + position = new { x = X, y = Y, z = Z }, - size = new - { + size = new { width = Width, height = Height }, - scale = new - { + scale = new { x = ScaleX, y = ScaleY }, @@ -435,14 +385,14 @@ public JsonElement ToJson(string htmlRelPath) return JsonSerializer.SerializeToElement(obj); } - + /* public static Widget? FromJson(JsonElement json, string rootPath, Guid routeId) { var htmlPath = Path.Join(rootPath, json.GetProperty("htmlPath").GetString()); var name = json.GetProperty("name").GetString() ?? string.Empty; if (string.IsNullOrEmpty(name)) return null; - + var widget = new Widget(name, htmlPath) { RouteId = routeId, @@ -463,13 +413,13 @@ public JsonElement ToJson(string htmlRelPath) widget.ScaleX = scale.GetProperty("x").GetSingle(); widget.ScaleY = scale.GetProperty("y").GetSingle(); - foreach (var cssVar in json.GetProperty("cssVariables").EnumerateArray().Select(css => + foreach (var cssVar in json.GetProperty("cssVariables").EnumerateArray().Select(css => CssVariable.FromJson(css, widget.Id)).OfType()) { widget.CssVariables.Add(cssVar); } - foreach (var jsVar in json.GetProperty("jsVariables").EnumerateArray().Select(js => + foreach (var jsVar in json.GetProperty("jsVariables").EnumerateArray().Select(js => JsVariable.FromJson(js, widget.Id)).OfType()) { widget.JsVariables.Add(jsVar); @@ -478,4 +428,4 @@ public JsonElement ToJson(string htmlRelPath) return widget; } */ -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/WidgetCatalogEntry.cs b/SubathonManager.Core/Models/WidgetCatalogEntry.cs index 73280e61..5c5c66ce 100644 --- a/SubathonManager.Core/Models/WidgetCatalogEntry.cs +++ b/SubathonManager.Core/Models/WidgetCatalogEntry.cs @@ -5,10 +5,8 @@ namespace SubathonManager.Core.Models; [ExcludeFromCodeCoverage] -public class WidgetCatalogEntry -{ - [Key] - public int Id { get; set; } +public class WidgetCatalogEntry { + [Key] public int Id { get; set; } public string PackPath { get; set; } = string.Empty; public long FileSize { get; set; } @@ -31,4 +29,4 @@ public class WidgetCatalogEntry public float ScaleX { get; set; } = 1; public float ScaleY { get; set; } = 1; -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Objects/IntegrationConnection.cs b/SubathonManager.Core/Objects/IntegrationConnection.cs index 10a0fdfa..b8a292fd 100644 --- a/SubathonManager.Core/Objects/IntegrationConnection.cs +++ b/SubathonManager.Core/Objects/IntegrationConnection.cs @@ -2,8 +2,7 @@ namespace SubathonManager.Core.Objects; -public class IntegrationConnection -{ +public class IntegrationConnection { public SubathonEventSource Source { get; init; } public string Service { get; init; } = ""; public string Name { get; init; } = ""; @@ -11,5 +10,7 @@ public class IntegrationConnection public bool Status { get; init; } public bool Configured { get; init; } = true; - public override string ToString() => $"[{Source}:{Service}] [{Name}] Connected: {Status}"; + public override string ToString() { + return $"[{Source}:{Service}] [{Name}] Connected: {Status}"; + } } \ No newline at end of file diff --git a/SubathonManager.Core/Objects/OAuthCallback.cs b/SubathonManager.Core/Objects/OAuthCallback.cs index 5a2348ad..804acb12 100644 --- a/SubathonManager.Core/Objects/OAuthCallback.cs +++ b/SubathonManager.Core/Objects/OAuthCallback.cs @@ -3,8 +3,7 @@ namespace SubathonManager.Core.Objects; [ExcludeFromCodeCoverage] -public class OAuthCallback -{ +public class OAuthCallback { public string Provider { get; set; } = string.Empty; public string? AccessToken { get; set; } public string? RefreshToken { get; set; } diff --git a/SubathonManager.Core/Objects/SubathonTotals.cs b/SubathonManager.Core/Objects/SubathonTotals.cs index 279b5cc3..035bca1b 100644 --- a/SubathonManager.Core/Objects/SubathonTotals.cs +++ b/SubathonManager.Core/Objects/SubathonTotals.cs @@ -2,8 +2,7 @@ namespace SubathonManager.Core.Objects; -public class SubathonTotals -{ +public class SubathonTotals { public double MoneySum { get; init; } = 0; public string? Currency { get; init; } = "USD"; @@ -23,8 +22,7 @@ public class SubathonTotals public SubathonSimulatedTotals Simulated { get; init; } = new(); } -public class SubathonSimulatedTotals -{ +public class SubathonSimulatedTotals { public int SubLikeTotal { get; init; } = 0; public Dictionary SubLikeByEvent { get; init; } = new(); diff --git a/SubathonManager.Core/Objects/SubscriptionTotals.cs b/SubathonManager.Core/Objects/SubscriptionTotals.cs index 2d21ae14..e582f4f9 100644 --- a/SubathonManager.Core/Objects/SubscriptionTotals.cs +++ b/SubathonManager.Core/Objects/SubscriptionTotals.cs @@ -2,8 +2,7 @@ namespace SubathonManager.Core.Objects; -public class SubscriptionTotals -{ +public class SubscriptionTotals { public int SubTotal { get; init; } = 0; public Dictionary SubTotalByEvent { get; init; } = new(); @@ -12,9 +11,8 @@ public class SubscriptionTotals public SubscriptionSimulatedTotals Simulated { get; init; } = new(); } -public class SubscriptionSimulatedTotals -{ +public class SubscriptionSimulatedTotals { public int SubTotal { get; init; } = 0; public Dictionary SubTotalByEvent { get; init; } = new(); public Dictionary> SubTotalByEventTier { get; init; } = new(); -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Objects/WidgetMeta.cs b/SubathonManager.Core/Objects/WidgetMeta.cs index 7ad96841..b0f6e00e 100644 --- a/SubathonManager.Core/Objects/WidgetMeta.cs +++ b/SubathonManager.Core/Objects/WidgetMeta.cs @@ -5,8 +5,7 @@ namespace SubathonManager.Core.Objects; -public class WidgetMeta -{ +public class WidgetMeta { public string Author { get; set; } = string.Empty; public string Url { get; set; } = string.Empty; public int Width { get; set; } = 300; @@ -15,20 +14,17 @@ public class WidgetMeta } [ExcludeFromCodeCoverage] -public class WidgetMetaVar -{ - [JsonIgnore] - public string Name { get; set; } = string.Empty; +public class WidgetMetaVar { + [JsonIgnore] public string Name { get; set; } = string.Empty; + public WidgetVariableType Type { get; set; } = WidgetVariableType.String; public string Description { get; set; } = string.Empty; - + public object Value { get; set; } = string.Empty; public List? Options { get; set; } - public string ValueToString() - { - var value = Value switch - { + public string ValueToString() { + string value = Value switch { JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty, JsonElement { ValueKind: JsonValueKind.Number } el => el.GetRawText(), JsonElement { ValueKind: JsonValueKind.True } => "true", diff --git a/SubathonManager.Core/Security/AesFileSecureStorage.cs b/SubathonManager.Core/Security/AesFileSecureStorage.cs index 57e491c4..de4815bb 100644 --- a/SubathonManager.Core/Security/AesFileSecureStorage.cs +++ b/SubathonManager.Core/Security/AesFileSecureStorage.cs @@ -8,135 +8,121 @@ namespace SubathonManager.Core.Security; [ExcludeFromCodeCoverage] -public sealed class AesFileSecureStorage : ISecureStorage -{ +public sealed class AesFileSecureStorage : ISecureStorage { private const int KeySize = 32; private const int NonceSize = 12; private const int TagSize = 16; private readonly string _filePath; + private readonly byte[] _key; private readonly string _keyPath; private readonly Lock _lock = new(); - private readonly byte[] _key; - private Dictionary _store; - private ILogger? _logger; + private readonly ILogger? _logger; + private readonly Dictionary _store; - public AesFileSecureStorage(ILogger? logger, string? filePath = null, string? keyPath = null) - { + public AesFileSecureStorage(ILogger? logger, string? filePath = null, string? keyPath = null) { _logger = logger; _filePath = filePath - ?? Path.GetFullPath(Path.Combine(string.Empty - , "data/secure_store.bin")); + ?? Path.GetFullPath(Path.Combine(string.Empty + , "data/secure_store.bin")); _keyPath = keyPath - ?? Path.ChangeExtension(_filePath, ".key"); + ?? Path.ChangeExtension(_filePath, ".key"); _key = LoadOrCreateKey(); _store = Load(); } - public bool Set(string key, string value) - { + public bool Set(string key, string value) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - bool hasUpdated = false; - if (string.IsNullOrWhiteSpace(value)) - { + var hasUpdated = false; + if (string.IsNullOrWhiteSpace(value)) { Delete(key); return false; } + if (string.Equals(GetOrDefault(key), value)) return false; - lock (_lock) - { + lock (_lock) { _store[key] = value; Flush(); hasUpdated = true; } - if (hasUpdated) - { - _logger?.LogInformation("[SecureStorage] Saved {Key}", key); - } + if (hasUpdated) _logger?.LogInformation("[SecureStorage] Saved {Key}", key); return hasUpdated; } - public string? Get(string key) - { + public string? Get(string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) + lock (_lock) { return _store.GetValueOrDefault(key); + } } - public string? GetOrDefault(string key, string defaultValue = "") - { + public string? GetOrDefault(string key, string defaultValue = "") { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) + lock (_lock) { return _store.GetValueOrDefault(key, defaultValue); + } } - public bool Delete(string key) - { + public bool Delete(string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) - { - if (_store.Remove(key)) - { + lock (_lock) { + if (_store.Remove(key)) { Flush(); _logger?.LogInformation("[SecureStorage] Removed {Key}", key); return true; } } + return false; } - public bool Exists(string key) - { - lock (_lock) + public bool Exists(string key) { + lock (_lock) { return _store.ContainsKey(key); + } } - private byte[] LoadOrCreateKey() - { - if (File.Exists(_keyPath)) - { - var existing = File.ReadAllBytes(_keyPath); + private byte[] LoadOrCreateKey() { + if (File.Exists(_keyPath)) { + byte[] existing = File.ReadAllBytes(_keyPath); if (existing.Length == KeySize) return existing; throw new InvalidOperationException( $"Secure store key file is invalid ({_keyPath}). " + "Delete secure_store.bin and secure_store.key and re-authenticate all services."); } - var key = RandomNumberGenerator.GetBytes(KeySize); - var dir = Path.GetDirectoryName(_keyPath); + byte[] key = RandomNumberGenerator.GetBytes(KeySize); + string? dir = Path.GetDirectoryName(_keyPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); File.WriteAllBytes(_keyPath, key); RestrictToOwner(_keyPath); return key; } - private Dictionary Load() - { + private Dictionary Load() { if (!File.Exists(_filePath)) return new Dictionary(); - try - { - var raw = File.ReadAllBytes(_filePath); + try { + byte[] raw = File.ReadAllBytes(_filePath); if (raw.Length < NonceSize + TagSize) throw new CryptographicException("Secure store file is too short to be valid."); - var nonce = raw.AsSpan(0, NonceSize); - var tag = raw.AsSpan(NonceSize, TagSize); - var ciphertext = raw.AsSpan(NonceSize + TagSize); + Span nonce = raw.AsSpan(0, NonceSize); + Span tag = raw.AsSpan(NonceSize, TagSize); + Span ciphertext = raw.AsSpan(NonceSize + TagSize); var plaintext = new byte[ciphertext.Length]; using var aes = new AesGcm(_key, TagSize); aes.Decrypt(nonce, ciphertext, tag, plaintext); return JsonSerializer.Deserialize>( - Encoding.UTF8.GetString(plaintext)) - ?? new Dictionary(); + Encoding.UTF8.GetString(plaintext)) + ?? new Dictionary(); } - catch (CryptographicException ex) - { + catch (CryptographicException ex) { throw new InvalidOperationException( "Secure store could not be decrypted. " + "If you moved the app between machines or lost secure_store.key, " + @@ -145,12 +131,11 @@ private Dictionary Load() } } - private void Flush() - { - var json = JsonSerializer.Serialize(_store); - var plaintext = Encoding.UTF8.GetBytes(json); + private void Flush() { + string json = JsonSerializer.Serialize(_store); + byte[] plaintext = Encoding.UTF8.GetBytes(json); - var nonce = RandomNumberGenerator.GetBytes(NonceSize); + byte[] nonce = RandomNumberGenerator.GetBytes(NonceSize); var tag = new byte[TagSize]; var ciphertext = new byte[plaintext.Length]; @@ -162,15 +147,14 @@ private void Flush() tag.CopyTo(raw, NonceSize); ciphertext.CopyTo(raw, NonceSize + TagSize); - var tmp = _filePath + ".tmp"; + string tmp = _filePath + ".tmp"; File.WriteAllBytes(tmp, raw); RestrictToOwner(tmp); - File.Move(tmp, _filePath, overwrite: true); + File.Move(tmp, _filePath, true); } - private static void RestrictToOwner(string path) - { + private static void RestrictToOwner(string path) { if (OperatingSystem.IsWindows()) return; File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Security/DpapiSecureStorage.cs b/SubathonManager.Core/Security/DpapiSecureStorage.cs index d8beab70..168c23df 100644 --- a/SubathonManager.Core/Security/DpapiSecureStorage.cs +++ b/SubathonManager.Core/Security/DpapiSecureStorage.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -7,101 +8,89 @@ namespace SubathonManager.Core.Security; -[System.Runtime.Versioning.SupportedOSPlatform("windows")] +[SupportedOSPlatform("windows")] [ExcludeFromCodeCoverage] -public sealed class DpapiSecureStorage : ISecureStorage -{ +public sealed class DpapiSecureStorage : ISecureStorage { private readonly string _filePath; private readonly Lock _lock = new(); - private Dictionary _store; - private ILogger? _logger; + private readonly ILogger? _logger; + private readonly Dictionary _store; - public DpapiSecureStorage(ILogger? logger, string? filePath = null) - { + public DpapiSecureStorage(ILogger? logger, string? filePath = null) { _logger = logger; _filePath = filePath - ?? Path.GetFullPath(Path.Combine(string.Empty - , "data/secure_store.bin")); + ?? Path.GetFullPath(Path.Combine(string.Empty + , "data/secure_store.bin")); _store = Load(); } - public bool Set(string key, string value) - { + public bool Set(string key, string value) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - bool hasUpdated = false; - if (string.IsNullOrWhiteSpace(value)) - { + var hasUpdated = false; + if (string.IsNullOrWhiteSpace(value)) { Delete(key); return false; } + if (string.Equals(GetOrDefault(key), value)) return false; - lock (_lock) - { + lock (_lock) { _store[key] = value; Flush(); hasUpdated = true; } - if (hasUpdated) - { - _logger?.LogInformation("[SecureStorage] Saved {Key}", key); - } + if (hasUpdated) _logger?.LogInformation("[SecureStorage] Saved {Key}", key); return hasUpdated; } - public string? Get(string key) - { + public string? Get(string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) + lock (_lock) { return _store.GetValueOrDefault(key); + } } - - public string? GetOrDefault(string key, string defaultValue = "") - { + + public string? GetOrDefault(string key, string defaultValue = "") { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) + lock (_lock) { return _store.GetValueOrDefault(key, defaultValue); + } } - public bool Delete(string key) - { + public bool Delete(string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - lock (_lock) - { - if (_store.Remove(key)) - { + lock (_lock) { + if (_store.Remove(key)) { Flush(); _logger?.LogInformation("[SecureStorage] Removed {Key}", key); return true; } } + return false; } - public bool Exists(string key) - { - lock (_lock) + public bool Exists(string key) { + lock (_lock) { return _store.ContainsKey(key); + } } - private Dictionary Load() - { + private Dictionary Load() { if (!File.Exists(_filePath)) return new Dictionary(); - try - { - var encrypted = File.ReadAllBytes(_filePath); - var plaintext = ProtectedData.Unprotect( + try { + byte[] encrypted = File.ReadAllBytes(_filePath); + byte[] plaintext = ProtectedData.Unprotect( encrypted, null, DataProtectionScope.CurrentUser); return JsonSerializer.Deserialize>( - Encoding.UTF8.GetString(plaintext)) - ?? new Dictionary(); + Encoding.UTF8.GetString(plaintext)) + ?? new Dictionary(); } - catch (CryptographicException ex) - { + catch (CryptographicException ex) { throw new InvalidOperationException( "Secure store could not be decrypted. " + "If you moved the app between user accounts, delete secure_store.bin and re-authenticate all services.", @@ -109,15 +98,14 @@ private Dictionary Load() } } - private void Flush() - { - var json = JsonSerializer.Serialize(_store); - var plaintext = Encoding.UTF8.GetBytes(json); - var encrypted = ProtectedData.Protect( + private void Flush() { + string json = JsonSerializer.Serialize(_store); + byte[] plaintext = Encoding.UTF8.GetBytes(json); + byte[] encrypted = ProtectedData.Protect( plaintext, null, DataProtectionScope.CurrentUser); - var tmp = _filePath + ".tmp"; + string tmp = _filePath + ".tmp"; File.WriteAllBytes(tmp, encrypted); - File.Move(tmp, _filePath, overwrite: true); + File.Move(tmp, _filePath, true); } } \ No newline at end of file diff --git a/SubathonManager.Core/Security/Interfaces/ISecureStorage.cs b/SubathonManager.Core/Security/Interfaces/ISecureStorage.cs index c822cc36..7ef2a002 100644 --- a/SubathonManager.Core/Security/Interfaces/ISecureStorage.cs +++ b/SubathonManager.Core/Security/Interfaces/ISecureStorage.cs @@ -1,13 +1,12 @@ namespace SubathonManager.Core.Security.Interfaces; -public interface ISecureStorage -{ +public interface ISecureStorage { /// Store or overwrite a value. Flushes to disk immediately. bool Set(string key, string value); /// Returns the value, or null if the key doesn't exist. string? Get(string key); - + string? GetOrDefault(string key, string defaultValue); /// Remove a key. No-op if it doesn't exist. Flushes to disk. diff --git a/SubathonManager.Core/Security/SignatureVerification.cs b/SubathonManager.Core/Security/SignatureVerification.cs index b7946548..c5205132 100644 --- a/SubathonManager.Core/Security/SignatureVerification.cs +++ b/SubathonManager.Core/Security/SignatureVerification.cs @@ -4,26 +4,22 @@ namespace SubathonManager.Core.Security; -public static class SignatureVerification -{ +public static class SignatureVerification { private static readonly ConcurrentDictionary KeyCache = new(); - private static PublicKey ImportFromPem(string pem) - { - return KeyCache.GetOrAdd(pem, p => - { - var b64 = p + private static PublicKey ImportFromPem(string pem) { + return KeyCache.GetOrAdd(pem, p => { + string b64 = p .Replace("-----BEGIN PUBLIC KEY-----", "") .Replace("-----END PUBLIC KEY-----", "") .Replace("\n", "").Replace("\r", "").Trim(); - var spki = Convert.FromBase64String(b64); + byte[] spki = Convert.FromBase64String(b64); return PublicKey.Import(SignatureAlgorithm.Ed25519, spki[^32..], KeyBlobFormat.RawPublicKey); }); } - - public static bool VerifyEd25519Signature(string rawBody, string? timestampHeader, - string? signatureHex, string publicKeyPem) - { + + public static bool VerifyEd25519Signature(string rawBody, string? timestampHeader, + string? signatureHex, string publicKeyPem) { if (string.IsNullOrWhiteSpace(timestampHeader) || !long.TryParse(timestampHeader, out _)) return false; @@ -31,15 +27,19 @@ public static bool VerifyEd25519Signature(string rawBody, string? timestampHeade return false; byte[] signature; - try { signature = Convert.FromHexString(signatureHex); } - catch { return false; } + try { + signature = Convert.FromHexString(signatureHex); + } + catch { + return false; + } if (signature.Length != 64) return false; - var key = ImportFromPem(publicKeyPem); + PublicKey key = ImportFromPem(publicKeyPem); - var message = Encoding.UTF8.GetBytes(timestampHeader + "." + rawBody); + byte[] message = Encoding.UTF8.GetBytes(timestampHeader + "." + rawBody); return SignatureAlgorithm.Ed25519.Verify(key, message, signature); } diff --git a/SubathonManager.Core/Security/StorageKeys.cs b/SubathonManager.Core/Security/StorageKeys.cs index 8c5bf543..dc4411d0 100644 --- a/SubathonManager.Core/Security/StorageKeys.cs +++ b/SubathonManager.Core/Security/StorageKeys.cs @@ -1,19 +1,18 @@ namespace SubathonManager.Core.Security; -public static class StorageKeys -{ +public static class StorageKeys { public const string TwitchAccessToken = "SM.Twitch.AccessToken"; - + public const string FourthWallAccessToken = "SM.FourthWall.AccessToken"; public const string FourthWallRefreshToken = "SM.FourthWall.RefreshToken"; - public const string StreamLabsSocketToken = "SM.StreamLabs.SocketToken"; + public const string StreamLabsSocketToken = "SM.StreamLabs.SocketToken"; public const string StreamElementsJwt = "SM.StreamElements.JWTToken"; public const string KoFiVerificationToken = "SM.KoFi.VerificationToken"; - + public const string GoAffProEmail = "SM.GoAffPro.Email"; public const string GoAffProPassword = "SM.GoAffPro.Password"; - + public const string OBSWebSocketPassword = "SM.OBS.WebSocket.Password"; public const string TipeeeStreamAccessToken = "SM.TipeeeStream.AccessToken"; From 6404e0432cdb1661ea4bf6cdb6c22bee84dde736 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:00:23 -0300 Subject: [PATCH 06/39] stage formatting changes --- SubathonManager.Core/AppServices.cs | 200 ++++++------ SubathonManager.Core/Config.cs | 299 ++++++++---------- SubathonManager.Core/FeatureFlags.cs | 5 +- .../GoAffProConfigMigration.cs | 18 +- .../MakeShipTrackingRegistry.cs | 64 ++-- SubathonManager.Core/OrderEventHelper.cs | 279 +++++++--------- SubathonManager.Core/ResourcePaths.cs | 92 +++--- .../RotatingFileLoggerProvider.cs | 298 +++++++++-------- SubathonManager.Core/SafeFileName.cs | 40 ++- SubathonManager.Core/Utils.cs | 280 +++++++--------- 10 files changed, 719 insertions(+), 856 deletions(-) diff --git a/SubathonManager.Core/AppServices.cs b/SubathonManager.Core/AppServices.cs index bf0c2e54..daa67139 100644 --- a/SubathonManager.Core/AppServices.cs +++ b/SubathonManager.Core/AppServices.cs @@ -1,128 +1,116 @@ -using Updatum; +using System.Diagnostics.CodeAnalysis; using System.Reflection; -using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; -namespace SubathonManager.Core { - - public static class AppServices - { - public static IServiceProvider Provider { get; set; } = default!; - - public static string AppVersion - { - get - { - var ver = Assembly.GetExecutingAssembly() - .GetCustomAttribute()? - .InformationalVersion ?? "dev"; - - var plusIndex = ver.IndexOf('+'); - return plusIndex > 0 && ver.StartsWith('v') ? ver[..plusIndex] : ver; - } - } +using Updatum; + +namespace SubathonManager.Core; + +public static class AppServices { + private static readonly UpdatumManager AppUpdater = new("WolfwithSword", "SubathonManager") { + AssetExtensionFilter = "zip", + CurrentVersion = GetVersion(), + FetchOnlyLatestRelease = true, + AssetRegexPattern = GetAssetRegexPattern() + }; - private static readonly UpdatumManager AppUpdater = new("WolfwithSword", "SubathonManager") - { - AssetExtensionFilter = "zip", - CurrentVersion = GetVersion(), - FetchOnlyLatestRelease = true, - AssetRegexPattern = GetAssetRegexPattern(), - }; - - private static string GetAssetRegexPattern() - { - string rid = - OperatingSystem.IsWindows() ? "win-x64" : - OperatingSystem.IsLinux() ? "linux-x64" : - OperatingSystem.IsMacOS() - ? (RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64") - : string.Empty; - - return string.IsNullOrEmpty(rid) ? string.Empty : $@"(?i).*{rid}.*\.zip$"; + public static IServiceProvider Provider { get; set; } = default!; + + public static string AppVersion { + get { + string ver = Assembly.GetExecutingAssembly() + .GetCustomAttribute()? + .InformationalVersion ?? "dev"; + + int plusIndex = ver.IndexOf('+'); + return plusIndex > 0 && ver.StartsWith('v') ? ver[..plusIndex] : ver; } + } - private static Version GetVersion() - { - if (string.IsNullOrWhiteSpace(AppVersion)) - throw new ArgumentException("Version string cannot be null or empty.", nameof(AppVersion)); + private static string GetAssetRegexPattern() { + string rid = + OperatingSystem.IsWindows() + ? "win-x64" + : OperatingSystem.IsLinux() + ? "linux-x64" + : OperatingSystem.IsMacOS() + ? RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64" + : string.Empty; + + return string.IsNullOrEmpty(rid) ? string.Empty : $@"(?i).*{rid}.*\.zip$"; + } + + private static Version GetVersion() { + if (string.IsNullOrWhiteSpace(AppVersion)) + throw new ArgumentException("Version string cannot be null or empty.", nameof(AppVersion)); - string input = AppVersion.Trim(); - if (input.StartsWith("v", StringComparison.OrdinalIgnoreCase)) - input = input.Substring(1); + string input = AppVersion.Trim(); + if (input.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + input = input.Substring(1); - var parts = input.Split('.', StringSplitOptions.RemoveEmptyEntries); + string[] parts = input.Split('.', StringSplitOptions.RemoveEmptyEntries); - int major = parts.Length > 0 && int.TryParse(parts[0], out var m) ? m : 0; - int minor = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n : 0; - int build = parts.Length > 2 && int.TryParse(parts[2], out var b) ? b : 0; - int revision = parts.Length > 3 && int.TryParse(parts[3], out var r) ? r : 0; + int major = parts.Length > 0 && int.TryParse(parts[0], out int m) ? m : 0; + int minor = parts.Length > 1 && int.TryParse(parts[1], out int n) ? n : 0; + int build = parts.Length > 2 && int.TryParse(parts[2], out int b) ? b : 0; + int revision = parts.Length > 3 && int.TryParse(parts[3], out int r) ? r : 0; + + return new Version(major, minor, build, revision); + } - return new Version(major, minor, build, revision); + public static async Task<(bool, string?, string?)> CheckForUpdate(ILogger? logger) { + try { + bool updateFound = await AppUpdater.CheckForUpdatesAsync(); + if (AppUpdater.LatestReleaseTagVersionStr == AppVersion) updateFound = false; + if (updateFound) + logger?.LogInformation( + $"SubathonManager found an update ({AppUpdater.LatestReleaseTagVersionStr}) from current {AppVersion}." + + $" Changelog: {AppUpdater.GetChangelog()}"); + return (updateFound, AppUpdater.LatestReleaseTagVersionStr, + $"{AppUpdater.RepositoryUrl}/releases/{AppUpdater.LatestReleaseTagVersionStr}"); + } + catch (Exception ex) { + logger?.LogWarning(ex, "Failed to check for updates"); } - public static async Task<(bool, string?, string?)> CheckForUpdate(ILogger? logger) - { - - try - { - bool updateFound = await AppUpdater.CheckForUpdatesAsync(); - if (AppUpdater.LatestReleaseTagVersionStr == AppVersion) updateFound = false; - if (updateFound) - logger?.LogInformation($"SubathonManager found an update ({AppUpdater.LatestReleaseTagVersionStr}) from current {AppVersion}." + - $" Changelog: {AppUpdater.GetChangelog()}"); - return (updateFound, AppUpdater.LatestReleaseTagVersionStr, $"{AppUpdater.RepositoryUrl}/releases/{AppUpdater.LatestReleaseTagVersionStr}"); - } - catch (Exception ex) - { - logger?.LogWarning(ex, "Failed to check for updates"); + return (false, null, null); + } + + public static async Task DownloadUpdate(ILogger? logger) { + try { + UpdatumDownloadedAsset? downloads = await AppUpdater.DownloadUpdateAsync(); + if (downloads == null) { + logger?.LogError("SubathonManager found an update but failed to download."); + return null; } - return (false, null, null); + + return downloads; + } + catch (Exception ex) { + logger?.LogWarning(ex, "Failed to download update"); } - public static async Task DownloadUpdate(ILogger? logger) - { - try - { - var downloads = await AppUpdater.DownloadUpdateAsync(); - if (downloads == null) - { - logger?.LogError($"SubathonManager found an update but failed to download."); - return null; - } + return null; + } - return downloads; + [ExcludeFromCodeCoverage] + public static async Task InstallUpdate(UpdatumDownloadedAsset? asset, ILogger? logger) { + if (asset == null) + return false; - } - catch (Exception ex) - { - logger?.LogWarning(ex, "Failed to download update"); - } - return null; + try { + await AppUpdater.InstallUpdateAsync(asset); + return true; } - - [ExcludeFromCodeCoverage] - public static async Task InstallUpdate(UpdatumDownloadedAsset? asset, ILogger? logger) - { - if (asset == null) - return false; - - try - { - await AppUpdater.InstallUpdateAsync(asset, true); - return true; - } - catch (Exception ex) - { - logger?.LogWarning(ex, "Failed to install update"); - } - return false; + catch (Exception ex) { + logger?.LogWarning(ex, "Failed to install update"); } - public static async Task DownloadAndInstall(ILogger? logger) - { - var asset = await DownloadUpdate(logger); - return await InstallUpdate(asset, logger); - } + return false; + } + + public static async Task DownloadAndInstall(ILogger? logger) { + UpdatumDownloadedAsset? asset = await DownloadUpdate(logger); + return await InstallUpdate(asset, logger); } } \ No newline at end of file diff --git a/SubathonManager.Core/Config.cs b/SubathonManager.Core/Config.cs index 24769b5a..0a5ba17a 100644 --- a/SubathonManager.Core/Config.cs +++ b/SubathonManager.Core/Config.cs @@ -1,193 +1,168 @@ -using IniParser; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using IniParser; using IniParser.Model; -using System.Diagnostics.CodeAnalysis; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; -namespace SubathonManager.Core -{ - [ExcludeFromCodeCoverage] - public class Config : IConfig - { - private static readonly string ConfigPath = Path.GetFullPath(Path.Combine(string.Empty - , "data/config.ini")); - - public static readonly string DataFolder = Path.GetFullPath(Path.Combine(string.Empty - , "data")); - - public static readonly string DatabasePath = Path.Combine(Path.GetFullPath(Path.Combine(string.Empty, - "data")), "subathonmanager.db"); - - public static readonly string AppFolder = Path.GetFullPath("."); - - private static readonly FileIniDataParser Parser = new(); - private static IniData Data { get; set; } = new(); - - public static string TwitchClientId { get; } = "jsykjc9k0yqkbqg4ttsfgnwwqmoxfh"; - - public bool PendingChanges { get; private set; } - - public virtual IniParser.Model.KeyDataCollection GetSection(string section) - { - return Data[section]; - } - - public bool MigrateConfig() - { - bool migrated = false; - if (GetSection("Twitch").Any(k => k.KeyName.StartsWith("Commands."))) - { - foreach (var keyData in GetSection("Twitch").Where(k => k.KeyName.StartsWith("Commands."))) - { - Set("Chat", keyData.KeyName, keyData.Value); - GetSection("Twitch").RemoveKey(keyData.KeyName); - migrated = true; - } - } +namespace SubathonManager.Core; + +[ExcludeFromCodeCoverage] +public class Config : IConfig { + private static readonly string ConfigPath = Path.GetFullPath(Path.Combine(string.Empty + , "data/config.ini")); + + public static readonly string DataFolder = Path.GetFullPath(Path.Combine(string.Empty + , "data")); + + public static readonly string DatabasePath = Path.Combine(Path.GetFullPath(Path.Combine(string.Empty, + "data")), "subathonmanager.db"); + + public static readonly string AppFolder = Path.GetFullPath("."); + + private static readonly FileIniDataParser Parser = new(); + private static IniData Data { get; set; } = new(); - if (GetSection("Twitch").ContainsKey("BitsAsDonation")) - { - Set("Currency", "BitsLikeAsDonation", Get("Twitch", "BitsAsDonation", "False")); - GetSection("Twitch").RemoveKey("BitsAsDonation"); + public static string TwitchClientId { get; } = "jsykjc9k0yqkbqg4ttsfgnwwqmoxfh"; + + public bool PendingChanges { get; private set; } + + public virtual KeyDataCollection GetSection(string section) { + return Data[section]; + } + + public bool MigrateConfig() { + var migrated = false; + if (GetSection("Twitch").Any(k => k.KeyName.StartsWith("Commands."))) + foreach (KeyData? keyData in GetSection("Twitch").Where(k => k.KeyName.StartsWith("Commands."))) { + Set("Chat", keyData.KeyName, keyData.Value); + GetSection("Twitch").RemoveKey(keyData.KeyName); migrated = true; } - if (migrated) Save(); - return migrated; + if (GetSection("Twitch").ContainsKey("BitsAsDonation")) { + Set("Currency", "BitsLikeAsDonation", Get("Twitch", "BitsAsDonation", "False")); + GetSection("Twitch").RemoveKey("BitsAsDonation"); + migrated = true; } - - public virtual void LoadOrCreateDefault() - { - string folder = Path.GetFullPath(Path.Combine(string.Empty, - "data")); - Directory.CreateDirectory(folder); - - Console.WriteLine($"[Config] Checking config at {ConfigPath}"); - var dir = Path.GetDirectoryName(ConfigPath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - if (!File.Exists(ConfigPath)) - { - Console.WriteLine("[Config] Creating new config.ini..."); - CreateDefault(); - Save(); - } - else - { - try - { - Data = Parser.ReadFile(ConfigPath); - } - catch (Exception ex) - { - Console.WriteLine($"[Config] Failed to read config.ini: {ex.Message}"); - Console.WriteLine("[Config] Recreating config with defaults."); - CreateDefault(); - Save(); - } - } - if (string.IsNullOrWhiteSpace(Get("Currency", "Primary"))) - { - Set("Currency", "Primary", "USD"); + if (migrated) Save(); + return migrated; + } + + public virtual void LoadOrCreateDefault() { + string folder = Path.GetFullPath(Path.Combine(string.Empty, + "data")); + Directory.CreateDirectory(folder); + + Console.WriteLine($"[Config] Checking config at {ConfigPath}"); + string? dir = Path.GetDirectoryName(ConfigPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + if (!File.Exists(ConfigPath)) { + Console.WriteLine("[Config] Creating new config.ini..."); + CreateDefault(); + Save(); + } + else { + try { + Data = Parser.ReadFile(ConfigPath); + } + catch (Exception ex) { + Console.WriteLine($"[Config] Failed to read config.ini: {ex.Message}"); + Console.WriteLine("[Config] Recreating config with defaults."); + CreateDefault(); Save(); } } - private void CreateDefault() - { - Data = new IniData(); - Data["Server"]["Port"] = "14040"; - - Data["Discord"]["Events.WebhookUrl"] = ""; - Data["Discord"]["WebhookUrl"] = ""; - - foreach (Core.Enums.SubathonEventType type in Enum.GetValues(typeof(Core.Enums.SubathonEventType))) - { - if (type.GetLegacyGoAffProSiteId() > 0) continue; // retired per-store values - Data["Discord"][$"Events.Log.{type}"] = $"{false}"; - } - Data["Discord"]["Events.Log.Simulated"] = $"{false}"; - Data["Currency"]["Primary"] = "USD"; + if (string.IsNullOrWhiteSpace(Get("Currency", "Primary"))) { + Set("Currency", "Primary", "USD"); + Save(); } + } + + public virtual void Save() { + Parser.WriteFile(ConfigPath, Data); + PendingChanges = false; + SettingsEvents.RaiseSettingsUnsavedChanges(PendingChanges); + } + + public virtual string GetDatabasePath() { + return DatabasePath; + } + + public virtual string? Get(string section, string key, string? defaultValue = "") { + return Data[section][key] ?? defaultValue; + } + + public virtual bool GetBool(string section, string key, bool defaultValue = false) { + bool val = bool.TryParse(Get(section, key, defaultValue.ToString()), out bool b) ? b : defaultValue; + return val; + } - public virtual void Save() - { - Parser.WriteFile(ConfigPath, Data); - PendingChanges = false; + public string? GetFromEncoded(string section, string key, string? defaultValue = "") { + string val = Get(section, key, defaultValue) ?? string.Empty; + byte[] fromBase64String = Convert.FromBase64String(val); + return Encoding.UTF8.GetString(fromBase64String); + } + + public virtual bool Set(string section, string key, string? value) { + if (Data[section][key] != value) { + Data[section][key] = value ?? string.Empty; + PendingChanges = true; SettingsEvents.RaiseSettingsUnsavedChanges(PendingChanges); + return true; } - public virtual string GetDatabasePath() - { - return DatabasePath; - } + return false; + } - public virtual string? Get(string section, string key, string? defaultValue = "") - { - return Data[section][key] ?? defaultValue; - } + public virtual bool SetBool(string section, string key, bool? value) { + value ??= false; + return Set(section, key, ((bool)value).ToString()); + } - public virtual bool GetBool(string section, string key, bool defaultValue = false) - { - bool val = Boolean.TryParse(Get(section, key, defaultValue.ToString()), out bool b) ? b : defaultValue; - return val; - } + public bool SetEncoded(string section, string key, string value) { + byte[] bytes = Encoding.UTF8.GetBytes(value); + return Set(section, key, Convert.ToBase64String(bytes)); + } - public string? GetFromEncoded(string section, string key, string? defaultValue = "") - { - string val = Get(section, key, defaultValue) ?? string.Empty; - var fromBase64String = Convert.FromBase64String(val); - return System.Text.Encoding.UTF8.GetString(fromBase64String); - } + public OrderTypeModes GetOrderTypeMode(string section, string orderEnumName, OrderTypeModes modeDefault) { + string? stringVal = Get(section, $"{orderEnumName}.Mode", "Dollar"); + OrderTypeModes sourceMode = Enum.TryParse(stringVal, out OrderTypeModes m) ? m : OrderTypeModes.Dollar; + return sourceMode; + } - public virtual bool Set(string section, string key, string? value) - { - if (Data[section][key] != value) - { - Data[section][key] = value ?? string.Empty; - PendingChanges = true; - SettingsEvents.RaiseSettingsUnsavedChanges(PendingChanges); - return true; - } - return false; - } + public bool SetOrderTypeMode(string section, string orderEnumName, OrderTypeModes mode) { + return Set(section, $"{orderEnumName}.Mode", $"{mode}"); + } - public virtual bool SetBool(string section, string key, bool? value) - { - value ??= false; - return Set(section, key, ((bool)value).ToString()); - } + public string GetInstallId() { + string? id = Get("Telemetry", "InstallId"); + if (!string.IsNullOrWhiteSpace(id)) return id; - public bool SetEncoded(string section, string key, string value) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(value); - return Set(section, key, Convert.ToBase64String(bytes)); - } + id = Guid.NewGuid().ToString(); + Set("Telemetry", "InstallId", id); + Save(); + return id; + } - public OrderTypeModes GetOrderTypeMode(string section, string orderEnumName, OrderTypeModes modeDefault) - { - var stringVal = Get(section, $"{orderEnumName}.Mode", "Dollar"); - var sourceMode = Enum.TryParse(stringVal, out OrderTypeModes m) ? m : OrderTypeModes.Dollar; - return sourceMode; - } + private void CreateDefault() { + Data = new IniData(); + Data["Server"]["Port"] = "14040"; - public bool SetOrderTypeMode(string section, string orderEnumName, OrderTypeModes mode) - { - return Set(section, $"{orderEnumName}.Mode", $"{mode}"); - } + Data["Discord"]["Events.WebhookUrl"] = ""; + Data["Discord"]["WebhookUrl"] = ""; - public string GetInstallId() - { - var id = Get("Telemetry", "InstallId", ""); - if (!string.IsNullOrWhiteSpace(id)) return id; - - id = Guid.NewGuid().ToString(); - Set("Telemetry", "InstallId", id); - Save(); - return id; + foreach (SubathonEventType type in Enum.GetValues(typeof(SubathonEventType))) { + if (type.GetLegacyGoAffProSiteId() > 0) continue; // retired per-store values + Data["Discord"][$"Events.Log.{type}"] = $"{false}"; } + + Data["Discord"]["Events.Log.Simulated"] = $"{false}"; + Data["Currency"]["Primary"] = "USD"; } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/FeatureFlags.cs b/SubathonManager.Core/FeatureFlags.cs index 34677cab..837247e6 100644 --- a/SubathonManager.Core/FeatureFlags.cs +++ b/SubathonManager.Core/FeatureFlags.cs @@ -1,6 +1,5 @@ namespace SubathonManager.Core; -public static class FeatureFlags -{ +public static class FeatureFlags { public static readonly bool KoFiStreamerBotSetupEnabled = false; -} +} \ No newline at end of file diff --git a/SubathonManager.Core/GoAffProConfigMigration.cs b/SubathonManager.Core/GoAffProConfigMigration.cs index d086e301..b9bb9b65 100644 --- a/SubathonManager.Core/GoAffProConfigMigration.cs +++ b/SubathonManager.Core/GoAffProConfigMigration.cs @@ -1,22 +1,20 @@ using System.Diagnostics.CodeAnalysis; +using IniParser.Model; using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; namespace SubathonManager.Core; [ExcludeFromCodeCoverage] -public static class GoAffProConfigMigration -{ - public static bool Run(IConfig config) - { - bool migrated = false; - var discord = config.GetSection("Discord"); +public static class GoAffProConfigMigration { + public static bool Run(IConfig config) { + var migrated = false; + KeyDataCollection? discord = config.GetSection("Discord"); if (discord == null) return false; - foreach (var legacyType in Enum.GetValues() + foreach (SubathonEventType legacyType in Enum.GetValues() .Where(t => t.GetSource() == SubathonEventSource.GoAffPro - && t.GetLegacyGoAffProSiteId() > 0)) - { + && t.GetLegacyGoAffProSiteId() > 0)) { var oldKey = $"Events.Log.{legacyType}"; if (!discord.ContainsKey(oldKey)) continue; @@ -30,4 +28,4 @@ public static bool Run(IConfig config) if (migrated) config.Save(); return migrated; } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/MakeShipTrackingRegistry.cs b/SubathonManager.Core/MakeShipTrackingRegistry.cs index aeb0612d..b73a40c2 100644 --- a/SubathonManager.Core/MakeShipTrackingRegistry.cs +++ b/SubathonManager.Core/MakeShipTrackingRegistry.cs @@ -1,57 +1,57 @@ - using System.Globalization; using SubathonManager.Core.Enums; using SubathonManager.Core.Models; namespace SubathonManager.Core; -public static class MakeShipTrackingRegistry -{ +public static class MakeShipTrackingRegistry { public const string PetitionUrlPrefix = "https://www.makeship.com/petitions/"; public const string ProductUrlPrefix = "https://www.makeship.com/products/"; - - private static TextInfo _textInfo = new CultureInfo("en-US",false).TextInfo; + + private static readonly TextInfo _textInfo = new CultureInfo("en-US", false).TextInfo; private static readonly Lock Lock = new(); private static readonly Dictionary ById = new(); public static event Action? TrackingUpdated; - public static void Initialize(IEnumerable trackings) - { - lock (Lock) - { + public static void Initialize(IEnumerable trackings) { + lock (Lock) { ById.Clear(); - foreach (var tracking in trackings) + foreach (MakeShipTracking tracking in trackings) ById[tracking.Id] = tracking; } } - public static List All() - { - lock (Lock) { return ById.Values.ToList(); } + public static List All() { + lock (Lock) { + return ById.Values.ToList(); + } } - public static bool TryGet(Guid id, out MakeShipTracking? tracking) - { - lock (Lock) { return ById.TryGetValue(id, out tracking); } + public static bool TryGet(Guid id, out MakeShipTracking? tracking) { + lock (Lock) { + return ById.TryGetValue(id, out tracking); + } } - public static void Upsert(MakeShipTracking tracking) - { - lock (Lock) { ById[tracking.Id] = tracking; } + public static void Upsert(MakeShipTracking tracking) { + lock (Lock) { + ById[tracking.Id] = tracking; + } } - public static void Remove(Guid id) - { - lock (Lock) { ById.Remove(id); } + public static void Remove(Guid id) { + lock (Lock) { + ById.Remove(id); + } } - public static void RaiseTrackingUpdated(MakeShipTracking tracking) - => TrackingUpdated?.Invoke(tracking); + public static void RaiseTrackingUpdated(MakeShipTracking tracking) { + TrackingUpdated?.Invoke(tracking); + } - public static MakeShipProductType ClassifyUrl(string? url) - { + public static MakeShipProductType ClassifyUrl(string? url) { if (string.IsNullOrWhiteSpace(url)) return MakeShipProductType.Invalid; url = url.Trim(); if (url.StartsWith(PetitionUrlPrefix, StringComparison.OrdinalIgnoreCase) @@ -63,17 +63,15 @@ public static MakeShipProductType ClassifyUrl(string? url) return MakeShipProductType.Invalid; } - public static string GetSlug(string? url) - { + public static string GetSlug(string? url) { if (string.IsNullOrWhiteSpace(url)) return ""; - var trimmed = url.Trim().Split('?')[0].Split('#')[0].TrimEnd('/'); + string trimmed = url.Trim().Split('?')[0].Split('#')[0].TrimEnd('/'); int idx = trimmed.LastIndexOf('/'); return idx >= 0 && idx < trimmed.Length - 1 ? trimmed[(idx + 1)..] : ""; } - public static string GetDisplayNameFromSlug(string? url) - { - var slug = GetSlug(url); + public static string GetDisplayNameFromSlug(string? url) { + string slug = GetSlug(url); return string.IsNullOrEmpty(slug) ? "" : _textInfo.ToTitleCase(slug.Replace('-', ' ').Replace('_', ' ')); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/OrderEventHelper.cs b/SubathonManager.Core/OrderEventHelper.cs index 56684ba0..f365502b 100644 --- a/SubathonManager.Core/OrderEventHelper.cs +++ b/SubathonManager.Core/OrderEventHelper.cs @@ -6,8 +6,7 @@ namespace SubathonManager.Core; -public static class JuniperStoreRegistry -{ +public static class JuniperStoreRegistry { private static readonly Dictionary ByProductId = new(); private static readonly Dictionary ByStoreId = new(); private static readonly object Lock = new(); @@ -16,137 +15,131 @@ public static class JuniperStoreRegistry public static event Action? StoreUpdated; public static event Action? ProductUpdated; - public static void Initialize(IEnumerable stores) - { - lock (Lock) - { + public static void Initialize(IEnumerable stores) { + lock (Lock) { ByProductId.Clear(); ByStoreId.Clear(); - foreach (var store in stores) + foreach (JuniperStore store in stores) RegisterCore(store); } } - public static void RegisterStore(JuniperStore store) - { - lock (Lock) { RegisterCore(store); } + public static void RegisterStore(JuniperStore store) { + lock (Lock) { + RegisterCore(store); + } } - private static void RegisterCore(JuniperStore store) - { + private static void RegisterCore(JuniperStore store) { ByStoreId[store.RowId] = store; - foreach (var product in store.Products) + foreach (JuniperProduct product in store.Products) ByProductId[product.ProductId] = product; } - public static void RegisterProduct(JuniperProduct product) - { - lock (Lock) - { + public static void RegisterProduct(JuniperProduct product) { + lock (Lock) { ByProductId[product.ProductId] = product; - if (ByStoreId.TryGetValue(product.StoreId, out var store) && + if (ByStoreId.TryGetValue(product.StoreId, out JuniperStore? store) && store.Products.All(p => p.ProductId != product.ProductId)) store.Products.Add(product); } } - - public static bool TryParseProductId(string? value, out BigInteger productId) - { + + public static bool TryParseProductId(string? value, out BigInteger productId) { productId = default; return !string.IsNullOrWhiteSpace(value) && BigInteger.TryParse(value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out productId) && productId > 0; } - public static bool TryGetProduct(BigInteger productId, [NotNullWhen(true)] out JuniperProduct? product) - { - lock (Lock) { return ByProductId.TryGetValue(productId, out product); } + public static bool TryGetProduct(BigInteger productId, [NotNullWhen(true)] out JuniperProduct? product) { + lock (Lock) { + return ByProductId.TryGetValue(productId, out product); + } } - public static bool TryGetProduct(string? productId, [NotNullWhen(true)] out JuniperProduct? product) - { + public static bool TryGetProduct(string? productId, [NotNullWhen(true)] out JuniperProduct? product) { product = null; - return TryParseProductId(productId, out var id) && TryGetProduct(id, out product); + return TryParseProductId(productId, out BigInteger id) && TryGetProduct(id, out product); } - - public static List AllValidProductIds() - { - lock (Lock) - { + + public static List AllValidProductIds() { + lock (Lock) { return ByProductId.Values.Where(p => p.Valid).Select(p => p.ProductId).ToList(); } } - public static bool TryGetStore(Guid rowId, [NotNullWhen(true)] out JuniperStore? store) - { - lock (Lock) { return ByStoreId.TryGetValue(rowId, out store); } + public static bool TryGetStore(Guid rowId, [NotNullWhen(true)] out JuniperStore? store) { + lock (Lock) { + return ByStoreId.TryGetValue(rowId, out store); + } } - - public static string GetStoreName(Guid rowId) - { - return TryGetStore(rowId, out var store) ? store.StoreName : string.Empty; + + public static string GetStoreName(Guid rowId) { + return TryGetStore(rowId, out JuniperStore? store) ? store.StoreName : string.Empty; } - public static IReadOnlyList AllStores() - { - lock (Lock) { return ByStoreId.Values.OrderBy(s => s.StoreName, StringComparer.OrdinalIgnoreCase).ToList(); } + public static IReadOnlyList AllStores() { + lock (Lock) { + return ByStoreId.Values.OrderBy(s => s.StoreName, StringComparer.OrdinalIgnoreCase).ToList(); + } } - public static IReadOnlyList AllProducts() - { - lock (Lock) { return ByProductId.Values.OrderBy(p => p.ProductName, StringComparer.OrdinalIgnoreCase).ToList(); } + public static IReadOnlyList AllProducts() { + lock (Lock) { + return ByProductId.Values.OrderBy(p => p.ProductName, StringComparer.OrdinalIgnoreCase).ToList(); + } } - public static void RemoveProduct(BigInteger productId) - { - lock (Lock) - { - if (!ByProductId.Remove(productId, out var product)) return; - if (ByStoreId.TryGetValue(product.StoreId, out var store)) + public static void RemoveProduct(BigInteger productId) { + lock (Lock) { + if (!ByProductId.Remove(productId, out JuniperProduct? product)) return; + if (ByStoreId.TryGetValue(product.StoreId, out JuniperStore? store)) store.Products.RemoveAll(p => p.ProductId == productId); } } - public static void RemoveStore(Guid rowId) - { - lock (Lock) - { - if (!ByStoreId.Remove(rowId, out var store)) return; - foreach (var product in store.Products) + public static void RemoveStore(Guid rowId) { + lock (Lock) { + if (!ByStoreId.Remove(rowId, out JuniperStore? store)) return; + foreach (JuniperProduct product in store.Products) ByProductId.Remove(product.ProductId); } } - public static JuniperStore GetOrProvisionStore(string storeName) - { + public static JuniperStore GetOrProvisionStore(string storeName) { JuniperStore store; - lock (Lock) - { - var existing = ByStoreId.Values.FirstOrDefault(s => + lock (Lock) { + JuniperStore? existing = ByStoreId.Values.FirstOrDefault(s => string.Equals(s.StoreName, storeName, StringComparison.OrdinalIgnoreCase)); if (existing != null) return existing; store = new JuniperStore { RowId = Guid.NewGuid(), StoreName = storeName }; RegisterCore(store); } + StoreDiscovered?.Invoke(store); return store; } - public static void RaiseStoreUpdated(JuniperStore store) => StoreUpdated?.Invoke(store); - public static void RaiseProductUpdated(JuniperProduct product) => ProductUpdated?.Invoke(product); + public static void RaiseStoreUpdated(JuniperStore store) { + StoreUpdated?.Invoke(store); + } - public static bool TryParseProductUrl(string? url, out string storeName, out BigInteger productId) - { + public static void RaiseProductUpdated(JuniperProduct product) { + ProductUpdated?.Invoke(product); + } + + public static bool TryParseProductUrl(string? url, out string storeName, out BigInteger productId) { storeName = ""; productId = default; if (string.IsNullOrWhiteSpace(url)) return false; - if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) || + if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out Uri? uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) return false; - var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + string[] segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); int pIndex = Array.FindIndex(segments, s => string.Equals(s, "p", StringComparison.OrdinalIgnoreCase)); if (pIndex < 0 || pIndex + 1 >= segments.Length) return false; if (!TryParseProductId(segments[pIndex + 1], out productId)) return false; @@ -157,26 +150,21 @@ public static bool TryParseProductUrl(string? url, out string storeName, out Big } [ExcludeFromCodeCoverage] -public static class JuniperOrderHelper -{ - public static bool TryGetProduct(string? meta, [NotNullWhen(true)] out JuniperProduct? product) - { +public static class JuniperOrderHelper { + public static bool TryGetProduct(string? meta, [NotNullWhen(true)] out JuniperProduct? product) { product = null; return !string.IsNullOrWhiteSpace(meta) && JuniperStoreRegistry.TryGetProduct(meta, out product); } - public static string GetProductLabel(string? meta) - { - if (!TryGetProduct(meta, out var product)) + public static string GetProductLabel(string? meta) { + if (!TryGetProduct(meta, out JuniperProduct? product)) return $"Juniper Product ({meta})"; return product.ProductName; } - public static string GetStoreLabel(string? meta) - { - if (TryGetProduct(meta, out var product)) - { - var name = JuniperStoreRegistry.GetStoreName(product.StoreId); + public static string GetStoreLabel(string? meta) { + if (TryGetProduct(meta, out JuniperProduct? product)) { + string name = JuniperStoreRegistry.GetStoreName(product.StoreId); if (!string.IsNullOrWhiteSpace(name)) return name; } @@ -184,49 +172,41 @@ public static string GetStoreLabel(string? meta) } } - [ExcludeFromCodeCoverage] -public static class OrderMetaFilter -{ - public static bool Matches(SubathonEventType? eventType, string? eventMeta, string? filter) - { +public static class OrderMetaFilter { + public static bool Matches(SubathonEventType? eventType, string? eventMeta, string? filter) { if (string.IsNullOrEmpty(filter)) return true; if (string.Equals(filter, eventMeta, StringComparison.OrdinalIgnoreCase)) return true; - if (eventType == SubathonEventType.JuniperMerchSale && Guid.TryParse(filter, out var storeId) - && JuniperOrderHelper.TryGetProduct(eventMeta, out var product)) + if (eventType == SubathonEventType.JuniperMerchSale && Guid.TryParse(filter, out Guid storeId) + && JuniperOrderHelper.TryGetProduct(eventMeta, + out JuniperProduct? product)) return product.StoreId == storeId; return false; } - public static int Specificity(SubathonEventType? eventType, string? eventMeta, string? filter) - { + public static int Specificity(SubathonEventType? eventType, string? eventMeta, string? filter) { if (string.IsNullOrEmpty(filter)) return 0; if (string.Equals(filter, eventMeta, StringComparison.OrdinalIgnoreCase)) return 2; return Matches(eventType, eventMeta, filter) ? 1 : -1; } - public static string Describe(SubathonEventType? eventType, string? filter) - { + public static string Describe(SubathonEventType? eventType, string? filter) { if (string.IsNullOrEmpty(filter)) - { - return eventType switch - { + return eventType switch { SubathonEventType.MakeShipPledge => "Any Pledge", SubathonEventType.MakeShipSale or SubathonEventType.JuniperMerchSale => "Any Sale", SubathonEventType.GoAffProOrder => "Any Order", _ => "Any" }; - } - if (eventType == SubathonEventType.JuniperMerchSale) - { - if (Guid.TryParse(filter, out var storeId)) - { - var storeName = JuniperStoreRegistry.TryGetStore(storeId, out var store) + if (eventType == SubathonEventType.JuniperMerchSale) { + if (Guid.TryParse(filter, out Guid storeId)) { + string storeName = JuniperStoreRegistry.TryGetStore(storeId, out JuniperStore? store) ? store.StoreName : "Unknown Store"; return $"{storeName} - Any Sale"; } + return $"{JuniperOrderHelper.GetStoreLabel(filter)} - {JuniperOrderHelper.GetProductLabel(filter)}"; } @@ -237,8 +217,7 @@ public static string Describe(SubathonEventType? eventType, string? filter) } } -public static class GoAffProStoreRegistry -{ +public static class GoAffProStoreRegistry { private static readonly Dictionary BySiteId = new(); private static readonly Dictionary ByInternalName = new(); private static readonly HashSet ActiveSiteIds = new(); @@ -246,64 +225,59 @@ public static class GoAffProStoreRegistry public static event Action? StoreDiscovered; - public static void Initialize(IEnumerable stores) - { - lock (Lock) - { + public static void Initialize(IEnumerable stores) { + lock (Lock) { BySiteId.Clear(); ByInternalName.Clear(); - foreach (var store in stores) + foreach (GoAffProStore store in stores) Register(store); } } - public static void Register(GoAffProStore store) - { - lock (Lock) - { + public static void Register(GoAffProStore store) { + lock (Lock) { BySiteId[store.SiteId] = store; ByInternalName[store.InternalName] = store; } } - public static bool TryGetBySiteId(int siteId, [NotNullWhen(true)] out GoAffProStore? store) - { - lock (Lock) { return BySiteId.TryGetValue(siteId, out store); } + public static bool TryGetBySiteId(int siteId, [NotNullWhen(true)] out GoAffProStore? store) { + lock (Lock) { + return BySiteId.TryGetValue(siteId, out store); + } } - public static bool TryGetByInternalName(string name, [NotNullWhen(true)] out GoAffProStore? store) - { - lock (Lock) { return ByInternalName.TryGetValue(name, out store); } + public static bool TryGetByInternalName(string name, [NotNullWhen(true)] out GoAffProStore? store) { + lock (Lock) { + return ByInternalName.TryGetValue(name, out store); + } } - public static IReadOnlyList All() - { - lock (Lock) { return BySiteId.Values.OrderBy(s => s.RowId).ToList(); } + public static IReadOnlyList All() { + lock (Lock) { + return BySiteId.Values.OrderBy(s => s.RowId).ToList(); + } } - public static void MarkActiveOnAccount(int siteId, bool active = true) - { - lock (Lock) - { + public static void MarkActiveOnAccount(int siteId, bool active = true) { + lock (Lock) { if (active) ActiveSiteIds.Add(siteId); else ActiveSiteIds.Remove(siteId); } } - public static bool IsActiveOnAccount(int siteId) - { - lock (Lock) { return ActiveSiteIds.Contains(siteId); } + public static bool IsActiveOnAccount(int siteId) { + lock (Lock) { + return ActiveSiteIds.Contains(siteId); + } } - public static GoAffProStore GetOrProvision(int siteId, string fallbackName = "") - { - lock (Lock) - { - if (BySiteId.TryGetValue(siteId, out var existing)) + public static GoAffProStore GetOrProvision(int siteId, string fallbackName = "") { + lock (Lock) { + if (BySiteId.TryGetValue(siteId, out GoAffProStore? existing)) return existing; - var store = new GoAffProStore - { + var store = new GoAffProStore { SiteId = siteId, StoreName = string.IsNullOrWhiteSpace(fallbackName) ? $"Unknown Store ({siteId})" @@ -322,57 +296,49 @@ public static GoAffProStore GetOrProvision(int siteId, string fallbackName = "") } [ExcludeFromCodeCoverage] -public static class GoAffProOrderHelper -{ - public static bool TryParseMeta(string? meta, out int siteId) - { +public static class GoAffProOrderHelper { + public static bool TryParseMeta(string? meta, out int siteId) { siteId = -1; return meta != null && int.TryParse(meta, out siteId); } - public static bool TryGetStore(string? meta, [NotNullWhen(true)] out GoAffProStore? store) - { + public static bool TryGetStore(string? meta, [NotNullWhen(true)] out GoAffProStore? store) { store = null; - return TryParseMeta(meta, out var siteId) && GoAffProStoreRegistry.TryGetBySiteId(siteId, out store); + return TryParseMeta(meta, out int siteId) && GoAffProStoreRegistry.TryGetBySiteId(siteId, out store); } - public static string GetOrderLabel(string? meta) - { - if (!TryGetStore(meta, out var store)) + public static string GetOrderLabel(string? meta) { + if (!TryGetStore(meta, out GoAffProStore? store)) return $"GoAffPro Order ({meta})"; return store.EventName; } - public static string GetLabel(string? meta) - { - if (!TryGetStore(meta, out var store)) + public static string GetLabel(string? meta) { + if (!TryGetStore(meta, out GoAffProStore? store)) return $"GoAffPro Store ({meta})"; return store.StoreName; } - public static string GetOrderKey(SubathonEventType? eventType, string? meta = null) - { + public static string GetOrderKey(SubathonEventType? eventType, string? meta = null) { if (eventType != SubathonEventType.GoAffProOrder) return eventType?.ToString() ?? string.Empty; if (string.IsNullOrEmpty(meta)) return "GoAffProOrder"; - if (TryGetStore(meta, out var store)) + if (TryGetStore(meta, out GoAffProStore? store)) return store.InternalEventName; return $"GoAffProOrder_{meta}"; } - public static bool TryGetStoreByOrderKey(string key, [NotNullWhen(true)] out GoAffProStore? store) - { + public static bool TryGetStoreByOrderKey(string key, [NotNullWhen(true)] out GoAffProStore? store) { store = GoAffProStoreRegistry.All() .FirstOrDefault(s => string.Equals(s.InternalEventName, key, StringComparison.OrdinalIgnoreCase)); return store != null; } - public static string GetOrderEventDisplayLabel(SubathonEventType? eventType, string? meta = null) - { + public static string GetOrderEventDisplayLabel(SubathonEventType? eventType, string? meta = null) { if (eventType != SubathonEventType.GoAffProOrder) return eventType.GetLabel(); return string.IsNullOrEmpty(meta) @@ -380,12 +346,11 @@ public static string GetOrderEventDisplayLabel(SubathonEventType? eventType, str : GetOrderLabel(meta); } - public static string GetOrderEventDisplayDescription(SubathonEventType? eventType, string? meta = null) - { + public static string GetOrderEventDisplayDescription(SubathonEventType? eventType, string? meta = null) { if (eventType != SubathonEventType.GoAffProOrder) return eventType?.GetDescription() ?? string.Empty; return string.IsNullOrEmpty(meta) ? "GoAffPro Order" : GetOrderLabel(meta); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/ResourcePaths.cs b/SubathonManager.Core/ResourcePaths.cs index 3a4a5633..7e59a2cc 100644 --- a/SubathonManager.Core/ResourcePaths.cs +++ b/SubathonManager.Core/ResourcePaths.cs @@ -2,29 +2,26 @@ namespace SubathonManager.Core; -public static partial class ResourcePaths -{ +public static partial class ResourcePaths { public const string UrlPrefix = "/resources/"; public const string BundleFolder = "resources"; + public static readonly string[] DefaultFolders = ["images", "images/logos", "audio"]; // think of more later? public static string Root => Path.GetFullPath("./resources"); - public static readonly string[] DefaultFolders = ["images", "images/logos", "audio"]; // think of more later? - public static void EnsureCreated() - { - try - { + public static void EnsureCreated() { + try { Directory.CreateDirectory(Root); - foreach (var folder in DefaultFolders) + foreach (string folder in DefaultFolders) Directory.CreateDirectory(Path.Combine(Root, folder.Replace('/', Path.DirectorySeparatorChar))); } - catch { /**/ } + catch { + /**/ + } } - public static List EnumerateRelative() - { - try - { + public static List EnumerateRelative() { + try { string root = Root; if (!Directory.Exists(root)) return []; @@ -34,26 +31,24 @@ public static List EnumerateRelative() .OrderBy(rel => rel, StringComparer.OrdinalIgnoreCase) .ToList(); } - catch { return []; } + catch { + return []; + } } - public static IEnumerable FindReferences(string? text) - { + public static IEnumerable FindReferences(string? text) { if (string.IsNullOrEmpty(text)) yield break; - foreach (Match m in ReferenceRegex().Matches(text)) - { + foreach (Match m in ReferenceRegex().Matches(text)) { string? rel = NormalizeReference(m.Groups[1].Value); if (rel != null) yield return rel; } } - public static string RewriteReferences(string text, Func prefixFor) - { + public static string RewriteReferences(string text, Func prefixFor) { if (string.IsNullOrEmpty(text)) return text; - return ReferenceRegex().Replace(text, m => - { + return ReferenceRegex().Replace(text, m => { string? rel = NormalizeReference(m.Groups[1].Value); if (rel == null) return m.Value; @@ -62,33 +57,34 @@ public static string RewriteReferences(string text, Func prefix }); } - private static string? NormalizeReference(string captured) - { + private static string? NormalizeReference(string captured) { string rel = captured.Split('?')[0].Split('#')[0].Trim('/'); if (rel.Length == 0) return null; - try { return Uri.UnescapeDataString(rel); } - catch { return rel; } + try { + return Uri.UnescapeDataString(rel); + } + catch { + return rel; + } } - public static string? RelativeFromUrl(string? value) - { + public static string? RelativeFromUrl(string? value) { if (!IsResourceUrl(value)) return null; string rel = value!.Replace('\\', '/')[UrlPrefix.Length..].Split('?')[0].Split('#')[0].Trim('/'); return rel.Length == 0 ? null : rel; } - public static bool IsResourceUrl(string? value) - => !string.IsNullOrWhiteSpace(value) - && value.Replace('\\', '/').StartsWith(UrlPrefix, StringComparison.OrdinalIgnoreCase); + public static bool IsResourceUrl(string? value) { + return !string.IsNullOrWhiteSpace(value) + && value.Replace('\\', '/').StartsWith(UrlPrefix, StringComparison.OrdinalIgnoreCase); + } - public static string? ToResourceUrl(string? localPath) - { + public static string? ToResourceUrl(string? localPath) { if (string.IsNullOrWhiteSpace(localPath)) return null; if (IsResourceUrl(localPath)) return localPath.Replace('\\', '/'); - try - { + try { string root = Root; string full = Path.GetFullPath(localPath); @@ -98,15 +94,15 @@ public static bool IsResourceUrl(string? value) string relative = full[(root.Length + 1)..].Replace('\\', '/').Trim('/'); return relative.Length == 0 ? null : UrlPrefix + relative; } - catch { return null; } + catch { + return null; + } } - public static string? ToLocalPath(string? resourceUrl) - { + public static string? ToLocalPath(string? resourceUrl) { if (!IsResourceUrl(resourceUrl)) return null; - try - { + try { string relative = resourceUrl!.Replace('\\', '/')[UrlPrefix.Length..].Trim('/'); string root = Root; string full = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); @@ -115,14 +111,15 @@ public static bool IsResourceUrl(string? value) ? full : null; } - catch { return null; } + catch { + return null; + } } [GeneratedRegex(@"(?)]+)", RegexOptions.IgnoreCase)] private static partial Regex ReferenceRegex(); - public static string? ResolveRequestPath(string? requestPath) - { + public static string? ResolveRequestPath(string? requestPath) { if (string.IsNullOrWhiteSpace(requestPath)) return null; string relative = requestPath.Split('?')[0]; @@ -132,8 +129,7 @@ public static bool IsResourceUrl(string? value) if (relative.Length == 0) return null; - try - { + try { relative = Uri.UnescapeDataString(relative); if (relative.IndexOfAny(Path.GetInvalidPathChars()) >= 0) return null; @@ -145,6 +141,8 @@ public static bool IsResourceUrl(string? value) return File.Exists(full) ? full : null; } - catch { return null; } + catch { + return null; + } } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/RotatingFileLoggerProvider.cs b/SubathonManager.Core/RotatingFileLoggerProvider.cs index aff84e4e..08f3d8b2 100644 --- a/SubathonManager.Core/RotatingFileLoggerProvider.cs +++ b/SubathonManager.Core/RotatingFileLoggerProvider.cs @@ -1,37 +1,40 @@ -using System.Text; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; using Microsoft.Extensions.Logging; -using System.Diagnostics.CodeAnalysis; - -namespace SubathonManager.Core -{ - [ExcludeFromCodeCoverage] - public class RotatingFileLoggerProvider : ILoggerProvider - { - private readonly string _logDir; - private readonly TimeSpan _retention; - private readonly SemaphoreSlim _lock = new(1, 1); - private string _currentLogFile; - private DateTime _lastRotationDate = DateTime.Now.Date; - - public RotatingFileLoggerProvider(string logDirectory, int retentionDays = 30) - { - _logDir = logDirectory; - _retention = TimeSpan.FromDays(retentionDays); - Directory.CreateDirectory(_logDir); - _currentLogFile = GetLogFilePath(DateTime.Now); - _ = Task.Run(CleanupOldLogsAsync); - } - public ILogger CreateLogger(string categoryName) => - new RotatingFileLogger(categoryName, this); +namespace SubathonManager.Core; + +[ExcludeFromCodeCoverage] +public class RotatingFileLoggerProvider : ILoggerProvider { + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly string _logDir; + private readonly TimeSpan _retention; + private string _currentLogFile; + private DateTime _lastRotationDate = DateTime.Now.Date; + + public RotatingFileLoggerProvider(string logDirectory, int retentionDays = 30) { + _logDir = logDirectory; + _retention = TimeSpan.FromDays(retentionDays); + Directory.CreateDirectory(_logDir); + _currentLogFile = GetLogFilePath(DateTime.Now); + _ = Task.Run(CleanupOldLogsAsync); + } + + public ILogger CreateLogger(string categoryName) { + return new RotatingFileLogger(categoryName, this); + } - public void Dispose() => _lock.Dispose(); + public void Dispose() { + _lock.Dispose(); + } - private string GetLogFilePath(DateTime date) => - Path.Combine(_logDir, $"app_{date:yyyy-MM-dd}.log"); + private string GetLogFilePath(DateTime date) { + return Path.Combine(_logDir, $"app_{date:yyyy-MM-dd}.log"); + } - private static string GetShortLevel(LogLevel level) => level switch - { + private static string GetShortLevel(LogLevel level) { + return level switch { LogLevel.Trace => "TRACE", LogLevel.Debug => "DEBUG", LogLevel.Information => "INFO", @@ -40,144 +43,129 @@ private string GetLogFilePath(DateTime date) => LogLevel.Critical => "CRIT", _ => "NONE" }; - - internal async Task WriteAsync(string category, LogLevel level, string message, Exception? ex) - { - await _lock.WaitAsync(); - try - { - RotateIfNeeded(); - - var sb = new StringBuilder() - .Append('[').Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append("] ") - .Append('[').Append(GetShortLevel(level)).Append("] ") - .Append('[').Append(category).Append("] ") - .Append(message); - - if (ex != null) - sb.Append(" Exception: ").Append(ex); - - sb.AppendLine(); - - await File.AppendAllTextAsync(_currentLogFile, sb.ToString(), Encoding.UTF8); - } - finally - { - _lock.Release(); - } + } + + internal async Task WriteAsync(string category, LogLevel level, string message, Exception? ex) { + await _lock.WaitAsync(); + try { + RotateIfNeeded(); + + StringBuilder sb = new StringBuilder() + .Append('[').Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")).Append("] ") + .Append('[').Append(GetShortLevel(level)).Append("] ") + .Append('[').Append(category).Append("] ") + .Append(message); + + if (ex != null) + sb.Append(" Exception: ").Append(ex); + + sb.AppendLine(); + + await File.AppendAllTextAsync(_currentLogFile, sb.ToString(), Encoding.UTF8); } + finally { + _lock.Release(); + } + } - private void RotateIfNeeded() - { - var now = DateTime.Now; - if (now.Date != _lastRotationDate) - { - _currentLogFile = GetLogFilePath(now); - _lastRotationDate = now.Date; - _ = Task.Run(CleanupOldLogsAsync); - } + private void RotateIfNeeded() { + DateTime now = DateTime.Now; + if (now.Date != _lastRotationDate) { + _currentLogFile = GetLogFilePath(now); + _lastRotationDate = now.Date; + _ = Task.Run(CleanupOldLogsAsync); } + } - private async Task CleanupOldLogsAsync() - { - try - { - var files = Directory.GetFiles(_logDir, "app_*.log"); - var cutoff = DateTime.Now - _retention; - - foreach (var file in files) - { - var name = Path.GetFileNameWithoutExtension(file); - if (name.StartsWith("app_") && - DateTime.TryParseExact(name[4..], "yyyy-MM-dd", null, - System.Globalization.DateTimeStyles.None, out var date) && - date < cutoff) - { - try { File.Delete(file); } catch { /**/ } + private async Task CleanupOldLogsAsync() { + try { + string[] files = Directory.GetFiles(_logDir, "app_*.log"); + DateTime cutoff = DateTime.Now - _retention; + + foreach (string file in files) { + string name = Path.GetFileNameWithoutExtension(file); + if (name.StartsWith("app_") && + DateTime.TryParseExact(name[4..], "yyyy-MM-dd", null, + DateTimeStyles.None, out DateTime date) && + date < cutoff) + try { + File.Delete(file); + } + catch { + /**/ } - } - - await Task.CompletedTask; } - catch {/**/} + + await Task.CompletedTask; + } + catch { + /**/ } } +} - [ExcludeFromCodeCoverage] - internal class RotatingFileLogger : ILogger - { - private readonly string _category; - private readonly RotatingFileLoggerProvider _provider; +[ExcludeFromCodeCoverage] +internal class RotatingFileLogger : ILogger { + private readonly string _category; + private readonly RotatingFileLoggerProvider _provider; - public RotatingFileLogger(string category, RotatingFileLoggerProvider provider) - { - _category = category; - _provider = provider; - } + public RotatingFileLogger(string category, RotatingFileLoggerProvider provider) { + _category = category; + _provider = provider; + } - public IDisposable? BeginScope(TState state) where TState : notnull - => null; - - public bool IsEnabled(LogLevel logLevel) - { - if (_category.StartsWith("Microsoft.EntityFrameworkCore", StringComparison.OrdinalIgnoreCase)) - return logLevel >= LogLevel.Warning; - return logLevel != LogLevel.None; - } + public IDisposable? BeginScope(TState state) where TState : notnull { + return null; + } - public void Log(LogLevel logLevel, EventId eventId, TState state, - Exception? exception, Func formatter) - { - if (!IsEnabled(logLevel)) return; + public bool IsEnabled(LogLevel logLevel) { + if (_category.StartsWith("Microsoft.EntityFrameworkCore", StringComparison.OrdinalIgnoreCase)) + return logLevel >= LogLevel.Warning; + return logLevel != LogLevel.None; + } - var message = formatter(state, exception); - - if (_category.StartsWith("Streamlabs.SocketClient", StringComparison.OrdinalIgnoreCase)) - { - // ignore many StreamLabs errors as we only use it for tips, but it still ingests all events?? - // won't ignore logging for dev/console, but will for file output at least - var text = (message + " " + exception?.Message); - - if (text.Contains("JSON property 'type' could not be mapped", StringComparison.OrdinalIgnoreCase) || - text.Contains("The input string '' was not in a correct format", StringComparison.OrdinalIgnoreCase) || - text.Contains("The JSON value could not be converted to ", StringComparison.OrdinalIgnoreCase)) - logLevel = LogLevel.Debug; - - if (text.Contains("deserializing event", StringComparison.OrdinalIgnoreCase) && - !text.Contains("\"type\":\"donation\"", StringComparison.OrdinalIgnoreCase)) - logLevel = LogLevel.Debug; - - if (!IsEnabled(logLevel)) return; - } - else if (_category.Contains("PicartoEventsLib", StringComparison.OrdinalIgnoreCase)) - { - var text = (message + " " + exception?.Message); - if (text.Contains("WebSocket receive loop error", StringComparison.OrdinalIgnoreCase) && - text.Contains("without completing the close handshake")) - { - logLevel = LogLevel.Debug; - } - if (!IsEnabled(logLevel)) return; - } - else if (_category.Contains("YTLiveChat", StringComparison.OrdinalIgnoreCase)) - { - var text = (message + " " + exception?.Message); - if (text.Contains("Live Stream canonical link not found", StringComparison.OrdinalIgnoreCase) - || text.Contains("Failed to get or parse initial options") - || text.Contains("Initial Continuation token not found", StringComparison.OrdinalIgnoreCase)) - { - logLevel = LogLevel.None; - } - else if (text.Contains("Listener task finished execution") || text.Contains("Fetching initial options") || - text.Contains("Polling task completed normally") || text.Contains("Attempting to start listener") - || text.Contains("Listener task starting initialization")) - { - logLevel = LogLevel.Debug; - } - if (!IsEnabled(logLevel)) return; - } - - _ = _provider.WriteAsync(_category, logLevel, message, exception); + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) { + if (!IsEnabled(logLevel)) return; + + string message = formatter(state, exception); + + if (_category.StartsWith("Streamlabs.SocketClient", StringComparison.OrdinalIgnoreCase)) { + // ignore many StreamLabs errors as we only use it for tips, but it still ingests all events?? + // won't ignore logging for dev/console, but will for file output at least + string text = message + " " + exception?.Message; + + if (text.Contains("JSON property 'type' could not be mapped", StringComparison.OrdinalIgnoreCase) || + text.Contains("The input string '' was not in a correct format", StringComparison.OrdinalIgnoreCase) || + text.Contains("The JSON value could not be converted to ", StringComparison.OrdinalIgnoreCase)) + logLevel = LogLevel.Debug; + + if (text.Contains("deserializing event", StringComparison.OrdinalIgnoreCase) && + !text.Contains("\"type\":\"donation\"", StringComparison.OrdinalIgnoreCase)) + logLevel = LogLevel.Debug; + + if (!IsEnabled(logLevel)) return; } + else if (_category.Contains("PicartoEventsLib", StringComparison.OrdinalIgnoreCase)) { + string text = message + " " + exception?.Message; + if (text.Contains("WebSocket receive loop error", StringComparison.OrdinalIgnoreCase) && + text.Contains("without completing the close handshake")) + logLevel = LogLevel.Debug; + if (!IsEnabled(logLevel)) return; + } + else if (_category.Contains("YTLiveChat", StringComparison.OrdinalIgnoreCase)) { + string text = message + " " + exception?.Message; + if (text.Contains("Live Stream canonical link not found", StringComparison.OrdinalIgnoreCase) + || text.Contains("Failed to get or parse initial options") + || text.Contains("Initial Continuation token not found", StringComparison.OrdinalIgnoreCase)) + logLevel = LogLevel.None; + else if (text.Contains("Listener task finished execution") || text.Contains("Fetching initial options") || + text.Contains("Polling task completed normally") || text.Contains("Attempting to start listener") + || text.Contains("Listener task starting initialization")) + logLevel = LogLevel.Debug; + if (!IsEnabled(logLevel)) return; + } + + _ = _provider.WriteAsync(_category, logLevel, message, exception); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/SafeFileName.cs b/SubathonManager.Core/SafeFileName.cs index 4b9db491..c93c0bf5 100644 --- a/SubathonManager.Core/SafeFileName.cs +++ b/SubathonManager.Core/SafeFileName.cs @@ -3,46 +3,43 @@ namespace SubathonManager.Core; -public static class SafeFileName -{ +public static class SafeFileName { public const string DefaultReplacement = "_"; public const int MaxLength = 255; private static readonly HashSet InvalidChars = BuildInvalidChars(); - private static HashSet BuildInvalidChars() - { + private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) { + "CON", "PRN", "AUX", "NUL", + "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + + private static HashSet BuildInvalidChars() { var chars = new HashSet("\"<>|:*?\\/"); - for (char c = (char)0; c <= (char)31; c++) chars.Add(c); + for (var c = (char)0; c <= (char)31; c++) chars.Add(c); chars.Add((char)127); foreach (char c in Path.GetInvalidFileNameChars()) chars.Add(c); return chars; } - private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) - { - "CON", "PRN", "AUX", "NUL", - "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" - }; - - public static bool IsInvalidChar(char value) => InvalidChars.Contains(value); + public static bool IsInvalidChar(char value) { + return InvalidChars.Contains(value); + } - public static bool IsSafe([NotNullWhen(true)] string? value) - => !string.IsNullOrWhiteSpace(value) && string.Equals(Sanitize(value), value, StringComparison.Ordinal); + public static bool IsSafe([NotNullWhen(true)] string? value) { + return !string.IsNullOrWhiteSpace(value) && string.Equals(Sanitize(value), value, StringComparison.Ordinal); + } public static string Sanitize(string? value, string replacement = DefaultReplacement, - string fallback = "") - { + string fallback = "") { if (string.IsNullOrWhiteSpace(value)) return fallback; var sb = new StringBuilder(value.Length); foreach (char c in value.Trim()) - { if (IsInvalidChar(c)) sb.Append(replacement); else sb.Append(c); - } string result = sb.ToString().TrimEnd(' ', '.'); @@ -52,9 +49,8 @@ public static string Sanitize(string? value, string replacement = DefaultReplace return IsReserved(result) ? DefaultReplacement + result : result; } - private static bool IsReserved(string name) - { + private static bool IsReserved(string name) { int dot = name.IndexOf('.'); return ReservedNames.Contains(dot < 0 ? name : name[..dot]); } -} +} \ No newline at end of file diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index 3e6d54af..98f2fa44 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -1,10 +1,10 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; -using System.Text.RegularExpressions; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; @@ -12,24 +12,24 @@ namespace SubathonManager.Core; -public static class Utils -{ - +public static class Utils { public static readonly Dictionary DonationSettings = new(); - private static readonly ConcurrentDictionary<(SubathonEventSource Source, string Service), IntegrationConnection> ConnectionDetails = new(); - + + private static readonly ConcurrentDictionary<(SubathonEventSource Source, string Service), IntegrationConnection> + ConnectionDetails = new(); + public static string? PendingOverlayImportPath { get; set; } public static string? PendingWidgetPackImportPath { get; set; } public static OAuthCallback? PendingOAuthCallback { get; set; } - - public static IEnumerable GetAllConnections() - => ConnectionDetails.Values; - public static IntegrationConnection GetConnection(SubathonEventSource source, string service) - { - ConnectionDetails.TryGetValue((source, service), out var conn); + + public static IEnumerable GetAllConnections() { + return ConnectionDetails.Values; + } + + public static IntegrationConnection GetConnection(SubathonEventSource source, string service) { + ConnectionDetails.TryGetValue((source, service), out IntegrationConnection? conn); if (conn != null) return conn; - conn = new IntegrationConnection() - { + conn = new IntegrationConnection { Source = source, Service = service, Name = "", @@ -40,10 +40,9 @@ public static IntegrationConnection GetConnection(SubathonEventSource source, st return conn; } - - public static void UpdateConnection(IntegrationConnection connection) - { - var key = (connection.Source, connection.Service); + + public static void UpdateConnection(IntegrationConnection connection) { + (SubathonEventSource Source, string Service) key = (connection.Source, connection.Service); ConnectionDetails.AddOrUpdate( key, @@ -51,11 +50,9 @@ public static void UpdateConnection(IntegrationConnection connection) (_, _) => connection ); } - - public static TimeSpan ParseDurationString(string input) - { - if (string.IsNullOrWhiteSpace(input)) - { + + public static TimeSpan ParseDurationString(string input) { + if (string.IsNullOrWhiteSpace(input)) { Console.WriteLine($"Error - Invalid TimeString to Parse: {input}"); return TimeSpan.Zero; } @@ -65,21 +62,17 @@ public static TimeSpan ParseDurationString(string input) return ParseColonDurationString(input); return ParseLetterDurationString(input); - } - private static TimeSpan ParseColonDurationString(string input) - { - var parts = input.Replace(".", ":").Split(":"); - int[] values = Array.ConvertAll(parts, p => - { - if (int.TryParse(p, out var v)) return v; + private static TimeSpan ParseColonDurationString(string input) { + string[] parts = input.Replace(".", ":").Split(":"); + int[] values = Array.ConvertAll(parts, p => { + if (int.TryParse(p, out int v)) return v; return 0; }); int days = 0, hours = 0, minutes = 0, seconds = 0; - - switch (values.Length) - { + + switch (values.Length) { case 4: days = values[0]; hours = values[1]; @@ -99,54 +92,40 @@ private static TimeSpan ParseColonDurationString(string input) seconds = values[0]; break; } + return new TimeSpan(days, hours, minutes, seconds); } - private static TimeSpan ParseLetterDurationString(string input) - { - if (input.All(char.IsDigit)) - { - return new TimeSpan(0, 0, 0, int.Parse(input)); - } + private static TimeSpan ParseLetterDurationString(string input) { + if (input.All(char.IsDigit)) return new TimeSpan(0, 0, 0, int.Parse(input)); var regex = new Regex(@"(\d+d|\d+h|\d+m|\d+s)", RegexOptions.IgnoreCase); - + int days = 0, hours = 0, minutes = 0, seconds = 0; - foreach(Match match in regex.Matches(input.ToLower())) - { + foreach (Match match in regex.Matches(input.ToLower())) { if (!match.Success) continue; if (match.ToString().ToLower().Contains("d")) - { days += int.Parse(match.ToString().Replace("d", "")); - } else if (match.ToString().ToLower().Contains("h")) - { hours += int.Parse(match.ToString().Replace("h", "")); - } else if (match.ToString().ToLower().Contains("m")) - { minutes += int.Parse(match.ToString().Replace("m", "")); - } - else if (match.ToString().ToLower().Contains("s")) - { - seconds += int.Parse(match.ToString().Replace("s", "")); - } + else if (match.ToString().ToLower().Contains("s")) seconds += int.Parse(match.ToString().Replace("s", "")); } - return new TimeSpan(days, hours, minutes, seconds); + + return new TimeSpan(days, hours, minutes, seconds); } - - public static Guid TryParseGuid(string? value) - { - if (value != null && Guid.TryParse(value, out var g)) return g; + + public static Guid TryParseGuid(string? value) { + if (value != null && Guid.TryParse(value, out Guid g)) return g; return CreateGuidFromUniqueString(value ?? Guid.NewGuid().ToString()); } - public static Guid CreateGuidFromUniqueString(string? key) - { + public static Guid CreateGuidFromUniqueString(string? key) { if (string.IsNullOrWhiteSpace(key)) return Guid.Empty; using var sha1 = SHA1.Create(); byte[] hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(key)); - byte[] guidBytes = new byte[16]; + var guidBytes = new byte[16]; Array.Copy(hash, guidBytes, 16); guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | (5 << 4)); @@ -154,17 +133,15 @@ public static Guid CreateGuidFromUniqueString(string? key) return new Guid(guidBytes); } - - public static string TryParseCurrency(string amountString) - { - string currency = ""; - var match = Regex.Match(amountString, @"^(?[A-Z]{3})(?![A-Z])"); - if (match.Success) + + public static string TryParseCurrency(string amountString) { + var currency = ""; + Match match = Regex.Match(amountString, @"^(?[A-Z]{3})(?![A-Z])"); + if (match.Success) { currency = match.Groups["code"].Value; - else - { - if (amountString.Contains('$')) - { + } + else { + if (amountString.Contains('$')) { if (amountString.StartsWith("A$")) currency = "AUD"; else if (amountString.StartsWith("CA$")) currency = "CAD"; else if (amountString.StartsWith("R$")) currency = "BRL"; @@ -173,15 +150,13 @@ public static string TryParseCurrency(string amountString) else if (amountString.StartsWith("NT$")) currency = "TWD"; else if (amountString.StartsWith("NZ$")) currency = "NZD"; } - else if (amountString.Contains("₨")) - { + else if (amountString.Contains("₨")) { if (amountString.StartsWith("PK₨")) currency = "PKR"; else if (amountString.StartsWith("LK₨")) currency = "LKR"; else if (amountString.StartsWith("MU₨")) currency = "MUR"; else if (amountString.StartsWith("NP₨")) currency = "NPR"; } - else - { + else { if (amountString.StartsWith("₩")) currency = "KRW"; else if (amountString.StartsWith("₱")) currency = "PHP"; else if (amountString.StartsWith("₫")) currency = "VND"; @@ -199,80 +174,83 @@ public static string TryParseCurrency(string amountString) else if (amountString.StartsWith("₣")) currency = "CHF"; } } + return currency; } - - public static string EscapeCsv(string? value) - { + + public static string EscapeCsv(string? value) { if (string.IsNullOrWhiteSpace(value)) return ""; - if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) - { + if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) { value = value.Replace("\"", "\"\""); return $"\"{value}\""; } + return value; } - - public static (bool, double) GetAltCurrencyUseAsDonation(IConfig config, SubathonEventType? eventType) - { + + public static (bool, double) GetAltCurrencyUseAsDonation(IConfig config, SubathonEventType? eventType) { double modifier = 1; if (!eventType.IsToken()) return (false, 1); if (eventType != SubathonEventType.TwitchCheer && eventType != SubathonEventType.PicartoTip) - { double.TryParse(config.Get("Extensions", $"{eventType}.Modifier", "1"), out modifier); - } - bool useAsDonation = config.GetBool("Currency", "BitsLikeAsDonation", false); + bool useAsDonation = config.GetBool("Currency", "BitsLikeAsDonation"); return (useAsDonation, modifier); } - public static DateTime? GetAccessTokenExpiry(string accessToken) - { - try - { - var parts = accessToken.Split('.'); + public static DateTime? GetAccessTokenExpiry(string accessToken) { + try { + string[] parts = accessToken.Split('.'); if (parts.Length != 3) - return null; - var payload = parts[1]; + return null; + string payload = parts[1]; payload = payload.Replace('-', '+').Replace('_', '/'); - switch (payload.Length % 4) - { + switch (payload.Length % 4) { case 2: payload += "=="; break; case 3: payload += "="; break; } - var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); - using var doc = JsonDocument.Parse(json); + string json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + using JsonDocument doc = JsonDocument.Parse(json); - if (!doc.RootElement.TryGetProperty("exp", out var expProp)) - return null; + if (!doc.RootElement.TryGetProperty("exp", out JsonElement expProp)) + return null; - var expUnix = expProp.GetInt64(); + long expUnix = expProp.GetInt64(); return DateTimeOffset.FromUnixTimeSeconds(expUnix).UtcDateTime; } - catch - { + catch { return null; } } - - public sealed class ServiceReconnectState : IDisposable - { + + public static bool IsCommissionAsDonation(IConfig config, SubathonEvent ev) { + if (!ev.EventType.IsOrder()) return false; + + if (ev.EventType == SubathonEventType.GoAffProOrder) { + if (string.IsNullOrEmpty(ev.EventTypeMeta)) return false; + if (!GoAffProOrderHelper.TryGetStore(ev.EventTypeMeta, out GoAffProStore? store)) return false; + return config.GetBool("GoAffPro", $"{store.InternalName}.CommissionAsDonation"); + } + + return config.GetBool( + ev.EventType.GetSource().ToString(), + $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", + ev.EventType.GetSource() != SubathonEventSource.GoAffPro); + } + + public sealed class ServiceReconnectState : IDisposable { + public readonly SemaphoreSlim Lock = new(1, 1); public TimeSpan Backoff = TimeSpan.FromSeconds(2); - public int MaxRetries = 100; - public TimeSpan MaxBackoff = TimeSpan.FromMinutes(5); public CancellationTokenSource? Cts; - public readonly SemaphoreSlim Lock = new(1, 1); - public int Retries = 0; - public bool InfiniteRetries = false; - - private TimeSpan InitialBackOff { get; init; } - private TimeSpan InitialMaxBackOff { get; init; } - private int InitialMaxRetries { get; init; } - - public ServiceReconnectState(TimeSpan backoff, int maxRetries, TimeSpan maxBackoff, bool infiniteRetries = false) - { + public bool InfiniteRetries; + public TimeSpan MaxBackoff = TimeSpan.FromMinutes(5); + public int MaxRetries = 100; + public int Retries; + + public ServiceReconnectState(TimeSpan backoff, int maxRetries, TimeSpan maxBackoff, + bool infiniteRetries = false) { Backoff = backoff; MaxRetries = maxRetries; MaxBackoff = maxBackoff; @@ -281,37 +259,37 @@ public ServiceReconnectState(TimeSpan backoff, int maxRetries, TimeSpan maxBacko InitialMaxRetries = MaxRetries; InfiniteRetries = infiniteRetries; } - + public ServiceReconnectState() { InitialBackOff = Backoff; InitialMaxBackOff = MaxBackoff; InitialMaxRetries = MaxRetries; } - public async Task IsReconnecting() - { - return !(await Lock.WaitAsync(0)); + private TimeSpan InitialBackOff { get; } + private TimeSpan InitialMaxBackOff { get; } + private int InitialMaxRetries { get; } + + public void Dispose() { + Cts?.Cancel(); + Cts?.Dispose(); + } + + public async Task IsReconnecting() { + return !await Lock.WaitAsync(0); } - public void Reset() - { + public void Reset() { Backoff = InitialBackOff; MaxRetries = InitialMaxRetries; MaxBackoff = InitialMaxBackOff; Retries = 0; } - - public void Dispose() - { - Cts?.Cancel(); - Cts?.Dispose(); - } } - - + + [ExcludeFromCodeCoverage] - public static class SingleInstanceHelper - { + public static class SingleInstanceHelper { public const int WM_SHOWAPP = 0x0400 + 1; public const int WM_COPYDATA = 0x004A; @@ -321,24 +299,14 @@ public static extern bool PostMessage( int msg, IntPtr wparam, IntPtr lparam); - - [StructLayout(LayoutKind.Sequential)] - public struct COPYDATASTRUCT - { - public IntPtr dwData; - public int cbData; - public IntPtr lpData; - } - + [DllImport("user32")] public static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, ref COPYDATASTRUCT lParam); - public static void SendStringMessage(IntPtr hWnd, ProtocolMessageType type, string message) - { - var bytes = Encoding.Unicode.GetBytes(message); + public static void SendStringMessage(IntPtr hWnd, ProtocolMessageType type, string message) { + byte[] bytes = Encoding.Unicode.GetBytes(message); - var cds = new COPYDATASTRUCT - { + var cds = new COPYDATASTRUCT { cbData = bytes.Length, lpData = Marshal.AllocHGlobal(bytes.Length) }; @@ -349,22 +317,12 @@ public static void SendStringMessage(IntPtr hWnd, ProtocolMessageType type, stri Marshal.FreeHGlobal(cds.lpData); } - } - public static bool IsCommissionAsDonation(IConfig config, SubathonEvent ev) - { - if (!ev.EventType.IsOrder()) return false; - - if (ev.EventType == SubathonEventType.GoAffProOrder) - { - if (string.IsNullOrEmpty(ev.EventTypeMeta)) return false; - if (!GoAffProOrderHelper.TryGetStore(ev.EventTypeMeta, out var store)) return false; - return config.GetBool("GoAffPro", $"{store.InternalName}.CommissionAsDonation", false); + [StructLayout(LayoutKind.Sequential)] + public struct COPYDATASTRUCT { + public IntPtr dwData; + public int cbData; + public IntPtr lpData; } - - return config.GetBool( - ev.EventType.GetSource().ToString(), - $"{ev.EventType.ToString()?.Split("Order")[0]}.CommissionAsDonation", - ev.EventType.GetSource() != SubathonEventSource.GoAffPro); } } \ No newline at end of file From f2d1ab1e806dacdcd1ae8235261df05c119b9889 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:04:31 -0300 Subject: [PATCH 07/39] format data proj --- SubathonManager.Data/AppDbContextFactory.cs | 25 +- SubathonManager.Data/DbContext.cs | 1198 ++++++++--------- .../Extensions/SubathonQueryExtensions.cs | 26 +- SubathonManager.Data/OverlayPorter.cs | 393 +++--- .../Overlays/OverlayPackInstaller.cs | 65 +- .../Overlays/OverlayPackPaths.cs | 46 +- SubathonManager.Data/StateValueHelper.cs | 65 +- .../SubathonValueConfigHelper.cs | 99 +- SubathonManager.Data/WidgetEntityHelper.cs | 365 ++--- SubathonManager.Data/WidgetPorter.cs | 327 ++--- SubathonManager.Data/Widgets/WidgetCatalog.cs | 389 +++--- .../Widgets/WidgetCollectionInstaller.cs | 89 +- .../Widgets/WidgetDisplayHelper.cs | 39 +- .../Widgets/WidgetPackFileSystem.cs | 60 +- .../Widgets/WidgetPackInstaller.cs | 160 ++- .../Widgets/WidgetPackMemoryCache.cs | 67 +- .../Widgets/WidgetPackPaths.cs | 226 ++-- .../Widgets/WidgetPackReader.cs | 102 +- 18 files changed, 1725 insertions(+), 2016 deletions(-) diff --git a/SubathonManager.Data/AppDbContextFactory.cs b/SubathonManager.Data/AppDbContextFactory.cs index 2de6b323..fe3d1c1f 100644 --- a/SubathonManager.Data/AppDbContextFactory.cs +++ b/SubathonManager.Data/AppDbContextFactory.cs @@ -2,20 +2,17 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -namespace SubathonManager.Data -{ - // to allow migrations add cmdline to work - [ExcludeFromCodeCoverage] - public class AppDbContextFactory - : IDesignTimeDbContextFactory - { - public AppDbContext CreateDbContext(string[] args) - { - var options = new DbContextOptionsBuilder(); +namespace SubathonManager.Data; - options.UseSqlite("Data Source=data/design.db"); +// to allow migrations add cmdline to work +[ExcludeFromCodeCoverage] +public class AppDbContextFactory + : IDesignTimeDbContextFactory { + public AppDbContext CreateDbContext(string[] args) { + var options = new DbContextOptionsBuilder(); - return new AppDbContext(options.Options); - } + options.UseSqlite("Data Source=data/design.db"); + + return new AppDbContext(options.Options); } -} +} \ No newline at end of file diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index d5cc5af1..030abff1 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -1,666 +1,624 @@ -using Microsoft.EntityFrameworkCore; using System.Diagnostics; +using System.Numerics; using System.Text; -using SubathonManager.Core.Models; -using SubathonManager.Core.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + // ReSharper disable NullableWarningSuppressionIsUsed -namespace SubathonManager.Data -{ - public class AppDbContext : DbContext - { - public DbSet Routes => Set(); - public DbSet Widgets => Set(); - - public DbSet CssVariables => Set(); - public DbSet JsVariables => Set(); - - public DbSet SubathonEvents { get; set; } - public DbSet SubathonValues { get; set; } - - public DbSet SubathonDatas { get; set; } - public DbSet MultiplierDatas { get; set; } - - public DbSet SubathonGoals { get; set; } - public DbSet SubathonGoalSets { get; set; } - - public DbSet SubathonPromptSets { get; set; } - public DbSet SubathonPrompts { get; set; } - public DbSet SubathonPromptRuns { get; set; } - - public DbSet WheelSets { get; set; } - public DbSet WheelItems { get; set; } - public DbSet WheelSpinActions { get; set; } - public DbSet WheelSpinHistories { get; set; } - public DbSet WheelSpinTriggers { get; set; } - public DbSet WheelSpinTriggerHistories { get; set; } - - public DbSet GoAffProStores { get; set; } - public DbSet MakeShipTrackings { get; set; } - public DbSet JuniperStores { get; set; } - public DbSet JuniperProducts { get; set; } - - public DbSet StateValues { get; set; } - - public DbSet WidgetCatalogEntries => Set(); - - public AppDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - if (!optionsBuilder.IsConfigured) // allows migrations to be added still in dev - { - var dbPath = Config.DatabasePath; - Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); - optionsBuilder.UseSqlite($"Data Source={dbPath}"); - } - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity() - .HasMany(r => r.Widgets) - .WithOne(w => w.Route) - .HasForeignKey(w => w.RouteId) - .OnDelete(DeleteBehavior.Cascade); // deleting route deletes its widgets - - modelBuilder.Entity() - .HasMany(w => w.CssVariables) - .WithOne(cv => cv.Widget) - .HasForeignKey(cv => cv.WidgetId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasMany(w => w.JsVariables) - .WithOne(cv => cv.Widget) - .HasForeignKey(cv => cv.WidgetId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasKey(e => new { e.Id, e.Source }); - - modelBuilder.Entity() - .HasOne(e => e.LinkedSubathon) - .WithMany() - .HasForeignKey(e => e.SubathonId) - .IsRequired(false); - - modelBuilder.Entity() - .HasKey(sv => new { sv.EventType, sv.Meta }); - - modelBuilder.Entity() - .HasMany(s => s.Goals) - .WithOne(g => g.LinkedGoalSet) - .HasForeignKey(g => g.GoalSetId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity().HasOne(s => s.Multiplier) - .WithOne(m => m.LinkedSubathon).HasForeignKey(m => m.SubathonId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasMany(s => s.Prompts) - .WithOne(p => p.LinkedSet) - .HasForeignKey(p => p.SetId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(r => r.LinkedPrompt) - .WithMany() - .HasForeignKey(r => r.PromptId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(r => r.LinkedSet) - .WithMany() - .HasForeignKey(r => r.SetId) - .OnDelete(DeleteBehavior.NoAction); - - modelBuilder.Entity() - .Property(s => s.Interval) - .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); - - modelBuilder.Entity() - .Property(s => s.RandomOffset) - .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); - - modelBuilder.Entity() - .Property(s => s.Cooldown) - .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); - - modelBuilder.Entity() - .Property(p => p.CompletionDuration) - .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); - - modelBuilder.Entity() - .HasMany(w => w.WheelItems) - .WithOne(i => i.LinkedWheel) - .HasForeignKey(i => i.WheelId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(i => i.Action) - .WithOne(a => a.LinkedItem) - .HasForeignKey(a => a.WheelItemId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(h => h.LinkedItem) - .WithMany() - .HasForeignKey(h => h.WheelItemId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(h => h.LinkedWheel) - .WithMany() - .HasForeignKey(h => h.WheelId) - .OnDelete(DeleteBehavior.NoAction); - - modelBuilder.Entity() - .HasMany(t => t.History) - .WithOne(h => h.Trigger) - .HasForeignKey(h => h.TriggerId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasKey(e => new { e.RowId }); - modelBuilder.Entity() - .HasIndex(s => new { s.SiteId }) - .IsUnique(); - - modelBuilder.Entity() - .HasIndex(e => e.PackPath) - .IsUnique(); - - modelBuilder.Entity() - .Property(p => p.ProductId) - .HasConversion(v => v.ToString(), v => System.Numerics.BigInteger.Parse(v)) - .ValueGeneratedNever(); - modelBuilder.Entity() - .HasOne(p => p.Store) - .WithMany(s => s.Products) - .HasForeignKey(p => p.StoreId) - .OnDelete(DeleteBehavior.Cascade); - } +namespace SubathonManager.Data; - public override int SaveChanges() - { - UpdateTimestamps(); - return base.SaveChanges(); - } +public class AppDbContext : DbContext { + public AppDbContext(DbContextOptions options) + : base(options) { + } + + public DbSet Routes => Set(); + public DbSet Widgets => Set(); + + public DbSet CssVariables => Set(); + public DbSet JsVariables => Set(); + + public DbSet SubathonEvents { get; set; } + public DbSet SubathonValues { get; set; } + + public DbSet SubathonDatas { get; set; } + public DbSet MultiplierDatas { get; set; } + + public DbSet SubathonGoals { get; set; } + public DbSet SubathonGoalSets { get; set; } - public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + public DbSet SubathonPromptSets { get; set; } + public DbSet SubathonPrompts { get; set; } + public DbSet SubathonPromptRuns { get; set; } + + public DbSet WheelSets { get; set; } + public DbSet WheelItems { get; set; } + public DbSet WheelSpinActions { get; set; } + public DbSet WheelSpinHistories { get; set; } + public DbSet WheelSpinTriggers { get; set; } + public DbSet WheelSpinTriggerHistories { get; set; } + + public DbSet GoAffProStores { get; set; } + public DbSet MakeShipTrackings { get; set; } + public DbSet JuniperStores { get; set; } + public DbSet JuniperProducts { get; set; } + + public DbSet StateValues { get; set; } + + public DbSet WidgetCatalogEntries => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { + if (!optionsBuilder.IsConfigured) // allows migrations to be added still in dev { - UpdateTimestamps(); - return await base.SaveChangesAsync(cancellationToken); + string dbPath = Config.DatabasePath; + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + optionsBuilder.UseSqlite($"Data Source={dbPath}"); } + } - private void UpdateTimestamps() - { - var now = DateTime.UtcNow; - - foreach (var entry in ChangeTracker.Entries()) - { - if (entry.State == EntityState.Added) - { - entry.Entity.CreatedTimestamp = now; - entry.Entity.UpdatedTimestamp = now; - } - else if (entry.State == EntityState.Modified) - { - entry.Entity.UpdatedTimestamp = now; - } + protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity() + .HasMany(r => r.Widgets) + .WithOne(w => w.Route) + .HasForeignKey(w => w.RouteId) + .OnDelete(DeleteBehavior.Cascade); // deleting route deletes its widgets + + modelBuilder.Entity() + .HasMany(w => w.CssVariables) + .WithOne(cv => cv.Widget) + .HasForeignKey(cv => cv.WidgetId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(w => w.JsVariables) + .WithOne(cv => cv.Widget) + .HasForeignKey(cv => cv.WidgetId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasKey(e => new { e.Id, e.Source }); + + modelBuilder.Entity() + .HasOne(e => e.LinkedSubathon) + .WithMany() + .HasForeignKey(e => e.SubathonId) + .IsRequired(false); + + modelBuilder.Entity() + .HasKey(sv => new { sv.EventType, sv.Meta }); + + modelBuilder.Entity() + .HasMany(s => s.Goals) + .WithOne(g => g.LinkedGoalSet) + .HasForeignKey(g => g.GoalSetId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity().HasOne(s => s.Multiplier) + .WithOne(m => m.LinkedSubathon).HasForeignKey(m => m.SubathonId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(s => s.Prompts) + .WithOne(p => p.LinkedSet) + .HasForeignKey(p => p.SetId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(r => r.LinkedPrompt) + .WithMany() + .HasForeignKey(r => r.PromptId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(r => r.LinkedSet) + .WithMany() + .HasForeignKey(r => r.SetId) + .OnDelete(DeleteBehavior.NoAction); + + modelBuilder.Entity() + .Property(s => s.Interval) + .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); + + modelBuilder.Entity() + .Property(s => s.RandomOffset) + .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); + + modelBuilder.Entity() + .Property(s => s.Cooldown) + .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); + + modelBuilder.Entity() + .Property(p => p.CompletionDuration) + .HasConversion(ts => ts.Ticks, ticks => TimeSpan.FromTicks(ticks)); + + modelBuilder.Entity() + .HasMany(w => w.WheelItems) + .WithOne(i => i.LinkedWheel) + .HasForeignKey(i => i.WheelId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(i => i.Action) + .WithOne(a => a.LinkedItem) + .HasForeignKey(a => a.WheelItemId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(h => h.LinkedItem) + .WithMany() + .HasForeignKey(h => h.WheelItemId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(h => h.LinkedWheel) + .WithMany() + .HasForeignKey(h => h.WheelId) + .OnDelete(DeleteBehavior.NoAction); + + modelBuilder.Entity() + .HasMany(t => t.History) + .WithOne(h => h.Trigger) + .HasForeignKey(h => h.TriggerId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasKey(e => new { e.RowId }); + modelBuilder.Entity() + .HasIndex(s => new { s.SiteId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(e => e.PackPath) + .IsUnique(); + + modelBuilder.Entity() + .Property(p => p.ProductId) + .HasConversion(v => v.ToString(), v => BigInteger.Parse(v)) + .ValueGeneratedNever(); + modelBuilder.Entity() + .HasOne(p => p.Store) + .WithMany(s => s.Products) + .HasForeignKey(p => p.StoreId) + .OnDelete(DeleteBehavior.Cascade); + } + + public override int SaveChanges() { + UpdateTimestamps(); + return base.SaveChanges(); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { + UpdateTimestamps(); + return await base.SaveChangesAsync(cancellationToken); + } + + private void UpdateTimestamps() { + DateTime now = DateTime.UtcNow; + + foreach (EntityEntry entry in ChangeTracker.Entries()) + if (entry.State == EntityState.Added) { + entry.Entity.CreatedTimestamp = now; + entry.Entity.UpdatedTimestamp = now; + } + else if (entry.State == EntityState.Modified) { + entry.Entity.UpdatedTimestamp = now; } - // If a widget or cssvariable/jsvariable changes, update its parent route’s timestamp - foreach (var entry in ChangeTracker.Entries()) - { - if (entry.State == EntityState.Added || entry.State == EntityState.Modified || - entry.State == EntityState.Deleted) - { - var route = Routes.FirstOrDefault(r => r.Id == entry.Entity.RouteId); + // If a widget or cssvariable/jsvariable changes, update its parent route’s timestamp + foreach (EntityEntry entry in ChangeTracker.Entries()) + if (entry.State == EntityState.Added || entry.State == EntityState.Modified || + entry.State == EntityState.Deleted) { + Route? route = Routes.FirstOrDefault(r => r.Id == entry.Entity.RouteId); + if (route != null) + route.UpdatedTimestamp = now; + } + + foreach (EntityEntry entry in ChangeTracker.Entries()) + if (entry.State == EntityState.Added || entry.State == EntityState.Modified || + entry.State == EntityState.Deleted) { + Widget? widget = Widgets.FirstOrDefault(w => w.Id == entry.Entity.WidgetId); + if (widget != null) { + Route? route = Routes.FirstOrDefault(r => r.Id == widget.RouteId); if (route != null) route.UpdatedTimestamp = now; } } - foreach (var entry in ChangeTracker.Entries()) - { - if (entry.State == EntityState.Added || entry.State == EntityState.Modified || - entry.State == EntityState.Deleted) - { - var widget = Widgets.FirstOrDefault(w => w.Id == entry.Entity.WidgetId); - if (widget != null) - { - var route = Routes.FirstOrDefault(r => r.Id == widget.RouteId); - if (route != null) - route.UpdatedTimestamp = now; - } - } - } - - foreach (var entry in ChangeTracker.Entries()) - { - if (entry.State == EntityState.Added || entry.State == EntityState.Modified || - entry.State == EntityState.Deleted) - { - var widget = Widgets.FirstOrDefault(w => w.Id == entry.Entity.WidgetId); - if (widget != null) - { - var route = Routes.FirstOrDefault(r => r.Id == widget.RouteId); - if (route != null) - route.UpdatedTimestamp = now; - } + foreach (EntityEntry entry in ChangeTracker.Entries()) + if (entry.State == EntityState.Added || entry.State == EntityState.Modified || + entry.State == EntityState.Deleted) { + Widget? widget = Widgets.FirstOrDefault(w => w.Id == entry.Entity.WidgetId); + if (widget != null) { + Route? route = Routes.FirstOrDefault(r => r.Id == widget.RouteId); + if (route != null) + route.UpdatedTimestamp = now; } } - } - - public static async Task> GetSubathonCurrencyEvents(AppDbContext db) - { - SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); - if (subathon == null) return new List(); - - List events = await db.SubathonEvents.AsNoTracking().Where(e => - e.SubathonId == subathon.Id && e.ProcessedToSubathon && - !string.IsNullOrWhiteSpace(e.Currency) - && e.EventType != SubathonEventType.Command && !string.IsNullOrWhiteSpace(e.Value)) - .ToListAsync(); - - bool includeBits = Utils.DonationSettings.TryGetValue("BitsLikeAsDonation", out bool bitslike) && bitslike ; - List orderTypesToInclude = new List(); - foreach (var orderEvent in Enum.GetValues().Where(et => - ((SubathonEventType?)et).IsOrder() && !et.IsDisabled() - && et.GetSource() != SubathonEventSource.GoAffPro)) - { - bool asDonation = Utils.DonationSettings.TryGetValue($"{orderEvent.ToString()?.Split("Order")[0]}", out bool donation) && donation; - if (asDonation) - orderTypesToInclude.Add(orderEvent); - } + } - // GoAffPro is per store: commission-as-donation keyed by store InternalName, matched on event meta (SiteId) - HashSet goAffProMetasToInclude = GoAffProStoreRegistry.All() - .Where(s => Utils.DonationSettings.TryGetValue(s.InternalName, out bool donation) && donation) - .Select(s => s.SiteId.ToString()) - .ToHashSet(); - - events = events.Where(e => e.EventType != null && - (e.EventType.IsCurrencyDonation() || - (e.EventType == SubathonEventType.GoAffProOrder && - !string.IsNullOrEmpty(e.EventTypeMeta) && goAffProMetasToInclude.Contains(e.EventTypeMeta)) || - (e.EventType.IsOrder() && e.EventType != SubathonEventType.GoAffProOrder && - orderTypesToInclude.Contains((SubathonEventType)e.EventType)) || - (includeBits && e.EventType.IsToken()))).ToList(); - return events; + public static async Task> GetSubathonCurrencyEvents(AppDbContext db) { + SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); + if (subathon == null) return new List(); + + List events = await db.SubathonEvents.AsNoTracking().Where(e => + e.SubathonId == subathon.Id && e.ProcessedToSubathon && + !string.IsNullOrWhiteSpace(e.Currency) + && e.EventType != SubathonEventType.Command && !string.IsNullOrWhiteSpace(e.Value)) + .ToListAsync(); + + bool includeBits = Utils.DonationSettings.TryGetValue("BitsLikeAsDonation", out bool bitslike) && bitslike; + var orderTypesToInclude = new List(); + foreach (SubathonEventType orderEvent in Enum.GetValues().Where(et => + ((SubathonEventType?)et).IsOrder() && !et.IsDisabled() + && et.GetSource() != SubathonEventSource.GoAffPro)) { + bool asDonation = + Utils.DonationSettings.TryGetValue($"{orderEvent.ToString()?.Split("Order")[0]}", out bool donation) && + donation; + if (asDonation) + orderTypesToInclude.Add(orderEvent); } - public static async Task ActiveEventsToCsv(AppDbContext db) - { - SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); - if (subathon == null) return; - List events = await db.SubathonEvents.Where(ev => ev.SubathonId == subathon.Id) - .ToListAsync(); - - string exportDir = Path.Combine(Config.DataFolder, "exports"); - Directory.CreateDirectory(exportDir); - string filepath = $"{exportDir}/subathon-{subathon.Id}.csv"; - - var sb = new StringBuilder(); - sb.AppendLine("Id,Source,Type,Command,User,Seconds Value,Points Value,Value,Currency,Amount,Multiplier Seconds,Multiplier Points,Processed,Final Seconds Added,Final Points Added,Timestamp,Secondary Value,Event Meta Type, Event Meta Common Type"); - foreach (var e in events) - { - var val = e.Value; - var commonMeta = string.IsNullOrWhiteSpace(e.EventTypeMeta) ? "" : e.EventTypeMeta; - if (e.EventType is SubathonEventType.TwitchGiftSub or SubathonEventType.TwitchSub) - { - val = e.Value switch - { - "1000" => "T1", - "2000" => "T2", - "3000" => "T3", - _ => val - }; - commonMeta = val; - } - - if (e.EventType == SubathonEventType.GoAffProOrder - && GoAffProOrderHelper.TryGetStore(e.EventTypeMeta, out var store)) - { - commonMeta = store.InternalName; - } + // GoAffPro is per store: commission-as-donation keyed by store InternalName, matched on event meta (SiteId) + HashSet goAffProMetasToInclude = GoAffProStoreRegistry.All() + .Where(s => Utils.DonationSettings.TryGetValue(s.InternalName, out bool donation) && donation) + .Select(s => s.SiteId.ToString()) + .ToHashSet(); + + events = events.Where(e => e.EventType != null && + (e.EventType.IsCurrencyDonation() || + (e.EventType == SubathonEventType.GoAffProOrder && + !string.IsNullOrEmpty(e.EventTypeMeta) && + goAffProMetasToInclude.Contains(e.EventTypeMeta)) || + (e.EventType.IsOrder() && e.EventType != SubathonEventType.GoAffProOrder && + orderTypesToInclude.Contains((SubathonEventType)e.EventType)) || + (includeBits && e.EventType.IsToken()))).ToList(); + return events; + } - sb.AppendLine(string.Join(",", - e.Id, - Utils.EscapeCsv(e.Source.ToString()), - Utils.EscapeCsv(e.EventType.ToString()), - Utils.EscapeCsv(e.Command.ToString()), - Utils.EscapeCsv(e.User), - e.SecondsValue, - e.PointsValue, - Utils.EscapeCsv(val), - Utils.EscapeCsv(e.Currency), - e.Amount, - e.MultiplierSeconds, - e.MultiplierPoints, - e.ProcessedToSubathon, - e.GetFinalSecondsValueRaw(), - e.GetFinalPointsValue(), - e.EventTimestamp.ToString("yyyy-MM-dd HH:mm:ss"), - Utils.EscapeCsv(e.SecondaryValue), - Utils.EscapeCsv(e.EventTypeMeta), - Utils.EscapeCsv(commonMeta) - )); - } - await File.WriteAllTextAsync(filepath, sb.ToString(), Encoding.UTF8); - - try - { - bool isTest = - AppDomain.CurrentDomain.GetAssemblies() - .Any(a => a.FullName!.StartsWith("xunit", StringComparison.OrdinalIgnoreCase)); - if (!isTest) - { - Process.Start(new ProcessStartInfo - { - FileName = exportDir, - UseShellExecute = true, - Verb = "open" - }); - } + public static async Task ActiveEventsToCsv(AppDbContext db) { + SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); + if (subathon == null) return; + List events = await db.SubathonEvents.Where(ev => ev.SubathonId == subathon.Id) + .ToListAsync(); + + string exportDir = Path.Combine(Config.DataFolder, "exports"); + Directory.CreateDirectory(exportDir); + var filepath = $"{exportDir}/subathon-{subathon.Id}.csv"; + + var sb = new StringBuilder(); + sb.AppendLine( + "Id,Source,Type,Command,User,Seconds Value,Points Value,Value,Currency,Amount,Multiplier Seconds,Multiplier Points,Processed,Final Seconds Added,Final Points Added,Timestamp,Secondary Value,Event Meta Type, Event Meta Common Type"); + foreach (SubathonEvent e in events) { + string val = e.Value; + string? commonMeta = string.IsNullOrWhiteSpace(e.EventTypeMeta) ? "" : e.EventTypeMeta; + if (e.EventType is SubathonEventType.TwitchGiftSub or SubathonEventType.TwitchSub) { + val = e.Value switch { + "1000" => "T1", + "2000" => "T2", + "3000" => "T3", + _ => val + }; + commonMeta = val; } - catch {/**/} - } - public static async Task PauseAllTimers(AppDbContext db) - { - await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET IsPaused = 1"); + if (e.EventType == SubathonEventType.GoAffProOrder + && GoAffProOrderHelper.TryGetStore(e.EventTypeMeta, out GoAffProStore? store)) + commonMeta = store.InternalName; + + sb.AppendLine(string.Join(",", + e.Id, + Utils.EscapeCsv(e.Source.ToString()), + Utils.EscapeCsv(e.EventType.ToString()), + Utils.EscapeCsv(e.Command.ToString()), + Utils.EscapeCsv(e.User), + e.SecondsValue, + e.PointsValue, + Utils.EscapeCsv(val), + Utils.EscapeCsv(e.Currency), + e.Amount, + e.MultiplierSeconds, + e.MultiplierPoints, + e.ProcessedToSubathon, + e.GetFinalSecondsValueRaw(), + e.GetFinalPointsValue(), + e.EventTimestamp.ToString("yyyy-MM-dd HH:mm:ss"), + Utils.EscapeCsv(e.SecondaryValue), + Utils.EscapeCsv(e.EventTypeMeta), + Utils.EscapeCsv(commonMeta) + )); } - public static async Task ResetPowerHour(AppDbContext db) - { - await db.Database.ExecuteSqlRawAsync("UPDATE MultiplierDatas SET Multiplier = 1, Duration = null, ApplyToSeconds=false, ApplyToPoints=false, FromHypeTrain=false "); + await File.WriteAllTextAsync(filepath, sb.ToString(), Encoding.UTF8); + + try { + bool isTest = + AppDomain.CurrentDomain.GetAssemblies() + .Any(a => a.FullName!.StartsWith("xunit", StringComparison.OrdinalIgnoreCase)); + if (!isTest) + Process.Start(new ProcessStartInfo { + FileName = exportDir, + UseShellExecute = true, + Verb = "open" + }); } - - public static async Task DisableAllTimers(AppDbContext db) - { - await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET IsActive = 0"); - await PauseAllTimers(db); + catch { + /**/ } + } - public static async Task UpdateSubathonCurrency(AppDbContext db, string currency) - { - await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET Currency = {0} WHERE IsActive = 1", currency); + public static async Task PauseAllTimers(AppDbContext db) { + await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET IsPaused = 1"); + } + + public static async Task ResetPowerHour(AppDbContext db) { + await db.Database.ExecuteSqlRawAsync( + "UPDATE MultiplierDatas SET Multiplier = 1, Duration = null, ApplyToSeconds=false, ApplyToPoints=false, FromHypeTrain=false "); + } + + public static async Task DisableAllTimers(AppDbContext db) { + await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET IsActive = 0"); + await PauseAllTimers(db); + } + + public static async Task UpdateSubathonCurrency(AppDbContext db, string currency) { + await db.Database.ExecuteSqlRawAsync("UPDATE SubathonDatas SET Currency = {0} WHERE IsActive = 1", currency); + } + + public async Task UpdateSubathonMoney(double money, Guid subathonId) { + await Database.ExecuteSqlRawAsync( + "UPDATE SubathonDatas SET MoneySum = {0} WHERE IsActive = 1 and Id = {1}", money, subathonId); + SubathonData? subathon = await SubathonDatas.Include(s => s.Multiplier) + .AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); + if (subathon == null) return; + Entry(subathon).State = EntityState.Detached; + Entry(subathon.Multiplier).State = EntityState.Detached; + Core.Events.SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); + + SubathonGoalSet? goalSet = await SubathonGoalSets.AsNoTracking() + .Include(x => x.Goals).Where(x => x.IsActive) + .FirstOrDefaultAsync(); + if (goalSet != null && goalSet.Goals.Any() && goalSet.Type == GoalsType.Money) + Core.Events.SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, + subathon.GetRoundedMoneySum(), GoalsType.Money); + } + + public static void SeedDefaultValues(AppDbContext db) { + db.Database.ExecuteSqlRaw( + "UPDATE SubathonValues SET Meta = 'DEFAULT' WHERE Meta = '' AND EventType IN ({0}, {1})", + (int)SubathonEventType.MakeShipPledge, (int)SubathonEventType.MakeShipSale); + + var defaults = new List { + new() { EventType = SubathonEventType.TwitchSub, Meta = "1000", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.TwitchSub, Meta = "2000", Seconds = 120, Points = 2 }, + new() { EventType = SubathonEventType.TwitchSub, Meta = "3000", Seconds = 300, Points = 5 }, + new() { EventType = SubathonEventType.TwitchGiftSub, Meta = "1000", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.TwitchGiftSub, Meta = "2000", Seconds = 120, Points = 2 }, + new() { EventType = SubathonEventType.TwitchGiftSub, Meta = "3000", Seconds = 300, Points = 5 }, + new() { EventType = SubathonEventType.TwitchCheer, Seconds = 0.12 }, + new() { EventType = SubathonEventType.TwitchFollow, Seconds = 0 }, + new() { EventType = SubathonEventType.TwitchRaid, Seconds = 0 }, + new() { + EventType = SubathonEventType.StreamElementsDonation, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { + EventType = SubathonEventType.StreamLabsDonation, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { + EventType = SubathonEventType.YouTubeSuperChat, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { EventType = SubathonEventType.YouTubeMembership, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.YouTubeGiftMembership, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { + EventType = SubathonEventType.TwitchCharityDonation, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { + EventType = SubathonEventType.ExternalDonation, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { EventType = SubathonEventType.KoFiDonation, Seconds = 12 }, // per 1 unit/dollar of default currency + new() { EventType = SubathonEventType.KoFiSub, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { + EventType = SubathonEventType.KoFiShopOrder, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { + EventType = SubathonEventType.KoFiCommissionOrder, Seconds = 12 + }, // per 1 unit/dollar of default currency + new() { EventType = SubathonEventType.BlerpBeets, Seconds = 0.12 }, + new() { EventType = SubathonEventType.BlerpBits, Seconds = 0.12 }, + new() { EventType = SubathonEventType.PicartoSub, Meta = "T1", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.PicartoSub, Meta = "T2", Seconds = 120, Points = 2 }, + new() { EventType = SubathonEventType.PicartoSub, Meta = "T3", Seconds = 180, Points = 3 }, + new() { + EventType = SubathonEventType.PicartoGiftSub, Meta = "T1", Seconds = 60, Points = 1 + }, // you can only gift T1 + // PicartoMonth subs, treat as amount? = months * quantity = amount + new() { EventType = SubathonEventType.PicartoTip, Seconds = 0.12 }, + new() { EventType = SubathonEventType.PicartoFollow, Seconds = 0 }, + // assuming defaults for order types are by Dollar, we set at 12s + new() { EventType = SubathonEventType.ExternalSub, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.YouTubeRedirect, Seconds = 0 }, + new() { EventType = SubathonEventType.FourthWallDonation, Seconds = 12 }, + new() { EventType = SubathonEventType.FourthWallMembership, Meta = "DEFAULT", Seconds = 60, Points = 1 }, + new() { EventType = SubathonEventType.FourthWallGiftOrder, Seconds = 12 }, + new() { EventType = SubathonEventType.FourthWallOrder, Seconds = 12 }, + new() { EventType = SubathonEventType.ThroneGiftContribution, Seconds = 12 }, + new() { EventType = SubathonEventType.ThroneGiftPurchase, Seconds = 12 }, + // GoAffPro store order values are seeded per store (Meta = SiteId) in SeedKnownGoAffProStores + new() { EventType = SubathonEventType.TipeeeStreamDonation, Seconds = 12 }, + new() { EventType = SubathonEventType.TangiaTokens, Seconds = 0.12 }, + new() { EventType = SubathonEventType.PallyGGDonation, Seconds = 12 }, // per 1 USD, Pally is USD only + new() { EventType = SubathonEventType.TreatStreamOrder, Seconds = 600 }, // per treat, always 1 item + new() { + EventType = SubathonEventType.MakeShipPledge, Meta = "DEFAULT", Seconds = 60 + }, // always items mode, per pledge; overridable per tracked item (Meta = tracking name) + new() { + EventType = SubathonEventType.MakeShipSale, Meta = "DEFAULT", Seconds = 300 + }, // always items mode, per order; overridable per tracked item (Meta = tracking name) + new() { + EventType = SubathonEventType.JuniperMerchSale, Meta = "DEFAULT", Seconds = 300 + } // always items mode. meta = product id number, which links back to a store + }; + + foreach (SubathonValue def in defaults) + // only add if not exists + if (!db.SubathonValues.Any(sv => sv.EventType == def.EventType && sv.Meta == def.Meta)) + db.SubathonValues.Add(def); + + if (!db.SubathonDatas.Any(s => s.IsActive)) { + var subathon = new SubathonData(); + TimeSpan initialMs = TimeSpan.FromHours(8); + subathon.MillisecondsCumulative += (int)initialMs.TotalMilliseconds; + subathon.IsPaused = true; + db.SubathonDatas.Add(subathon); } - - public async Task UpdateSubathonMoney(double money, Guid subathonId) - { - await Database.ExecuteSqlRawAsync( - "UPDATE SubathonDatas SET MoneySum = {0} WHERE IsActive = 1 and Id = {1}", money, subathonId); - var subathon = await SubathonDatas.Include(s=> s.Multiplier) - .AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); - if (subathon == null) return; - Entry(subathon).State = EntityState.Detached; - Entry(subathon.Multiplier).State = EntityState.Detached; - Core.Events.SubathonEvents.RaiseSubathonDataUpdate(subathon, DateTime.Now); - - var goalSet = await SubathonGoalSets.AsNoTracking() - .Include(x => x.Goals).Where(x => x.IsActive) - .FirstOrDefaultAsync(); - if (goalSet != null && goalSet.Goals.Any() && goalSet.Type == GoalsType.Money) - { - Core.Events.SubathonEvents.RaiseSubathonGoalListUpdated(goalSet.Goals, - subathon.GetRoundedMoneySum(), GoalsType.Money); - } + + if (!db.SubathonGoalSets.Any(s => s.IsActive)) { + var goalSet = new SubathonGoalSet(); + db.SubathonGoalSets.Add(goalSet); } - - public static void SeedDefaultValues(AppDbContext db) - { - db.Database.ExecuteSqlRaw( - "UPDATE SubathonValues SET Meta = 'DEFAULT' WHERE Meta = '' AND EventType IN ({0}, {1})", - (int)SubathonEventType.MakeShipPledge, (int)SubathonEventType.MakeShipSale); - - var defaults = new List - { - new () { EventType = SubathonEventType.TwitchSub, Meta = "1000", Seconds = 60, Points = 1 }, - new () { EventType = SubathonEventType.TwitchSub, Meta = "2000", Seconds = 120, Points = 2 }, - new () { EventType = SubathonEventType.TwitchSub, Meta = "3000", Seconds = 300, Points = 5 }, - new () { EventType = SubathonEventType.TwitchGiftSub, Meta = "1000", Seconds = 60, Points = 1 }, - new () { EventType = SubathonEventType.TwitchGiftSub, Meta = "2000", Seconds = 120, Points = 2 }, - new () { EventType = SubathonEventType.TwitchGiftSub, Meta = "3000", Seconds = 300, Points = 5 }, - new () { EventType = SubathonEventType.TwitchCheer, Seconds = 0.12 }, - new () { EventType = SubathonEventType.TwitchFollow, Seconds = 0 }, - new () { EventType = SubathonEventType.TwitchRaid, Seconds = 0 }, - new () { EventType = SubathonEventType.StreamElementsDonation, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.StreamLabsDonation, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.YouTubeSuperChat, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.YouTubeMembership, Meta = "DEFAULT", Seconds = 60, Points = 1}, - new () { EventType = SubathonEventType.YouTubeGiftMembership, Meta = "DEFAULT", Seconds = 60, Points = 1}, - new () { EventType = SubathonEventType.TwitchCharityDonation, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.ExternalDonation, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.KoFiDonation, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.KoFiSub, Meta = "DEFAULT", Seconds = 60, Points = 1}, - new () { EventType = SubathonEventType.KoFiShopOrder, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.KoFiCommissionOrder, Seconds = 12}, // per 1 unit/dollar of default currency - new () { EventType = SubathonEventType.BlerpBeets, Seconds = 0.12 }, - new () { EventType = SubathonEventType.BlerpBits, Seconds = 0.12 }, - new () { EventType = SubathonEventType.PicartoSub, Meta = "T1", Seconds = 60, Points = 1 }, - new () { EventType = SubathonEventType.PicartoSub, Meta = "T2", Seconds = 120, Points = 2 }, - new () { EventType = SubathonEventType.PicartoSub, Meta = "T3", Seconds = 180, Points = 3 }, - new () { EventType = SubathonEventType.PicartoGiftSub, Meta = "T1", Seconds = 60, Points = 1 }, // you can only gift T1 - // PicartoMonth subs, treat as amount? = months * quantity = amount - new () { EventType = SubathonEventType.PicartoTip, Seconds = 0.12 }, - new () { EventType = SubathonEventType.PicartoFollow, Seconds = 0 }, - // assuming defaults for order types are by Dollar, we set at 12s - new () { EventType = SubathonEventType.ExternalSub, Meta = "DEFAULT", Seconds = 60, Points = 1}, - new () { EventType = SubathonEventType.YouTubeRedirect, Seconds = 0 }, - new () { EventType = SubathonEventType.FourthWallDonation, Seconds = 12}, - new () { EventType = SubathonEventType.FourthWallMembership, Meta = "DEFAULT", Seconds = 60, Points = 1}, - new () { EventType = SubathonEventType.FourthWallGiftOrder, Seconds = 12}, - new () { EventType = SubathonEventType.FourthWallOrder, Seconds = 12}, - new () { EventType = SubathonEventType.ThroneGiftContribution, Seconds = 12}, - new () { EventType = SubathonEventType.ThroneGiftPurchase, Seconds = 12}, - // GoAffPro store order values are seeded per store (Meta = SiteId) in SeedKnownGoAffProStores - new () { EventType = SubathonEventType.TipeeeStreamDonation, Seconds = 12 }, - new () { EventType = SubathonEventType.TangiaTokens, Seconds = 0.12 }, - new () { EventType = SubathonEventType.PallyGGDonation, Seconds = 12 }, // per 1 USD, Pally is USD only - new () { EventType = SubathonEventType.TreatStreamOrder, Seconds = 600 }, // per treat, always 1 item - new () { EventType = SubathonEventType.MakeShipPledge, Meta = "DEFAULT", Seconds = 60 }, // always items mode, per pledge; overridable per tracked item (Meta = tracking name) - new () { EventType = SubathonEventType.MakeShipSale, Meta = "DEFAULT", Seconds = 300 }, // always items mode, per order; overridable per tracked item (Meta = tracking name) - new () { EventType = SubathonEventType.JuniperMerchSale, Meta = "DEFAULT", Seconds = 300 }, // always items mode. meta = product id number, which links back to a store - }; - - foreach (var def in defaults) - { - // only add if not exists - if (!db.SubathonValues.Any(sv => sv.EventType == def.EventType && sv.Meta == def.Meta)) - { - db.SubathonValues.Add(def); - } - } - if (!db.SubathonDatas.Any(s => s.IsActive)) - { - SubathonData subathon = new SubathonData(); - TimeSpan initialMs = TimeSpan.FromHours(8); - subathon.MillisecondsCumulative += (int)initialMs.TotalMilliseconds; - subathon.IsPaused = true; - db.SubathonDatas.Add(subathon); - } - - if (!db.SubathonGoalSets.Any(s => s.IsActive)) - { - SubathonGoalSet goalSet = new SubathonGoalSet(); - db.SubathonGoalSets.Add(goalSet); - } - - if (!db.SubathonPromptSets.Any(s => s.IsActive)) - { - db.SubathonPromptSets.Add(new SubathonPromptSet { IsActive = true }); - } + if (!db.SubathonPromptSets.Any(s => s.IsActive)) + db.SubathonPromptSets.Add(new SubathonPromptSet { IsActive = true }); - if (!db.WheelSets.Any(w => w.IsActive)) - { - db.WheelSets.Add(new WheelSet { IsActive = true }); - } + if (!db.WheelSets.Any(w => w.IsActive)) db.WheelSets.Add(new WheelSet { IsActive = true }); - if (!db.StateValues.Any(sv => sv.Name == StateKeys.WheelSpinsOwed)) - { - db.StateValues.Add(new StateValue - { - Name = StateKeys.WheelSpinsOwed, - Value = "0", - TypeName = "Int32" - }); - } + if (!db.StateValues.Any(sv => sv.Name == StateKeys.WheelSpinsOwed)) + db.StateValues.Add(new StateValue { + Name = StateKeys.WheelSpinsOwed, + Value = "0", + TypeName = "Int32" + }); - MigrateLegacyData(db); - SeedKnownGoAffProStores(db); - db.SaveChanges(); - } + MigrateLegacyData(db); + SeedKnownGoAffProStores(db); + db.SaveChanges(); + } - private static void SeedKnownGoAffProStores(AppDbContext db) - { - var defaults = new List - { - new () { SiteId = 165328, StoreName = "GamerSupps", EventName = "GamerSupps Order"}, - new () { SiteId = 132230, StoreName = "UwUMarket", EventName = "UwUMarket Order"}, - new () { SiteId = 7142837, StoreName = "Orchid Eight", EventName = "Orchid Eight Order"}, - new () { SiteId = 7160049, StoreName = "KatDragonz", EventName = "KatDragonz Order"}, - new () { SiteId = 7138531, StoreName = "Cheeky Soap", EventName = "Cheeky Soap Order"}, - new () { SiteId = 105752, StoreName = "Advanced GG", EventName = "Advanced GG Order"}, - new () { SiteId = 7014645, StoreName = "Rogue Energy", EventName = "Rogue Energy Order"}, - new () { SiteId = 7118656, StoreName = "Saucy Biz", EventName = "Saucy Biz Order"}, - new () { SiteId = 48808, StoreName = "GFuel", EventName = "GFuel Order"}, - new () { SiteId = 7132796, StoreName = "Natura Pine", EventName = "Natura Pine Order"}, - new () { SiteId = 18870, StoreName = "Madrinas", EventName = "Madrinas Order"}, - new () { SiteId = 7000863, StoreName = "Otaku", EventName = "Otaku Order"}, - new () { SiteId = 7111695, StoreName = "V1 Tech", EventName = "V1 Tech Order"}, - new () { SiteId = 7120088, StoreName = "Plush Foundry", EventName = "PlushFoundry Order"}, - new () { SiteId = 7112002, StoreName = "Horizons Merch", EventName = "Horizons Merch Order"} - }; - - foreach (var def in defaults) - { - var existing = db.GoAffProStores.FirstOrDefault(store => store.SiteId == def.SiteId); - if (existing == null) - { - db.GoAffProStores.Add(def); - } - else if (existing.StoreName != def.StoreName || existing.EventName != def.EventName) - { - existing.StoreName = def.StoreName; - existing.EventName = def.EventName; - } + private static void SeedKnownGoAffProStores(AppDbContext db) { + var defaults = new List { + new() { SiteId = 165328, StoreName = "GamerSupps", EventName = "GamerSupps Order" }, + new() { SiteId = 132230, StoreName = "UwUMarket", EventName = "UwUMarket Order" }, + new() { SiteId = 7142837, StoreName = "Orchid Eight", EventName = "Orchid Eight Order" }, + new() { SiteId = 7160049, StoreName = "KatDragonz", EventName = "KatDragonz Order" }, + new() { SiteId = 7138531, StoreName = "Cheeky Soap", EventName = "Cheeky Soap Order" }, + new() { SiteId = 105752, StoreName = "Advanced GG", EventName = "Advanced GG Order" }, + new() { SiteId = 7014645, StoreName = "Rogue Energy", EventName = "Rogue Energy Order" }, + new() { SiteId = 7118656, StoreName = "Saucy Biz", EventName = "Saucy Biz Order" }, + new() { SiteId = 48808, StoreName = "GFuel", EventName = "GFuel Order" }, + new() { SiteId = 7132796, StoreName = "Natura Pine", EventName = "Natura Pine Order" }, + new() { SiteId = 18870, StoreName = "Madrinas", EventName = "Madrinas Order" }, + new() { SiteId = 7000863, StoreName = "Otaku", EventName = "Otaku Order" }, + new() { SiteId = 7111695, StoreName = "V1 Tech", EventName = "V1 Tech Order" }, + new() { SiteId = 7120088, StoreName = "Plush Foundry", EventName = "PlushFoundry Order" }, + new() { SiteId = 7112002, StoreName = "Horizons Merch", EventName = "Horizons Merch Order" } + }; + + foreach (GoAffProStore def in defaults) { + GoAffProStore? existing = db.GoAffProStores.FirstOrDefault(store => store.SiteId == def.SiteId); + if (existing == null) { + db.GoAffProStores.Add(def); } - - var siteIds = defaults.Select(d => d.SiteId) - .Concat(db.GoAffProStores.AsNoTracking().Select(s => s.SiteId).ToList()) - .Distinct(); - foreach (var siteId in siteIds) - { - var meta = siteId.ToString(); - if (!db.SubathonValues.Any(sv => sv.EventType == SubathonEventType.GoAffProOrder - && sv.Meta == meta)) - { - db.SubathonValues.Add( new () - { - EventType = SubathonEventType.GoAffProOrder, Seconds = 12, Meta = meta - }); - } + else if (existing.StoreName != def.StoreName || existing.EventName != def.EventName) { + existing.StoreName = def.StoreName; + existing.EventName = def.EventName; } } - private static void MigrateLegacyGoAffProData(AppDbContext db) - { - // Convert rows stored under retired per-store GoAffPro enum values to the single - // GoAffProOrder type, SiteId in the meta column - int newGoAffProOrderInt = (int)SubathonEventType.GoAffProOrder; - - foreach (var legacyType in Enum.GetValues() - .Where(t => t.GetLegacyGoAffProSiteId() > 0)) - { - int oldTypeInt = (int)legacyType; - string meta = legacyType.GetLegacyGoAffProSiteId().ToString(); - - db.Database.ExecuteSqlRaw( - "UPDATE OR IGNORE SubathonValues SET EventType = {0}, Meta = {1} WHERE EventType = {2}", - newGoAffProOrderInt, meta, oldTypeInt); - db.Database.ExecuteSqlRaw( - "DELETE FROM SubathonValues WHERE EventType = {0}", - oldTypeInt); - - db.Database.ExecuteSqlRaw( - "UPDATE SubathonEvents SET EventType = {0}, EventTypeMeta = {1} WHERE EventType = {2}", - newGoAffProOrderInt, meta, oldTypeInt); - - db.Database.ExecuteSqlRaw( - "UPDATE WheelSpinTriggers SET EventType = {0}, TierValue = {1} WHERE EventType = {2}", - newGoAffProOrderInt, meta, oldTypeInt); - db.Database.ExecuteSqlRaw( - "UPDATE WheelSpinTriggerHistories SET SubathonEventType = {0} WHERE SubathonEventType = {1}", - newGoAffProOrderInt, oldTypeInt); - - db.Database.ExecuteSqlRaw( - "UPDATE SubathonPrompts SET FilterEventType = {0}, FilterMeta = {1} WHERE FilterEventType = {2}", - newGoAffProOrderInt, meta, oldTypeInt); - } + IEnumerable siteIds = defaults.Select(d => d.SiteId) + .Concat(db.GoAffProStores.AsNoTracking().Select(s => s.SiteId).ToList()) + .Distinct(); + foreach (int siteId in siteIds) { + var meta = siteId.ToString(); + if (!db.SubathonValues.Any(sv => sv.EventType == SubathonEventType.GoAffProOrder + && sv.Meta == meta)) + db.SubathonValues.Add(new SubathonValue { + EventType = SubathonEventType.GoAffProOrder, Seconds = 12, Meta = meta + }); } + } - private static void MigrateLegacyData(AppDbContext db) - { - - if (db.SubathonGoalSets.Any(s => s.Type == null)) - { - db.SubathonGoalSets.Where(s => s.Type == null) - .ExecuteUpdate(s => - s.SetProperty(x => x.Type, GoalsType.Points)); - } - - if (db.SubathonDatas.Any(s => s.ReversedTime == null)) - { - db.SubathonDatas.Where(s => s.ReversedTime == null) - .ExecuteUpdate(s => - s.SetProperty(x => x.ReversedTime, false)); - } - - MigrateLegacyGoAffProData(db); - - // var fontTypes = WidgetVariableTypeHelper.FontVariables.ToList(); - // var widgetsMissingFontTypes = db.Widgets - // .Where(w => !fontTypes.All(ft => w.JsVariables.Any(v => v.Type == ft))) - // .Include(w => w.JsVariables) - // .ToList(); - // if (widgetsMissingFontTypes.Count > 0) - // { - // var newVars = new List(); - // foreach (var widget in widgetsMissingFontTypes) - // { - // var existingTypes = widget.JsVariables - // .Select(v => v.Type) - // .ToHashSet(); - // - // foreach (var fontVar in fontTypes.Where(ft => !existingTypes.Contains(ft))) - // { - // newVars.Add(new JsVariable - // { - // WidgetId = widget.Id, - // Widget = widget, - // Type = fontVar, - // Name = $"{fontVar}s", - // Description = $"Custom font names to include from {fontVar}s, comma separated", - // Value = string.Empty - // }); - // } - // } - // if (newVars.Count > 0) - // { - // db.JsVariables.AddRange(newVars); - // db.SaveChanges(); - // } - // } + private static void MigrateLegacyGoAffProData(AppDbContext db) { + // Convert rows stored under retired per-store GoAffPro enum values to the single + // GoAffProOrder type, SiteId in the meta column + var newGoAffProOrderInt = (int)SubathonEventType.GoAffProOrder; + + foreach (SubathonEventType legacyType in Enum.GetValues() + .Where(t => t.GetLegacyGoAffProSiteId() > 0)) { + var oldTypeInt = (int)legacyType; + var meta = legacyType.GetLegacyGoAffProSiteId().ToString(); + + db.Database.ExecuteSqlRaw( + "UPDATE OR IGNORE SubathonValues SET EventType = {0}, Meta = {1} WHERE EventType = {2}", + newGoAffProOrderInt, meta, oldTypeInt); + db.Database.ExecuteSqlRaw( + "DELETE FROM SubathonValues WHERE EventType = {0}", + oldTypeInt); + + db.Database.ExecuteSqlRaw( + "UPDATE SubathonEvents SET EventType = {0}, EventTypeMeta = {1} WHERE EventType = {2}", + newGoAffProOrderInt, meta, oldTypeInt); + + db.Database.ExecuteSqlRaw( + "UPDATE WheelSpinTriggers SET EventType = {0}, TierValue = {1} WHERE EventType = {2}", + newGoAffProOrderInt, meta, oldTypeInt); + db.Database.ExecuteSqlRaw( + "UPDATE WheelSpinTriggerHistories SET SubathonEventType = {0} WHERE SubathonEventType = {1}", + newGoAffProOrderInt, oldTypeInt); + + db.Database.ExecuteSqlRaw( + "UPDATE SubathonPrompts SET FilterEventType = {0}, FilterMeta = {1} WHERE FilterEventType = {2}", + newGoAffProOrderInt, meta, oldTypeInt); } } + + private static void MigrateLegacyData(AppDbContext db) { + if (db.SubathonGoalSets.Any(s => s.Type == null)) + db.SubathonGoalSets.Where(s => s.Type == null) + .ExecuteUpdate(s => + s.SetProperty(x => x.Type, GoalsType.Points)); + + if (db.SubathonDatas.Any(s => s.ReversedTime == null)) + db.SubathonDatas.Where(s => s.ReversedTime == null) + .ExecuteUpdate(s => + s.SetProperty(x => x.ReversedTime, false)); + + MigrateLegacyGoAffProData(db); + + // var fontTypes = WidgetVariableTypeHelper.FontVariables.ToList(); + // var widgetsMissingFontTypes = db.Widgets + // .Where(w => !fontTypes.All(ft => w.JsVariables.Any(v => v.Type == ft))) + // .Include(w => w.JsVariables) + // .ToList(); + // if (widgetsMissingFontTypes.Count > 0) + // { + // var newVars = new List(); + // foreach (var widget in widgetsMissingFontTypes) + // { + // var existingTypes = widget.JsVariables + // .Select(v => v.Type) + // .ToHashSet(); + // + // foreach (var fontVar in fontTypes.Where(ft => !existingTypes.Contains(ft))) + // { + // newVars.Add(new JsVariable + // { + // WidgetId = widget.Id, + // Widget = widget, + // Type = fontVar, + // Name = $"{fontVar}s", + // Description = $"Custom font names to include from {fontVar}s, comma separated", + // Value = string.Empty + // }); + // } + // } + // if (newVars.Count > 0) + // { + // db.JsVariables.AddRange(newVars); + // db.SaveChanges(); + // } + // } + } } \ No newline at end of file diff --git a/SubathonManager.Data/Extensions/SubathonQueryExtensions.cs b/SubathonManager.Data/Extensions/SubathonQueryExtensions.cs index cd315478..7a40a460 100644 --- a/SubathonManager.Data/Extensions/SubathonQueryExtensions.cs +++ b/SubathonManager.Data/Extensions/SubathonQueryExtensions.cs @@ -1,35 +1,33 @@ -using SubathonManager.Core.Models; -using SubathonManager.Core.Enums; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + namespace SubathonManager.Data.Extensions; [ExcludeFromCodeCoverage] -public static class SubathonQueryExtensions -{ +public static class SubathonQueryExtensions { // unused but may be useful in future. Also sets up possible future extensions - + public static async Task TwitchCheerTotalAsync( this SubathonData subathon, - AppDbContext db) - { - var events = await db.SubathonEvents.AsNoTracking() + AppDbContext db) { + List events = await db.SubathonEvents.AsNoTracking() .Where(e => e.SubathonId == subathon.Id && e.EventType == SubathonEventType.TwitchCheer) .ToListAsync(); return events.Sum(e => - long.TryParse(e.Value, out var v) ? v : 0); + long.TryParse(e.Value, out long v) ? v : 0); } - + public static long TwitchCheerTotal( this SubathonData subathon, - AppDbContext db) - { + AppDbContext db) { return db.Set() .Where(e => e.SubathonId == subathon.Id && e.EventType == SubathonEventType.TwitchCheer).AsNoTracking().AsEnumerable() - .Sum(e => (long?)Convert.ToInt64(e.Value))?? 0; + .Sum(e => (long?)Convert.ToInt64(e.Value)) ?? 0; } } \ No newline at end of file diff --git a/SubathonManager.Data/OverlayPorter.cs b/SubathonManager.Data/OverlayPorter.cs index 1fac1028..7ceb6690 100644 --- a/SubathonManager.Data/OverlayPorter.cs +++ b/SubathonManager.Data/OverlayPorter.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using SubathonManager.Core; using SubathonManager.Core.Enums; @@ -16,139 +17,126 @@ namespace SubathonManager.Data; -public static class OverlayPorter -{ +public static class OverlayPorter { private const int SegmentHashLength = 4; private const string ExternalFolder = "_external"; private const string PacksFolder = "packs"; private const string ManifestFileName = OverlayPackInstaller.ManifestFileName; public const string FormatVersion = "2"; - private static readonly JsonSerializerOptions SerializeOptions = new JsonSerializerOptions - { + private static readonly JsonSerializerOptions SerializeOptions = new() { WriteIndented = true, - Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + Converters = { new JsonStringEnumConverter() } }; #region EXPORT public static async Task ExportRouteAsync(Route route, string outputPath, string exportName, HashSet? excludedZipEntries = null, string version = "1", string appVersion = "", - string author = "", List? tags = null) - { - var widgets = route.Widgets.ToList(); - var plan = BuildExportPlan(widgets, excludedZipEntries); + string author = "", List? tags = null) { + List widgets = route.Widgets.ToList(); + ExportPlan plan = BuildExportPlan(widgets, excludedZipEntries); await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); - using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false); + using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, false); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var (srcFile, zipEntry) in plan.FileCopies) - { + foreach ((string srcFile, string zipEntry) in plan.FileCopies) { if (!WidgetFiles.Current.Exists(srcFile)) continue; if (!seen.Add(zipEntry)) continue; if (excludedZipEntries != null && excludedZipEntries.Contains(zipEntry)) continue; - if (File.Exists(srcFile)) - { + if (File.Exists(srcFile)) { await archive.CreateEntryFromFileAsync(srcFile, zipEntry, CompressionLevel.Optimal); continue; } - var bytes = WidgetFiles.Current.ReadAllBytes(srcFile); + byte[]? bytes = WidgetFiles.Current.ReadAllBytes(srcFile); if (bytes == null) continue; - var packedEntry = archive.CreateEntry(zipEntry, CompressionLevel.Optimal); - await using var packedStream = await packedEntry.OpenAsync(); + ZipArchiveEntry packedEntry = archive.CreateEntry(zipEntry, CompressionLevel.Optimal); + await using Stream packedStream = await packedEntry.OpenAsync(); await packedStream.WriteAsync(bytes); } - var manifest = BuildManifest(route, widgets, plan, exportName, version, appVersion, author, tags); - var manifestJson = JsonSerializer.Serialize(manifest, SerializeOptions); - var manifestEntry = archive.CreateEntry(ManifestFileName, CompressionLevel.Optimal); - await using var manifestStream = await manifestEntry.OpenAsync(); + JsonElement manifest = BuildManifest(route, widgets, plan, exportName, version, appVersion, author, tags); + string manifestJson = JsonSerializer.Serialize(manifest, SerializeOptions); + ZipArchiveEntry manifestEntry = archive.CreateEntry(ManifestFileName, CompressionLevel.Optimal); + await using Stream manifestStream = await manifestEntry.OpenAsync(); await manifestStream.WriteAsync(Encoding.UTF8.GetBytes(manifestJson)); OpenExportFolder(outputPath); } [ExcludeFromCodeCoverage] - private static void OpenExportFolder(string outputPath) - { + private static void OpenExportFolder(string outputPath) { bool isTest = AppDomain.CurrentDomain.GetAssemblies() .Any(a => a.FullName!.StartsWith("xunit", StringComparison.OrdinalIgnoreCase)); if (isTest) return; - try - { - Process.Start(new ProcessStartInfo - { + try { + Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(Path.GetFullPath(outputPath)), UseShellExecute = true, Verb = "open" }); } - catch {/**/} + catch { + /**/ + } } - + public const string ResourcesFolder = "_resources"; - private static ExportPlan BuildExportPlan(List widgets, HashSet? excludedZipEntries = null) - { + private static ExportPlan BuildExportPlan(List widgets, HashSet? excludedZipEntries = null) { var plan = new ExportPlan(); - bool IsKept(string zipEntry) => excludedZipEntries?.Contains(zipEntry) != true; - foreach (var rel in ResourcePaths.EnumerateRelative()) - { - string zipEntry = $"{ResourcesFolder}/{rel}"; + bool IsKept(string zipEntry) { + return excludedZipEntries?.Contains(zipEntry) != true; + } + + foreach (string rel in ResourcePaths.EnumerateRelative()) { + var zipEntry = $"{ResourcesFolder}/{rel}"; if (!IsKept(zipEntry)) continue; if (ResourcePaths.ToLocalPath(ResourcePaths.UrlPrefix + rel) is { } source) plan.FileCopies.Add((source, zipEntry)); } - var widgetRoots = widgets.Select(w => w.GetPath()).ToList(); - var zipRoots = GetZipWidgetRoots(widgetRoots); + List widgetRoots = widgets.Select(w => w.GetPath()).ToList(); + List zipRoots = GetZipWidgetRoots(widgetRoots); - for (int i = 0; i < widgets.Count; i++) - { - var widget = widgets[i]; + for (var i = 0; i < widgets.Count; i++) { + Widget widget = widgets[i]; string widgetRoot = widgetRoots[i]; string zipWidgetRoot = zipRoots[i]; - var baseFolder = GetWidgetBaseFolder(zipWidgetRoot); + string baseFolder = GetWidgetBaseFolder(zipWidgetRoot); plan.WidgetFolderMap[widget.Id] = zipWidgetRoot; - bool isPacked = WidgetPackPaths.TryResolve(widget.HtmlPath, out var packFile, out var packEntry, - out var packId, out var packVersion); + bool isPacked = WidgetPackPaths.TryResolve(widget.HtmlPath, out string packFile, out string packEntry, + out string packId, out string packVersion); - if (isPacked) - { - string packZipPath = $"{PacksFolder}/{packId}/{packVersion}{WidgetPackPaths.PackExtension}"; + if (isPacked) { + var packZipPath = $"{PacksFolder}/{packId}/{packVersion}{WidgetPackPaths.PackExtension}"; plan.FileCopies.Add((packFile, packZipPath)); plan.WidgetPacks[widget.Id] = new PackReference(packId, packVersion, packEntry, packZipPath); } - else if (widget.Type.IsAsset()) - { - if (WidgetFiles.Current.Exists(widget.HtmlPath)) - { + else if (widget.Type.IsAsset()) { + if (WidgetFiles.Current.Exists(widget.HtmlPath)) { string fileName = Path.GetFileName(widget.HtmlPath); plan.FileCopies.Add((widget.HtmlPath, $"{zipWidgetRoot}/{fileName}")); } } - else - { - foreach (var file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) - { + else { + foreach (string file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) { string relative = Path.GetRelativePath(widgetRoot, file).Replace('\\', '/'); plan.FileCopies.Add((file, $"{zipWidgetRoot}/{relative}")); } } - foreach (var jsVar in widget.JsVariables) - { + foreach (JsVariable jsVar in widget.JsVariables) { if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; if (string.IsNullOrWhiteSpace(jsVar.Value)) continue; - if (ResourcePaths.RelativeFromUrl(jsVar.Value) is { } resourceRel) - { + if (ResourcePaths.RelativeFromUrl(jsVar.Value) is { } resourceRel) { if (!IsKept($"{ResourcesFolder}/{resourceRel}")) continue; string upToRoot = string.Concat( Enumerable.Repeat("../", zipWidgetRoot.Split('/').Length)); @@ -158,22 +146,20 @@ private static ExportPlan BuildExportPlan(List widgets, HashSet? } bool isAbsolute = !jsVar.Value.StartsWith("./") && !jsVar.Value.StartsWith("../") - && Path.IsPathRooted(jsVar.Value); + && Path.IsPathRooted(jsVar.Value); if (!isAbsolute) continue; bool isFolderType = jsVar.Type == WidgetVariableType.FolderPath; - if (isFolderType && Directory.Exists(jsVar.Value)) - { + if (isFolderType && Directory.Exists(jsVar.Value)) { string varFolderName = SanitizeName(jsVar.Name); - foreach (var file in Directory.EnumerateFiles(jsVar.Value, "*", SearchOption.AllDirectories)) - { + foreach (string file in Directory.EnumerateFiles(jsVar.Value, "*", SearchOption.AllDirectories)) { string relative = Path.GetRelativePath(jsVar.Value, file).Replace('\\', '/'); plan.FileCopies.Add((file, $"{zipWidgetRoot}/{ExternalFolder}/{varFolderName}/{relative}")); } + SetRewrite(plan.VariableRewrites, widget.Id, jsVar.Name, $"./{ExternalFolder}/{varFolderName}"); } - else if (!isFolderType && File.Exists(jsVar.Value)) - { + else if (!isFolderType && File.Exists(jsVar.Value)) { string fileName = Path.GetFileName(jsVar.Value); plan.FileCopies.Add((jsVar.Value, $"{zipWidgetRoot}/{ExternalFolder}/{fileName}")); SetRewrite(plan.VariableRewrites, widget.Id, jsVar.Name, $"./{ExternalFolder}/{fileName}"); @@ -185,28 +171,27 @@ private static ExportPlan BuildExportPlan(List widgets, HashSet? } private static JsonElement BuildManifest(Route route, List widgets, ExportPlan plan, string exportName, - string version = "1", string appVersion = "", string author = "", List? tags = null) - { + string version = "1", string appVersion = "", string author = "", List? tags = null) { if (string.IsNullOrWhiteSpace(appVersion)) appVersion = AppServices.AppVersion; - var widgetList = widgets.Select(w => - { - if (!plan.WidgetFolderMap.TryGetValue(w.Id, out var zipWidgetRoot)) return null!; + var widgetList = widgets.Select(w => { + if (!plan.WidgetFolderMap.TryGetValue(w.Id, out string? zipWidgetRoot)) return null!; string htmlFileName = Path.GetFileName(w.HtmlPath); - string htmlZipRelPath = $"{zipWidgetRoot}/{htmlFileName}"; - - var rewrites = plan.VariableRewrites.TryGetValue(w.Id, out var r) ? r : new(); - var jsVars = w.JsVariables.Select(v => - { - string value = rewrites.TryGetValue(v.Name, out var rewritten) ? rewritten : v.Value; + var htmlZipRelPath = $"{zipWidgetRoot}/{htmlFileName}"; + + Dictionary rewrites = + plan.VariableRewrites.TryGetValue(w.Id, out Dictionary? r) + ? r + : new Dictionary(); + var jsVars = w.JsVariables.Select(v => { + string value = rewrites.TryGetValue(v.Name, out string? rewritten) ? rewritten : v.Value; return new { name = v.Name, value, type = v.Type }; }); - var pack = plan.WidgetPacks.TryGetValue(w.Id, out var packRef) + var pack = plan.WidgetPacks.TryGetValue(w.Id, out PackReference? packRef) ? new { id = packRef.PackId, version = packRef.Version, entry = packRef.Entry, file = packRef.ZipPath } : null; - return new - { + return new { id = w.Id, name = w.Name, htmlPath = htmlZipRelPath, @@ -222,18 +207,16 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo }; }); - var obj = new - { + var obj = new { // formatversion helps determine some behaviour from old stuff - version = version, + version, format_version = FormatVersion, app_version = appVersion, exported_at = DateTime.UtcNow, - route = new - { + route = new { id = route.Id, name = exportName, - author = author, + author, overlay_version = version, tags = tags ?? new List(), resolution = new { width = route.Width, height = route.Height }, @@ -250,6 +233,7 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo return JsonSerializer.SerializeToElement(obj); } + #endregion #region IMPORT @@ -257,45 +241,42 @@ private static JsonElement BuildManifest(Route route, List widgets, Expo public static async Task ImportRouteAsync( string smoPath, string extractRoot, - IDbContextFactory factory) - { + IDbContextFactory factory) { string archiveName = Path.GetFileNameWithoutExtension(smoPath); string extractDir = Path.Combine(extractRoot, SanitizeName(archiveName)); Directory.CreateDirectory(extractDir); - await ZipFile.ExtractToDirectoryAsync(smoPath, extractDir, overwriteFiles: true); + await ZipFile.ExtractToDirectoryAsync(smoPath, extractDir, true); return await ImportExtractedRouteAsync(extractDir, factory, archiveName); } - + public static async Task ImportExtractedRouteAsync( string extractDir, IDbContextFactory factory, string fallbackName, - string? routeNameOverride = null) - { + string? routeNameOverride = null) { string manifestPath = Path.Combine(extractDir, ManifestFileName); if (!File.Exists(manifestPath)) return ImportResult.Fail("overlay.json not found in archive"); - var manifestJson = await File.ReadAllTextAsync(manifestPath); - using var doc = JsonDocument.Parse(manifestJson); - var root = doc.RootElement; + string manifestJson = await File.ReadAllTextAsync(manifestPath); + using JsonDocument doc = JsonDocument.Parse(manifestJson); + JsonElement root = doc.RootElement; - await using var db = await factory.CreateDbContextAsync(); + await using AppDbContext db = await factory.CreateDbContextAsync(); - var routeEl = root.GetProperty("route"); + JsonElement routeEl = root.GetProperty("route"); string routeName = routeNameOverride ?? routeEl.GetProperty("name").GetString() ?? fallbackName; - var manifestWidgets = root.GetProperty("widgets").EnumerateArray() + List manifestWidgets = root.GetProperty("widgets").EnumerateArray() .Select(wEl => ResolveManifestWidget(wEl, extractDir)) .ToList(); var existing = db.Widgets .Select(w => new { w.Id, w.HtmlPath, w.RouteId }) .ToList() - .Select(w => new - { + .Select(w => new { w.Id, w.HtmlPath, w.RouteId, @@ -304,8 +285,7 @@ public static async Task ImportExtractedRouteAsync( .ToList(); Guid? matchedRouteId = null; - foreach (var mw in manifestWidgets) - { + foreach (ManifestWidget mw in manifestWidgets) { var hit = existing.FirstOrDefault(w => mw.PackFolder != null ? string.Equals(w.PackFolder, mw.PackFolder, StringComparison.OrdinalIgnoreCase) @@ -316,39 +296,32 @@ public static async Task ImportExtractedRouteAsync( break; } - if (matchedRouteId == null) - { + if (matchedRouteId == null) { string baseName = OverlayPackPaths.BaseRouteName(routeName); if (!string.IsNullOrWhiteSpace(baseName)) - { matchedRouteId = db.Routes .Select(r => new { r.Id, r.Name }) .ToList() .FirstOrDefault(r => string.Equals( - OverlayPackPaths.BaseRouteName(r.Name), + OverlayPackPaths.BaseRouteName(r.Name), baseName, StringComparison.OrdinalIgnoreCase)) ?.Id; - } } Route? route = null; bool routeIsNew; if (matchedRouteId != null) - { route = await db.Routes .Include(r => r.Widgets).ThenInclude(w => w.JsVariables) .Include(r => r.Widgets).ThenInclude(w => w.CssVariables) .FirstOrDefaultAsync(r => r.Id == matchedRouteId); - } - if (route == null) - { + if (route == null) { route = BuildNewRoute(routeEl, routeName); routeIsNew = true; } - else - { + else { routeIsNew = false; } @@ -357,102 +330,92 @@ public static async Task ImportExtractedRouteAsync( var newJsVariables = new List(); var repointedWidgets = new List(); - foreach (var mw in manifestWidgets) - { - var wEl = mw.Element; + foreach (ManifestWidget mw in manifestWidgets) { + JsonElement wEl = mw.Element; string htmlAbsPath = mw.HtmlAbsPath; string widgetExtractFolder = mw.PackFolder != null ? mw.OverlayFolder : Path.GetDirectoryName(htmlAbsPath)!; - Widget? existingWidget = routeIsNew ? null + Widget? existingWidget = routeIsNew + ? null : route.Widgets.FirstOrDefault(w => MatchesManifestWidget(w, mw)); if (existingWidget != null && - !string.Equals(existingWidget.HtmlPath, htmlAbsPath, StringComparison.OrdinalIgnoreCase)) - { + !string.Equals(existingWidget.HtmlPath, htmlAbsPath, StringComparison.OrdinalIgnoreCase)) { existingWidget.HtmlPath = htmlAbsPath; repointedWidgets.Add(existingWidget); } - if (existingWidget == null) - { - WidgetType widgetType = WidgetType.Html; - if (wEl.TryGetProperty("type", out var typeEl)) - Enum.TryParse(typeEl.GetString(), ignoreCase: true, out widgetType); + if (existingWidget == null) { + var widgetType = WidgetType.Html; + if (wEl.TryGetProperty("type", out JsonElement typeEl)) + Enum.TryParse(typeEl.GetString(), true, out widgetType); else widgetType = WidgetTypeHelper.DetectFromPath(htmlAbsPath); - var widget = new Widget(wEl.GetProperty("name").GetString() ?? "Imported Widget", htmlAbsPath) - { + var widget = new Widget(wEl.GetProperty("name").GetString() ?? "Imported Widget", htmlAbsPath) { Id = Guid.NewGuid(), RouteId = route.Id, Type = widgetType, Visibility = wEl.GetProperty("visibility").GetBoolean(), - DocsUrl = wEl.TryGetProperty("docsUrl", out var du) ? du.GetString() : null + DocsUrl = wEl.TryGetProperty("docsUrl", out JsonElement du) ? du.GetString() : null }; - var pos = wEl.GetProperty("position"); + JsonElement pos = wEl.GetProperty("position"); widget.X = pos.GetProperty("x").GetSingle(); widget.Y = pos.GetProperty("y").GetSingle(); widget.Z = pos.GetProperty("z").GetInt32(); - var size = wEl.GetProperty("size"); + JsonElement size = wEl.GetProperty("size"); widget.Width = size.GetProperty("width").GetInt32(); widget.Height = size.GetProperty("height").GetInt32(); - var scale = wEl.GetProperty("scale"); + JsonElement scale = wEl.GetProperty("scale"); widget.ScaleX = scale.GetProperty("x").GetSingle(); widget.ScaleY = scale.GetProperty("y").GetSingle(); - if (widgetType == WidgetType.Html) - { - foreach (var v in wEl.GetProperty("cssVariables").EnumerateArray().Select(cssEl => + if (widgetType == WidgetType.Html) { + foreach (CssVariable v in wEl.GetProperty("cssVariables").EnumerateArray().Select(cssEl => CssVariable.FromJson(cssEl, widget.Id)).OfType()) - { widget.CssVariables.Add(v); - } - foreach (var v in wEl.GetProperty("jsVariables").EnumerateArray().Select(jsEl => - BuildJsVariable(jsEl, widget.Id, widgetExtractFolder, mw.PackFolder != null)) + foreach (JsVariable v in wEl.GetProperty("jsVariables").EnumerateArray().Select(jsEl => + BuildJsVariable(jsEl, widget.Id, widgetExtractFolder, mw.PackFolder != null)) .OfType()) - { widget.JsVariables.Add(v); - } } newWidgets.Add(widget); } - else - { - var existingCssNames = new HashSet(existingWidget.CssVariables.Select(v => v.Name), StringComparer.OrdinalIgnoreCase); - foreach (var cssEl in wEl.GetProperty("cssVariables").EnumerateArray()) - { - string? name = cssEl.TryGetProperty("name", out var n) ? n.GetString() : null; + else { + var existingCssNames = new HashSet(existingWidget.CssVariables.Select(v => v.Name), + StringComparer.OrdinalIgnoreCase); + foreach (JsonElement cssEl in wEl.GetProperty("cssVariables").EnumerateArray()) { + string? name = cssEl.TryGetProperty("name", out JsonElement n) ? n.GetString() : null; if (name == null || existingCssNames.Contains(name)) continue; - var v = CssVariable.FromJson(cssEl, existingWidget.Id); + CssVariable? v = CssVariable.FromJson(cssEl, existingWidget.Id); if (v != null) newCssVariables.Add(v); } - - var existingJsNames = new HashSet(existingWidget.JsVariables.Select(v => v.Name), StringComparer.OrdinalIgnoreCase); - foreach (var jsEl in wEl.GetProperty("jsVariables").EnumerateArray()) - { - string? name = jsEl.TryGetProperty("name", out var n) ? n.GetString() : null; + + var existingJsNames = new HashSet(existingWidget.JsVariables.Select(v => v.Name), + StringComparer.OrdinalIgnoreCase); + foreach (JsonElement jsEl in wEl.GetProperty("jsVariables").EnumerateArray()) { + string? name = jsEl.TryGetProperty("name", out JsonElement n) ? n.GetString() : null; if (name == null || existingJsNames.Contains(name)) continue; - var v = BuildJsVariable(jsEl, existingWidget.Id, widgetExtractFolder, mw.PackFolder != null); + JsVariable? v = BuildJsVariable(jsEl, existingWidget.Id, widgetExtractFolder, + mw.PackFolder != null); if (v != null) newJsVariables.Add(v); } } } var repointedIds = new HashSet(repointedWidgets.Select(w => w.Id)); - foreach (var mw in manifestWidgets.Where(m => m.PackFolder != null)) - { + foreach (ManifestWidget mw in manifestWidgets.Where(m => m.PackFolder != null)) { var strays = existing.Where(w => string.Equals(w.PackFolder, mw.PackFolder, StringComparison.OrdinalIgnoreCase) && !string.Equals(w.HtmlPath, mw.HtmlAbsPath, StringComparison.OrdinalIgnoreCase) && !repointedIds.Contains(w.Id)); - foreach (var stray in strays) - { - var tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == stray.Id); + foreach (var stray in strays) { + Widget? tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == stray.Id); if (tracked == null || !repointedIds.Add(tracked.Id)) continue; tracked.HtmlPath = mw.HtmlAbsPath; repointedWidgets.Add(tracked); @@ -461,8 +424,7 @@ public static async Task ImportExtractedRouteAsync( bool renameMerged = !routeIsNew && !string.Equals(route!.Name, routeName, StringComparison.Ordinal); - return new ImportResult - { + return new ImportResult { Route = routeIsNew ? route : null, NewWidgets = newWidgets, NewCssVariables = newCssVariables, @@ -476,28 +438,27 @@ public static async Task ImportExtractedRouteAsync( private record ManifestWidget(JsonElement Element, string HtmlAbsPath, string OverlayFolder, string? PackFolder); - private static ManifestWidget ResolveManifestWidget(JsonElement wEl, string extractDir) - { - string htmlZipRelPath = wEl.TryGetProperty("htmlPath", out var hp) ? hp.GetString() ?? "" : ""; - + private static ManifestWidget ResolveManifestWidget(JsonElement wEl, string extractDir) { + string htmlZipRelPath = wEl.TryGetProperty("htmlPath", out JsonElement hp) ? hp.GetString() ?? "" : ""; + string overlayPath = Path.GetFullPath( Path.Combine(extractDir, htmlZipRelPath.Replace('/', Path.DirectorySeparatorChar))); string overlayFolder = Path.GetDirectoryName(overlayPath) ?? extractDir; - if (!wEl.TryGetProperty("pack", out var packEl) || packEl.ValueKind != JsonValueKind.Object) + if (!wEl.TryGetProperty("pack", out JsonElement packEl) || packEl.ValueKind != JsonValueKind.Object) return new ManifestWidget(wEl, overlayPath, overlayFolder, null); - string file = packEl.TryGetProperty("file", out var f) ? f.GetString() ?? "" : ""; + string file = packEl.TryGetProperty("file", out JsonElement f) ? f.GetString() ?? "" : ""; if (string.IsNullOrWhiteSpace(file)) return new ManifestWidget(wEl, overlayPath, overlayFolder, null); string smwPath = Path.GetFullPath( Path.Combine(extractDir, file.Replace('/', Path.DirectorySeparatorChar))); - var packManifest = WidgetPackInstaller.ReadManifest(smwPath); + WidgetPackManifest? packManifest = WidgetPackInstaller.ReadManifest(smwPath); string entry = packManifest?.Entry ?? string.Empty; if (string.IsNullOrWhiteSpace(entry)) - entry = packEl.TryGetProperty("entry", out var en) ? en.GetString() ?? "" : ""; + entry = packEl.TryGetProperty("entry", out JsonElement en) ? en.GetString() ?? "" : ""; if (string.IsNullOrWhiteSpace(entry) || !File.Exists(smwPath)) return new ManifestWidget(wEl, overlayPath, overlayFolder, null); @@ -511,12 +472,10 @@ private static ManifestWidget ResolveManifestWidget(JsonElement wEl, string extr return new ManifestWidget(wEl, htmlPath, overlayFolder, packFolder); } - - private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) - { - if (mw.PackFolder != null) - { - var location = WidgetPackPaths.Resolve(widget.HtmlPath); + + private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) { + if (mw.PackFolder != null) { + WidgetPackPaths.PackLocation? location = WidgetPackPaths.Resolve(widget.HtmlPath); return location != null && string.Equals(location.PackFolderStr, mw.PackFolder, StringComparison.OrdinalIgnoreCase); } @@ -524,17 +483,17 @@ private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) return string.Equals(widget.HtmlPath, mw.HtmlAbsPath, StringComparison.OrdinalIgnoreCase); } - private static Route BuildNewRoute(JsonElement routeEl, string routeName) => new Route - { - Id = Guid.NewGuid(), - Name = routeName, - Width = routeEl.GetProperty("resolution").GetProperty("width").GetInt32(), - Height = routeEl.GetProperty("resolution").GetProperty("height").GetInt32(), - }; + private static Route BuildNewRoute(JsonElement routeEl, string routeName) { + return new Route { + Id = Guid.NewGuid(), + Name = routeName, + Width = routeEl.GetProperty("resolution").GetProperty("width").GetInt32(), + Height = routeEl.GetProperty("resolution").GetProperty("height").GetInt32() + }; + } - private static JsVariable? BuildJsVariable(JsonElement jsEl, Guid widgetId, string widgetFolder, bool isPacked) - { - var v = JsVariable.FromJson(jsEl, widgetId); + private static JsVariable? BuildJsVariable(JsonElement jsEl, Guid widgetId, string widgetFolder, bool isPacked) { + JsVariable? v = JsVariable.FromJson(jsEl, widgetId); if (v == null) return null; if (!((WidgetVariableType?)v.Type).IsFileVariable() || string.IsNullOrWhiteSpace(v.Value)) @@ -547,15 +506,14 @@ private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) resolvedValue = resolvedValue.Replace('\\', '/'); string normWidgetFolder = widgetFolder.Replace('\\', '/').TrimEnd('/') + "/"; - if (!resolvedValue.StartsWith(normWidgetFolder, StringComparison.OrdinalIgnoreCase)) - { + if (!resolvedValue.StartsWith(normWidgetFolder, StringComparison.OrdinalIgnoreCase)) { v.Value = resolvedValue; return v; } string relative = resolvedValue[normWidgetFolder.Length..]; bool bundledByOverlay = isPacked && - relative.StartsWith(ExternalFolder + "/", + relative.StartsWith(ExternalFolder + "/", StringComparison.OrdinalIgnoreCase); v.Value = bundledByOverlay ? resolvedValue : "./" + relative; @@ -567,51 +525,44 @@ private static bool MatchesManifestWidget(Widget widget, ManifestWidget mw) #region HELPERS - private static void SetRewrite(Dictionary> rewrites, Guid widgetId, string varName, string value) - { - if (!rewrites.TryGetValue(widgetId, out var inner)) - { + private static void SetRewrite(Dictionary> rewrites, Guid widgetId, string varName, + string value) { + if (!rewrites.TryGetValue(widgetId, out Dictionary? inner)) { inner = new Dictionary(); rewrites[widgetId] = inner; } + inner[varName] = value; } - private static string GetWidgetBaseFolder(string zipWidgetRoot) - { + private static string GetWidgetBaseFolder(string zipWidgetRoot) { int lastSlash = zipWidgetRoot.LastIndexOf('/'); return lastSlash > 0 ? zipWidgetRoot[..lastSlash] : zipWidgetRoot; } - public static List GetZipWidgetRoots(List absoluteFolderPaths) - { - if (absoluteFolderPaths.Count == 0) return new(); + public static List GetZipWidgetRoots(List absoluteFolderPaths) { + if (absoluteFolderPaths.Count == 0) return new List(); - var segmentSets = absoluteFolderPaths + List segmentSets = absoluteFolderPaths .Select(p => p.Replace('\\', '/').TrimEnd('/').Split('/', StringSplitOptions.RemoveEmptyEntries)) .ToList(); - static bool IsPrefix(string[] parent, string[] child) - { + static bool IsPrefix(string[] parent, string[] child) { if (parent.Length > child.Length) return false; return !parent.Where((t, i) => !string.Equals(t, child[i], StringComparison.OrdinalIgnoreCase)).Any(); } var roots = new List(segmentSets.Count); - foreach (var current in segmentSets) - { + foreach (string[] current in segmentSets) { string[]? bestRoot = null; - foreach (var candidate in segmentSets) - { + foreach (string[] candidate in segmentSets) { if (ReferenceEquals(candidate, current)) continue; if (IsPrefix(candidate, current)) - { if (bestRoot == null || candidate.Length > bestRoot.Length) bestRoot = candidate; - } } roots.Add(bestRoot ?? current); @@ -619,16 +570,15 @@ static bool IsPrefix(string[] parent, string[] child) var results = new List(segmentSets.Count); - for (int i = 0; i < segmentSets.Count; i++) - { - var segment = segmentSets[i]; - var root = roots[i]; + for (var i = 0; i < segmentSets.Count; i++) { + string[] segment = segmentSets[i]; + string[] root = roots[i]; string leaf = SanitizeName(segment[^1]); var sb = new StringBuilder("widgets"); - var parentParts = root.SkipLast(1).ToArray(); + string[] parentParts = root.SkipLast(1).ToArray(); string bucketSource = parentParts.Length > 0 ? string.Join("/", parentParts).ToLowerInvariant() @@ -639,8 +589,7 @@ static bool IsPrefix(string[] parent, string[] child) int start = root.Length - 1; - for (int j = start; j < segment.Length; j++) - { + for (int j = start; j < segment.Length; j++) { sb.Append('/'); sb.Append(SanitizeName(segment[j])); } @@ -651,19 +600,21 @@ static bool IsPrefix(string[] parent, string[] child) return results; } - private static string HashSegment(string segment) - { + private static string HashSegment(string segment) { using var sha = SHA256.Create(); - var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(segment)); + byte[] bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(segment)); return Convert.ToHexString(bytes)[..SegmentHashLength].ToLowerInvariant(); } - private static string SanitizeName(string name) => SafeFileName.Sanitize(name); + private static string SanitizeName(string name) { + return SafeFileName.Sanitize(name); + } + #endregion #region TYPES - public class ImportResult - { + + public class ImportResult { public Route? Route { get; init; } public List NewWidgets { get; init; } = []; public List NewCssVariables { get; init; } = []; @@ -680,19 +631,21 @@ public class ImportResult RouteIsNew || NewWidgets.Count > 0 || NewCssVariables.Count > 0 || NewJsVariables.Count > 0 || RepointedWidgets.Count > 0 || (MergedRouteName != null && MergedRouteId != null); - public static ImportResult Fail(string reason) => - new ImportResult { Failed = true, FailReason = reason }; + + public static ImportResult Fail(string reason) { + return new ImportResult { Failed = true, FailReason = reason }; + } } [ExcludeFromCodeCoverage] private record PackReference(string PackId, string Version, string Entry, string ZipPath); - private class ExportPlan - { + private class ExportPlan { public Dictionary WidgetFolderMap { get; } = new(); // debug helper public Dictionary WidgetPacks { get; } = new(); public List<(string Src, string ZipEntry)> FileCopies { get; } = []; public Dictionary> VariableRewrites { get; } = new(); } + #endregion } \ No newline at end of file diff --git a/SubathonManager.Data/Overlays/OverlayPackInstaller.cs b/SubathonManager.Data/Overlays/OverlayPackInstaller.cs index 00b5d5ea..69de2b82 100644 --- a/SubathonManager.Data/Overlays/OverlayPackInstaller.cs +++ b/SubathonManager.Data/Overlays/OverlayPackInstaller.cs @@ -3,8 +3,7 @@ namespace SubathonManager.Data.Overlays; -public class OverlayManifest -{ +public class OverlayManifest { public string FormatVersion { get; init; } = "1"; public string AppVersion { get; init; } = string.Empty; public string Name { get; init; } = string.Empty; @@ -13,68 +12,58 @@ public class OverlayManifest public List Tags { get; init; } = []; } -public static class OverlayPackInstaller -{ +public static class OverlayPackInstaller { public const string ManifestFileName = "overlay.json"; - public record InstalledOverlay(OverlayManifest Manifest, string ArchiveFile, string UnpackDir); - public static OverlayManifest? ReadManifest(string smoPath) - { - try - { + public static OverlayManifest? ReadManifest(string smoPath) { + try { using var stream = new FileStream(smoPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - var entry = archive.Entries.FirstOrDefault(e => + ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(e => string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); if (entry == null) return null; using var reader = new StreamReader(entry.Open()); - using var doc = JsonDocument.Parse(reader.ReadToEnd()); + using JsonDocument doc = JsonDocument.Parse(reader.ReadToEnd()); return Parse(doc.RootElement, smoPath); } - catch - { + catch { return null; } } - public static InstalledOverlay? Install(string smoPath) - { - var manifest = ReadManifest(smoPath); + public static InstalledOverlay? Install(string smoPath) { + OverlayManifest? manifest = ReadManifest(smoPath); if (manifest == null) return null; string archiveTarget = OverlayPackPaths.ArchiveFile(manifest.Author, manifest.Name, manifest.Version); string unpackDir = OverlayPackPaths.UnpackDir(manifest.Author, manifest.Name); - try - { + try { Directory.CreateDirectory(Path.GetDirectoryName(archiveTarget)!); if (!string.Equals(Path.GetFullPath(smoPath), Path.GetFullPath(archiveTarget), StringComparison.OrdinalIgnoreCase)) - File.Copy(smoPath, archiveTarget, overwrite: true); + File.Copy(smoPath, archiveTarget, true); Directory.CreateDirectory(unpackDir); ExtractSafely(archiveTarget, unpackDir); } - catch - { + catch { return null; } return new InstalledOverlay(manifest, archiveTarget, unpackDir); } - private static void ExtractSafely(string archivePath, string targetDir) - { + private static void ExtractSafely(string archivePath, string targetDir) { string root = Path.GetFullPath(targetDir) + Path.DirectorySeparatorChar; using var stream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - foreach (var entry in archive.Entries) - { + foreach (ZipArchiveEntry entry in archive.Entries) { if (string.IsNullOrEmpty(entry.Name)) continue; // directory marker string relative = entry.FullName.Replace('\\', '/').TrimStart('/'); @@ -84,13 +73,12 @@ private static void ExtractSafely(string archivePath, string targetDir) if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase)) continue; Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - entry.ExtractToFile(destination, overwrite: true); + entry.ExtractToFile(destination, true); } } - private static OverlayManifest Parse(JsonElement root, string smoPath) - { - var route = root.TryGetProperty("route", out var r) ? r : default; + private static OverlayManifest Parse(JsonElement root, string smoPath) { + JsonElement route = root.TryGetProperty("route", out JsonElement r) ? r : default; string name = Str(route, "name"); if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smoPath); @@ -99,8 +87,7 @@ private static OverlayManifest Parse(JsonElement root, string smoPath) if (string.IsNullOrWhiteSpace(version)) version = Str(root, "version"); if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; - return new OverlayManifest - { + return new OverlayManifest { FormatVersion = Str(root, "format_version"), AppVersion = Str(root, "app_version"), Name = name, @@ -110,16 +97,16 @@ private static OverlayManifest Parse(JsonElement root, string smoPath) }; } - private static string Str(JsonElement el, string name) - => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && - v.ValueKind == JsonValueKind.String + private static string Str(JsonElement el, string name) { + return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out JsonElement v) && + v.ValueKind == JsonValueKind.String ? v.GetString() ?? string.Empty : string.Empty; + } - private static List StrList(JsonElement el, string name) - { + private static List StrList(JsonElement el, string name) { if (el.ValueKind != JsonValueKind.Object || - !el.TryGetProperty(name, out var v) || + !el.TryGetProperty(name, out JsonElement v) || v.ValueKind != JsonValueKind.Array) return []; @@ -129,4 +116,6 @@ private static List StrList(JsonElement el, string name) .Where(s => !string.IsNullOrWhiteSpace(s)) .ToList(); } -} + + public record InstalledOverlay(OverlayManifest Manifest, string ArchiveFile, string UnpackDir); +} \ No newline at end of file diff --git a/SubathonManager.Data/Overlays/OverlayPackPaths.cs b/SubathonManager.Data/Overlays/OverlayPackPaths.cs index 6c7a7f2f..dd3b4648 100644 --- a/SubathonManager.Data/Overlays/OverlayPackPaths.cs +++ b/SubathonManager.Data/Overlays/OverlayPackPaths.cs @@ -1,18 +1,17 @@ using System.Text.RegularExpressions; using SubathonManager.Core; using SubathonManager.Data.Widgets; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data.Overlays; -public static partial class OverlayPackPaths -{ +public static partial class OverlayPackPaths { public const string OverlayExtension = ".smo"; public const string UnpackFolderName = "unpack"; public static string ImportsRoot => Path.GetFullPath(Path.Combine("./imports", "overlays")); - public static string OverlayRoot(string author, string name) - { + public static string OverlayRoot(string author, string name) { string authorFolder = WidgetPackPaths.Slug(author); if (string.IsNullOrEmpty(authorFolder)) authorFolder = "unknown"; @@ -22,37 +21,33 @@ public static string OverlayRoot(string author, string name) return Path.Combine(ImportsRoot, authorFolder, nameFolder); } - public static string ArchiveFile(string author, string name, string version) - { + public static string ArchiveFile(string author, string name, string version) { string versionFile = SanitizeSegment(version); if (string.IsNullOrEmpty(versionFile)) versionFile = "1.0.0"; return Path.Combine(OverlayRoot(author, name), versionFile + OverlayExtension); } - public static string UnpackDir(string author, string name) - => Path.Combine(OverlayRoot(author, name), UnpackFolderName); + public static string UnpackDir(string author, string name) { + return Path.Combine(OverlayRoot(author, name), UnpackFolderName); + } - public static List ImportedVersions(string author, string name) - { + public static List ImportedVersions(string author, string name) { string folder = OverlayRoot(author, name); if (!Directory.Exists(folder)) return []; - try - { + try { return Directory.EnumerateFiles(folder, "*" + OverlayExtension) .Select(Path.GetFileNameWithoutExtension) .Where(v => !string.IsNullOrWhiteSpace(v)) .Select(v => v!) .ToList(); } - catch - { + catch { return []; } } - public static string RouteName(string name, string version) - { + public static string RouteName(string name, string version) { string trimmed = (name ?? string.Empty).Trim(); if (string.IsNullOrEmpty(trimmed)) trimmed = "Imported Overlay"; @@ -60,18 +55,17 @@ public static string RouteName(string name, string version) return string.IsNullOrWhiteSpace(display) ? trimmed : $"{trimmed} v{display}"; } - public static string BaseRouteName(string? routeName) - { - return string.IsNullOrWhiteSpace(routeName) ? string.Empty : - VersionSuffixRegex().Replace(routeName.Trim(), string.Empty).Trim(); + public static string BaseRouteName(string? routeName) { + return string.IsNullOrWhiteSpace(routeName) + ? string.Empty + : VersionSuffixRegex().Replace(routeName.Trim(), string.Empty).Trim(); } [GeneratedRegex(@"\s+v\d[\w.\-]*$", RegexOptions.IgnoreCase)] private static partial Regex VersionSuffixRegex(); - public static string BuildFileName(string author, string name, string version) - { - var parts = new[] { author, name, version } + public static string BuildFileName(string author, string name, string version) { + IEnumerable parts = new[] { author, name, version } .Select(p => SanitizeSegment(p).Replace(' ', '-')) .Where(p => !string.IsNullOrWhiteSpace(p)); @@ -79,5 +73,7 @@ public static string BuildFileName(string author, string name, string version) return (string.IsNullOrWhiteSpace(joined) ? "overlay" : joined) + OverlayExtension; } - private static string SanitizeSegment(string? value) => SafeFileName.Sanitize(value); -} + private static string SanitizeSegment(string? value) { + return SafeFileName.Sanitize(value); + } +} \ No newline at end of file diff --git a/SubathonManager.Data/StateValueHelper.cs b/SubathonManager.Data/StateValueHelper.cs index 0fdc706b..0cde6433 100644 --- a/SubathonManager.Data/StateValueHelper.cs +++ b/SubathonManager.Data/StateValueHelper.cs @@ -3,65 +3,64 @@ namespace SubathonManager.Data; -public static class StateValueHelper -{ - private static T TypeDefault() - { +public static class StateValueHelper { + private static T TypeDefault() { if (typeof(T) == typeof(string)) return (T)(object)""; - return default(T)!; + return default!; } - private static T Parse(string value, T fallback) - { - try { return (T)Convert.ChangeType(value, typeof(T)); } - catch { return fallback; } + private static T Parse(string value, T fallback) { + try { + return (T)Convert.ChangeType(value, typeof(T)); + } + catch { + return fallback; + } } - public static T Get(AppDbContext db, string name, T? defaultValue = default) - { + public static T Get(AppDbContext db, string name, T? defaultValue = default) { T def = defaultValue is not null ? defaultValue : TypeDefault(); - var row = db.StateValues.AsNoTracking().FirstOrDefault(sv => sv.Name == name); - return row == null ? def : Parse(row.Value, def); + StateValue? row = db.StateValues.AsNoTracking().FirstOrDefault(sv => sv.Name == name); + return row == null ? def : Parse(row.Value, def); } - public static async Task GetAsync(IDbContextFactory factory, string name, T? defaultValue = default) - { - await using var db = await factory.CreateDbContextAsync(); - return Get(db, name, defaultValue); + public static async Task GetAsync(IDbContextFactory factory, string name, + T? defaultValue = default) { + await using AppDbContext db = await factory.CreateDbContextAsync(); + return Get(db, name, defaultValue); } - public static async Task SetAsync(AppDbContext db, string name, T value) where T : notnull - { + public static async Task SetAsync(AppDbContext db, string name, T value) where T : notnull { string strVal = value.ToString() ?? ""; string typeName = typeof(T).Name; - var existing = await db.StateValues.FindAsync(name); - if (existing == null) + StateValue? existing = await db.StateValues.FindAsync(name); + if (existing == null) { db.StateValues.Add(new StateValue { Name = name, Value = strVal, TypeName = typeName }); - else - { + } + else { existing.Value = strVal; existing.TypeName = typeName; } + await db.SaveChangesAsync(); } - public static async Task SetAsync(IDbContextFactory factory, string name, T value) where T : notnull - { - await using var db = await factory.CreateDbContextAsync(); + public static async Task SetAsync(IDbContextFactory factory, string name, T value) + where T : notnull { + await using AppDbContext db = await factory.CreateDbContextAsync(); await SetAsync(db, name, value); } - public static void Set(AppDbContext db, string name, T value) where T : notnull - { + public static void Set(AppDbContext db, string name, T value) where T : notnull { string strVal = value.ToString() ?? ""; string typeName = typeof(T).Name; - var existing = db.StateValues.Find(name); - if (existing == null) + StateValue? existing = db.StateValues.Find(name); + if (existing == null) { db.StateValues.Add(new StateValue { Name = name, Value = strVal, TypeName = typeName }); - else - { + } + else { existing.Value = strVal; existing.TypeName = typeName; } } -} +} \ No newline at end of file diff --git a/SubathonManager.Data/SubathonValueConfigHelper.cs b/SubathonManager.Data/SubathonValueConfigHelper.cs index b112929f..6df34a7e 100644 --- a/SubathonManager.Data/SubathonValueConfigHelper.cs +++ b/SubathonManager.Data/SubathonValueConfigHelper.cs @@ -1,23 +1,18 @@ using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.EntityFrameworkCore; -using System.Text.Json.Serialization; -using SubathonManager.Core.Events; +using SubathonManager.Core; using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; using SubathonManager.Core.Models; -using SubathonManager.Core; + namespace SubathonManager.Data; -public class SubathonValueConfigHelper -{ - private readonly ILogger? _logger; - private readonly IDbContextFactory _factory; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - Converters = - { +public class SubathonValueConfigHelper { + private static readonly JsonSerializerOptions JsonOptions = new() { + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, PropertyNameCaseInsensitive = true, @@ -25,56 +20,53 @@ public class SubathonValueConfigHelper WriteIndented = true }; - public SubathonValueConfigHelper(IDbContextFactory? factory, ILogger? logger) - { + private readonly IDbContextFactory _factory; + private readonly ILogger? _logger; + + public SubathonValueConfigHelper(IDbContextFactory? factory, ILogger? logger) { _factory = factory ?? AppServices.Provider.GetRequiredService>(); _logger = logger ?? AppServices.Provider?.GetRequiredService>(); } - - public string GetAllAsJson() - { - using var db = _factory.CreateDbContext(); - var values = db.SubathonValues + public string GetAllAsJson() { + using AppDbContext db = _factory.CreateDbContext(); + + List values = db.SubathonValues .AsNoTracking() .ToList(); - var dtoList = values.Select(v => v.ToObject()); + IEnumerable dtoList = values.Select(v => v.ToObject()); return JsonSerializer.Serialize(dtoList, JsonOptions); } - - public async Task GetAllAsJsonAsync(List? filterSources = null) - { - if (filterSources == null || filterSources.Count == 0) filterSources = Enum.GetValues().ToList(); + + public async Task GetAllAsJsonAsync(List? filterSources = null) { + if (filterSources == null || filterSources.Count == 0) + filterSources = Enum.GetValues().ToList(); // filter by source list, future scope - await using var db = await _factory.CreateDbContextAsync(); + await using AppDbContext db = await _factory.CreateDbContextAsync(); - var values = await db.SubathonValues + List values = await db.SubathonValues .AsNoTracking() .ToListAsync(); - var dtoList = values.Where(v => filterSources.Contains(( + IEnumerable dtoList = values.Where(v => filterSources.Contains(( (SubathonEventType?)v.EventType).GetSource())).Select(v => v.ToObject()); return JsonSerializer.Serialize(dtoList, JsonOptions); } - public async Task PatchFromJsonDataAsync(JsonElement data) - { + public async Task PatchFromJsonDataAsync(JsonElement data) { string json = JsonSerializer.Serialize(data, JsonOptions); return await PatchFromJsonAsync(json); } - public async Task PatchFromJsonAsync(string json) - { + public async Task PatchFromJsonAsync(string json) { List? incoming = null; - List? success = new (); - try - { + List? success = new(); + try { incoming = JsonSerializer.Deserialize>(json, JsonOptions); } - catch (Exception ex) - { + catch (Exception ex) { string msg = "Could not parse Value Config Patch: " + json; ErrorMessageEvents.RaiseErrorEvent("ERROR", "SYSTEM", msg, DateTime.Now); _logger?.LogError(ex.Message, msg); @@ -84,25 +76,23 @@ public async Task PatchFromJsonAsync(string json) if (incoming == null || incoming.Count == 0) return -1; - await using var db = await _factory.CreateDbContextAsync(); + await using AppDbContext db = await _factory.CreateDbContextAsync(); - var dbValues = await db.SubathonValues.ToListAsync(); + List dbValues = await db.SubathonValues.ToListAsync(); - int patched = 0; + var patched = 0; - foreach (var dto in incoming) - { - var meta = dto.Meta; + foreach (SubathonValueDto dto in incoming) { + string meta = dto.Meta; if (dto is { Source: SubathonEventSource.GoAffPro, EventType: SubathonEventType.GoAffProOrder } - && !int.TryParse(dto.Meta, out _)) - { - if (!GoAffProStoreRegistry.TryGetByInternalName(dto.Meta, out var store) + && !int.TryParse(dto.Meta, out _)) { + if (!GoAffProStoreRegistry.TryGetByInternalName(dto.Meta, out GoAffProStore? store) && !GoAffProOrderHelper.TryGetStoreByOrderKey(dto.Meta, out store)) continue; meta = store.SiteId.ToString(); } - var match = dbValues.FirstOrDefault(v => + SubathonValue? match = dbValues.FirstOrDefault(v => v.EventType == dto.EventType && v.Meta == meta && ((SubathonEventType?)v.EventType).GetSource() == dto.Source @@ -111,26 +101,22 @@ public async Task PatchFromJsonAsync(string json) if (match == null) continue; - if (match.PatchByObject(dto)) - { + if (match.PatchByObject(dto)) { patched++; success.Add(dto); } } - try - { - if (patched > 0) - { + try { + if (patched > 0) { await db.SaveChangesAsync(); - var newData = await GetAllAsJsonAsync(); + string newData = await GetAllAsJsonAsync(); SubathonEvents.RaiseSubathonValueConfigRequested(newData); SubathonEvents.RaiseSubathonValueConfigUpdatedRemote(); SubathonEvents.RaiseSubathonValuesPatched(success); } } - catch (Exception ex) - { + catch (Exception ex) { string msg = "Could not save Value Config Patch: " + json; ErrorMessageEvents.RaiseErrorEvent("ERROR", "SYSTEM", msg, DateTime.Now); _logger?.LogError(ex.Message, msg); @@ -141,5 +127,4 @@ public async Task PatchFromJsonAsync(string json) return patched; } - } \ No newline at end of file diff --git a/SubathonManager.Data/WidgetEntityHelper.cs b/SubathonManager.Data/WidgetEntityHelper.cs index 72f0fd82..d1e48bdd 100644 --- a/SubathonManager.Data/WidgetEntityHelper.cs +++ b/SubathonManager.Data/WidgetEntityHelper.cs @@ -1,113 +1,96 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.EntityFrameworkCore; -using System.Text.RegularExpressions; -using SubathonManager.Core.Events; +using SubathonManager.Core; using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; -using SubathonManager.Core; using SubathonManager.Core.Objects; namespace SubathonManager.Data; -public class WidgetEntityHelper -{ +public class WidgetEntityHelper { + private static readonly JsonSerializerOptions JsonOptions = new() { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; - private readonly ILogger? _logger; private readonly IDbContextFactory _factory; + private readonly ILogger? _logger; + private readonly List _protectedVarNames = ["height", "width", "url", "author", "version"]; - public WidgetEntityHelper(IDbContextFactory? factory, ILogger? logger) - { + public WidgetEntityHelper(IDbContextFactory? factory, ILogger? logger) { _factory = factory ?? AppServices.Provider.GetRequiredService>(); _logger = logger ?? AppServices.Provider?.GetRequiredService>(); } - public void SyncCssVariables(Widget widget) - { - using var db = _factory.CreateDbContext(); - var extracted = widget.ExtractCssVariablesFromFiles(); - List extractedNames = new List(); + public void SyncCssVariables(Widget widget) { + using AppDbContext db = _factory.CreateDbContext(); + List extracted = widget.ExtractCssVariablesFromFiles(); + var extractedNames = new List(); - foreach (var variable in extracted) - { - var cssVar = db.CssVariables + foreach (CssVariable variable in extracted) { + CssVariable? cssVar = db.CssVariables .FirstOrDefault(v => v.WidgetId == widget.Id && v.Name == variable.Name); - if (cssVar == null) - { + if (cssVar == null) { db.CssVariables.Add(variable); _logger?.LogDebug($"[Widget {widget.Name}] Added new CSS variable: {variable.Name}"); } - else - { - if (cssVar.Type != variable.Type) - { - cssVar.Type = variable.Type; - } + else { + if (cssVar.Type != variable.Type) cssVar.Type = variable.Type; - if (cssVar.Description != variable.Description) - { - cssVar.Description = variable.Description; - } + if (cssVar.Description != variable.Description) cssVar.Description = variable.Description; } extractedNames.Add(variable.Name); } - + db.SaveChanges(); - foreach (var variable in db.CssVariables.AsNoTracking() + foreach (CssVariable variable in db.CssVariables.AsNoTracking() .Where(v => v.WidgetId == widget.Id && !extractedNames.Contains(v.Name)) .ToList()) - { db.CssVariables.Remove(variable); - } //dedupe var seenNames = new HashSet(); - foreach (var variable in db.CssVariables.AsNoTracking() + foreach (CssVariable variable in db.CssVariables.AsNoTracking() .Where(v => v.WidgetId == widget.Id) .ToList()) - { if (!seenNames.Add(variable.Name)) - { db.CssVariables.Remove(variable); - } - } db.SaveChanges(); } - public void SyncJsVariables(Widget widget) - { + public void SyncJsVariables(Widget widget) { WidgetMeta metadata = ExtractWidgetMetadataSync(widget.HtmlPath); - var oldUrl = widget.DocsUrl; + string? oldUrl = widget.DocsUrl; widget.DocsUrl = !string.IsNullOrWhiteSpace(metadata.Url) && Uri.IsWellFormedUriString(metadata.Url, UriKind.Absolute) && !metadata.Url.Trim().Equals(widget.DocsUrl) ? metadata.Url.Trim() : widget.DocsUrl; - var (jsVars, extractedNames, updatedVars) = LoadNewJsVariables(widget, metadata); + (List jsVars, List extractedNames, List updatedVars) = + LoadNewJsVariables(widget, metadata); - using var db = _factory.CreateDbContext(); - if (oldUrl != widget.DocsUrl) - { - db.Widgets.First(w => w.Id == widget.Id).DocsUrl = widget.DocsUrl; - } + using AppDbContext db = _factory.CreateDbContext(); + if (oldUrl != widget.DocsUrl) db.Widgets.First(w => w.Id == widget.Id).DocsUrl = widget.DocsUrl; db.JsVariables.AddRange(jsVars); // db.JsVariables.UpdateRange(updatedVars); - foreach (var updated in updatedVars) - { - var tracked = db.JsVariables + foreach (JsVariable updated in updatedVars) { + JsVariable? tracked = db.JsVariables .FirstOrDefault(v => v.WidgetId == widget.Id && v.Name == updated.Name); - if (tracked != null) - { + if (tracked != null) { tracked.Value = updated.Value; tracked.Description = updated.Description; tracked.Type = updated.Type; @@ -118,96 +101,79 @@ public void SyncJsVariables(Widget widget) _logger?.LogDebug($"[Widget {widget.Name}] Added new JS variables: {jsVars.Count}"); - foreach (var variable in db.JsVariables + foreach (JsVariable variable in db.JsVariables .Where(v => v.WidgetId == widget.Id && !extractedNames.Contains(v.Name)) - .ToList()) - { - if (variable.Type.IsFontVariable() && string.Equals(variable.Name, $"{variable.Type}s")) - { - continue; - } + .ToList()) { + if (variable.Type.IsFontVariable() && string.Equals(variable.Name, $"{variable.Type}s")) continue; db.JsVariables.Remove(variable); } var seenNames = new HashSet(); - foreach (var variable in db.JsVariables.AsNoTracking() + foreach (JsVariable variable in db.JsVariables.AsNoTracking() .Where(v => v.WidgetId == widget.Id) .ToList()) - { // dupe check if (!seenNames.Add(variable.Name)) - { db.JsVariables.Remove(variable); - } - } - - var existingFontTypesInDb = db.JsVariables + + List existingFontTypesInDb = db.JsVariables .Where(v => v.WidgetId == widget.Id && WidgetVariableTypeHelper.FontVariables.ToList().Contains(v.Type)) .Select(v => v.Type) .Distinct() .ToList(); - var missingFontTypes = WidgetVariableTypeHelper.FontVariables + List missingFontTypes = WidgetVariableTypeHelper.FontVariables .Where(x => !existingFontTypesInDb.Contains(x)) .ToList(); - foreach (var fontType in missingFontTypes) - { - db.JsVariables.Add(new JsVariable - { + foreach (WidgetVariableType fontType in missingFontTypes) + db.JsVariables.Add(new JsVariable { WidgetId = widget.Id, Type = fontType, Name = $"{fontType}s", Description = $"Custom font names to include from {fontType}s, comma separated", Value = string.Empty - }); - } + }); db.SaveChanges(); } - public (List, List, List) LoadNewJsVariables(Widget widget, Dictionary metadata) - { + public (List, List, List) LoadNewJsVariables(Widget widget, + Dictionary metadata) { return LoadNewJsVariables(widget, ConvertHtmlMetaToJsonMeta(metadata)); } - public (List, List, List) LoadNewJsVariables(Widget widget, WidgetMeta metadata) - { + public (List, List, List) LoadNewJsVariables(Widget widget, WidgetMeta metadata) { var extractedVars = new List(); var extractedNames = new List(); var updatedVars = new List(); - foreach (var (varName, metaVar) in metadata.Vars) - { + foreach ((string varName, WidgetMetaVar metaVar) in metadata.Vars) { if (string.IsNullOrEmpty(varName) || "/?<>~!@#$%^&*()_+=-{}|\\]['\";:,.".Contains(varName[0])) continue; if (extractedNames.Contains(varName)) continue; extractedNames.Add(varName); - var existingVar = widget.JsVariables.Find(v => v.Name == varName); - var description = metaVar.Description; + JsVariable? existingVar = widget.JsVariables.Find(v => v.Name == varName); + string description = metaVar.Description; - if (metaVar.Type == WidgetVariableType.StringSelect && existingVar != null) - { - var oldVals = existingVar.Value.Split(',').Select(v => v.Trim()).ToList(); - var newVals = metaVar.Options ?? ((string)metaVar.Value).Split(',').Select(v => v.Trim()).ToList(); - foreach (var v in newVals) - if (!oldVals.Contains(v)) oldVals.Add(v); + if (metaVar.Type == WidgetVariableType.StringSelect && existingVar != null) { + List oldVals = existingVar.Value.Split(',').Select(v => v.Trim()).ToList(); + List newVals = + metaVar.Options ?? ((string)metaVar.Value).Split(',').Select(v => v.Trim()).ToList(); + foreach (string v in newVals) + if (!oldVals.Contains(v)) + oldVals.Add(v); oldVals.RemoveAll(v => !newVals.Contains(v)); existingVar.Value = string.Join(",", oldVals); - if (!string.Equals(description, existingVar.Description)) - { - existingVar.Description = description; - } + if (!string.Equals(description, existingVar.Description)) existingVar.Description = description; updatedVars.Add(existingVar); continue; } - if (existingVar != null && existingVar.Type != WidgetVariableType.StringSelect) - { - if (!string.Equals(description, existingVar.Description) || existingVar.Type != metaVar.Type) - { + if (existingVar != null && existingVar.Type != WidgetVariableType.StringSelect) { + if (!string.Equals(description, existingVar.Description) || existingVar.Type != metaVar.Type) { existingVar.Description = description; existingVar.Type = metaVar.Type; updatedVars.Add(existingVar); @@ -216,19 +182,14 @@ public void SyncJsVariables(Widget widget) continue; } - var value = metaVar.ValueToString(); - + string value = metaVar.ValueToString(); + if (metaVar.Type is WidgetVariableType.EventTypeSelect or WidgetVariableType.EventSubTypeSelect) - { if (!string.IsNullOrWhiteSpace(value) && !Enum.TryParse(metaVar.Type.GetClsSingleType(), value, true, out _)) - { value = string.Empty; - } - } - - extractedVars.Add(new JsVariable - { + + extractedVars.Add(new JsVariable { Name = varName, WidgetId = widget.Id, Type = metaVar.Type, @@ -239,123 +200,100 @@ public void SyncJsVariables(Widget widget) return (extractedVars, extractedNames, updatedVars); } - - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - Converters = { new JsonStringEnumConverter() } - }; - public WidgetMeta ExtractWidgetMetadataSync(string htmlpath) - { - var jsonPath = htmlpath + ".json"; - if (!EnsureMetaSidecar(htmlpath, jsonPath, out var packedFallback)) + public WidgetMeta ExtractWidgetMetadataSync(string htmlpath) { + string jsonPath = htmlpath + ".json"; + if (!EnsureMetaSidecar(htmlpath, jsonPath, out WidgetMeta packedFallback)) return packedFallback; - try - { - var json = WidgetFiles.Current.ReadAllText(jsonPath); + try { + string? json = WidgetFiles.Current.ReadAllText(jsonPath); if (json != null) return JsonSerializer.Deserialize(json, JsonOptions) ?? new WidgetMeta(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "Failed to read widget meta JSON at {Path}", jsonPath); } return new WidgetMeta(); } - public Task ExtractWidgetMetadata(string htmlpath) - => Task.FromResult(ExtractWidgetMetadataSync(htmlpath)); - - private bool EnsureMetaSidecar(string htmlpath, string jsonPath, out WidgetMeta packedFallback) - { + public Task ExtractWidgetMetadata(string htmlpath) { + return Task.FromResult(ExtractWidgetMetadataSync(htmlpath)); + } + + private bool EnsureMetaSidecar(string htmlpath, string jsonPath, out WidgetMeta packedFallback) { packedFallback = new WidgetMeta(); if (WidgetFiles.Current.Exists(jsonPath)) return true; if (WidgetFiles.Current.IsPacked(htmlpath)) return false; - var html = WidgetFiles.Current.ReadAllText(htmlpath); + string? html = WidgetFiles.Current.ReadAllText(htmlpath); if (html == null) return false; - try - { - var meta = ConvertHtmlMetaToJsonMeta(GetMetaDataHtml(html)); + try { + WidgetMeta meta = ConvertHtmlMetaToJsonMeta(GetMetaDataHtml(html)); File.WriteAllText(jsonPath, JsonSerializer.Serialize(meta, JsonOptions)); _logger?.LogDebug("Wrote widget meta JSON to {Path}", jsonPath); return true; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "Failed to write widget meta JSON to {Path}", jsonPath); return false; } } - internal WidgetMeta ConvertHtmlMetaToJsonMeta(Dictionary data) - { - WidgetMeta meta = new() - { + internal WidgetMeta ConvertHtmlMetaToJsonMeta(Dictionary data) { + WidgetMeta meta = new() { Author = data.GetValueOrDefault("Author", string.Empty), Url = data.GetValueOrDefault("Url", string.Empty), - Width = int.TryParse(data.GetValueOrDefault("Width", "400"), out var width) ? width : 400, - Height = int.TryParse(data.GetValueOrDefault("Height", "200"), out var height) ? height : 200 + Width = int.TryParse(data.GetValueOrDefault("Width", "400"), out int width) ? width : 400, + Height = int.TryParse(data.GetValueOrDefault("Height", "200"), out int height) ? height : 200 }; - foreach (var key in data.Keys)//.Where(x => x.Contains('.'))) + foreach (string key in data.Keys) //.Where(x => x.Contains('.'))) { - var parts = key.Split('.'); - var varName = parts[0]; - if (_protectedVarNames.Contains(varName.ToLower())) continue; - if (parts.Length < 2) - { - parts = [varName, "String"]; // default case if missing - } + string[] parts = key.Split('.'); + string varName = parts[0]; + if (_protectedVarNames.Contains(varName.ToLower())) continue; + if (parts.Length < 2) parts = [varName, "String"]; // default case if missing if (string.IsNullOrEmpty(varName) || parts.Length < 2) continue; if (!Enum.TryParse(parts[1], true, out WidgetVariableType type)) continue; - var rawValue = data.GetValueOrDefault(key, string.Empty); + string rawValue = data.GetValueOrDefault(key, string.Empty); if (string.Equals(rawValue, "NONE", StringComparison.OrdinalIgnoreCase)) rawValue = string.Empty; - WidgetMetaVar wVar = new() - { + WidgetMetaVar wVar = new() { Name = varName, - Type = type, + Type = type }; - if (type is WidgetVariableType.StringSelect) - { - var options = rawValue.Split(',') + if (type is WidgetVariableType.StringSelect) { + List options = rawValue.Split(',') .Select(v => v.Trim()) .Where(v => !string.IsNullOrEmpty(v) && v != "NONE") .ToList(); wVar.Options = options; wVar.Value = options.Count > 0 ? options[0] : string.Empty; } - else if (type is WidgetVariableType.EventSubTypeSelect or WidgetVariableType.EventTypeSelect) - { + else if (type is WidgetVariableType.EventSubTypeSelect or WidgetVariableType.EventTypeSelect) { wVar.Value = rawValue; } - else if (type.IsListType()) - { - var items = rawValue.Split(',') + else if (type.IsListType()) { + List items = rawValue.Split(',') .Select(v => v.Trim()) .Where(v => !string.IsNullOrEmpty(v) && v != "NONE") .ToList(); wVar.Value = items; } - else - { - wVar.Value = type switch - { - WidgetVariableType.Boolean => bool.TryParse(rawValue, out var b) && b, - WidgetVariableType.Int => int.TryParse(rawValue, out var i) ? i : 0, - WidgetVariableType.Percent => int.TryParse(rawValue, out var i) ? i : 0, - WidgetVariableType.Float => float.TryParse(rawValue, out var d) ? d : 0, + else { + wVar.Value = type switch { + WidgetVariableType.Boolean => bool.TryParse(rawValue, out bool b) && b, + WidgetVariableType.Int => int.TryParse(rawValue, out int i) ? i : 0, + WidgetVariableType.Percent => int.TryParse(rawValue, out int i) ? i : 0, + WidgetVariableType.Float => float.TryParse(rawValue, out float d) ? d : 0, _ => rawValue }; } @@ -365,59 +303,58 @@ internal WidgetMeta ConvertHtmlMetaToJsonMeta(Dictionary data) return meta; } - - + + private Dictionary GetMetaDataHtml(string html) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); var pattern = @""; - var match = Regex.Match(html, pattern, RegexOptions.Singleline); + Match match = Regex.Match(html, pattern, RegexOptions.Singleline); if (!match.Success) return result; - var block = match.Groups[1].Value; + string block = match.Groups[1].Value; - var lines = block.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - - foreach (var line in lines) - { - var trimmed = line.Trim(); + string[] lines = block.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (string line in lines) { + string trimmed = line.Trim(); - var index = trimmed.IndexOf(':'); + int index = trimmed.IndexOf(':'); if (index <= 0 || index == trimmed.Length - 1) continue; - var key = trimmed.Substring(0, index).Trim(); - var value = trimmed.Substring(index + 1).Trim(); + string key = trimmed.Substring(0, index).Trim(); + string value = trimmed.Substring(index + 1).Trim(); if (!string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(value)) result[key] = value; } + return result; } - private async Task<(Widget?, DbContext?)> GetWidgetForUpdate(string widgetId, Dictionary data) - { - if (data.Count == 0 || !Guid.TryParse(widgetId, out var widgetGuid)) return (null, null); - var db = await _factory.CreateDbContextAsync(); - var widget = await db.Widgets.FirstOrDefaultAsync(w => w.Id == widgetGuid); + private async Task<(Widget?, DbContext?)> + GetWidgetForUpdate(string widgetId, Dictionary data) { + if (data.Count == 0 || !Guid.TryParse(widgetId, out Guid widgetGuid)) return (null, null); + AppDbContext db = await _factory.CreateDbContextAsync(); + Widget? widget = await db.Widgets.FirstOrDefaultAsync(w => w.Id == widgetGuid); return widget == null ? (null, db) : (widget, db); } - - public async Task UpdateWidgetScale(string widgetId, Dictionary data) - { + + public async Task UpdateWidgetScale(string widgetId, Dictionary data) { (Widget?, DbContext?) result = await GetWidgetForUpdate(widgetId, data); - var widget = result.Item1; - await using var db = result.Item2; + Widget? widget = result.Item1; + await using DbContext? db = result.Item2; if (widget == null || db == null) return false; - + float origX = widget.X; float origY = widget.Y; - if (data.TryGetValue("scaleX", out var sxElem) && sxElem.TryGetSingle(out var sx)) widget.ScaleX = sx; - if (data.TryGetValue("scaleY", out var syElem) && syElem.TryGetSingle(out var sy)) widget.ScaleY = sy; - if (data.TryGetValue("x", out var xElem) && xElem.TryGetSingle(out var x)) widget.X = x; - if (data.TryGetValue("y", out var yElem) && yElem.TryGetSingle(out var y)) widget.Y = y; - + if (data.TryGetValue("scaleX", out JsonElement sxElem) && sxElem.TryGetSingle(out float sx)) widget.ScaleX = sx; + if (data.TryGetValue("scaleY", out JsonElement syElem) && syElem.TryGetSingle(out float sy)) widget.ScaleY = sy; + if (data.TryGetValue("x", out JsonElement xElem) && xElem.TryGetSingle(out float x)) widget.X = x; + if (data.TryGetValue("y", out JsonElement yElem) && yElem.TryGetSingle(out float y)) widget.Y = y; + await db.SaveChangesAsync(); WidgetEvents.RaiseScaleUpdated(widget); if (!origX.Equals(widget.X) || !origY.Equals(widget.Y)) @@ -425,41 +362,37 @@ public async Task UpdateWidgetScale(string widgetId, Dictionary UpdateWidgetPosition(string widgetId, Dictionary data) - { + + public async Task UpdateWidgetPosition(string widgetId, Dictionary data) { (Widget?, DbContext?) result = await GetWidgetForUpdate(widgetId, data); - var widget = result.Item1; - await using var db = result.Item2; + Widget? widget = result.Item1; + await using DbContext? db = result.Item2; if (widget == null || db == null) return false; - - if (data.TryGetValue("x", out var xElem) && xElem.TryGetSingle(out var x)) widget.X = x; - if (data.TryGetValue("y", out var yElem) && yElem.TryGetSingle(out var y)) widget.Y = y; - if (data.TryGetValue("z", out var zElem) && zElem.TryGetInt32(out var z)) widget.Z = z; - + + if (data.TryGetValue("x", out JsonElement xElem) && xElem.TryGetSingle(out float x)) widget.X = x; + if (data.TryGetValue("y", out JsonElement yElem) && yElem.TryGetSingle(out float y)) widget.Y = y; + if (data.TryGetValue("z", out JsonElement zElem) && zElem.TryGetInt32(out int z)) widget.Z = z; + await db.SaveChangesAsync(); WidgetEvents.RaisePositionUpdated(widget); await db.Entry(widget).ReloadAsync(); return true; - } - - public async Task UpdateWidgetDimensions(string widgetId, Dictionary data) - { + + public async Task UpdateWidgetDimensions(string widgetId, Dictionary data) { (Widget?, DbContext?) result = await GetWidgetForUpdate(widgetId, data); - var widget = result.Item1; - await using var db = result.Item2; + Widget? widget = result.Item1; + await using DbContext? db = result.Item2; if (widget == null || db == null) return false; - - if (data.TryGetValue("width", out var wEl) && wEl.TryGetInt32(out int w)) widget.Width = w; - if (data.TryGetValue("height", out var hEl) && hEl.TryGetInt32(out int h)) widget.Height = h; - if (data.TryGetValue("x", out var xEl) && xEl.TryGetSingle(out float x)) widget.X = x; - if (data.TryGetValue("y", out var yEl) && yEl.TryGetSingle(out float y)) widget.Y = y; - + + if (data.TryGetValue("width", out JsonElement wEl) && wEl.TryGetInt32(out int w)) widget.Width = w; + if (data.TryGetValue("height", out JsonElement hEl) && hEl.TryGetInt32(out int h)) widget.Height = h; + if (data.TryGetValue("x", out JsonElement xEl) && xEl.TryGetSingle(out float x)) widget.X = x; + if (data.TryGetValue("y", out JsonElement yEl) && yEl.TryGetSingle(out float y)) widget.Y = y; + await db.SaveChangesAsync(); WidgetEvents.RaiseSizeUpdated(widget); await db.Entry(widget).ReloadAsync(); return true; } - } \ No newline at end of file diff --git a/SubathonManager.Data/WidgetPorter.cs b/SubathonManager.Data/WidgetPorter.cs index ad78360f..35313a0c 100644 --- a/SubathonManager.Data/WidgetPorter.cs +++ b/SubathonManager.Data/WidgetPorter.cs @@ -10,28 +10,26 @@ using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Data.Widgets; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data; -public static partial class WidgetPorter -{ +public static partial class WidgetPorter { public const string ManifestFileName = WidgetPackInstaller.ManifestFileName; public const string ContentFolder = "content"; public const string ExternalFolder = "_external"; public const string SharedFolder = "_shared"; public const string FormatVersion = "1"; - private static readonly JsonSerializerOptions MetaOptions = new() - { + private static readonly JsonSerializerOptions MetaOptions = new() { WriteIndented = true, PropertyNameCaseInsensitive = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Converters = { new JsonStringEnumConverter() } }; - private static readonly JsonSerializerOptions ManifestOptions = new() - { + private static readonly JsonSerializerOptions ManifestOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Converters = { new JsonStringEnumConverter() } @@ -39,8 +37,7 @@ public static partial class WidgetPorter #region TYPES - public enum SmwEntryKind - { + public enum SmwEntryKind { Manifest, Entry, Css, @@ -49,8 +46,7 @@ public enum SmwEntryKind External } - public class SmwEntry - { + public class SmwEntry { public required string ZipEntry { get; init; } public string? AbsSource { get; init; } public SmwEntryKind Kind { get; init; } @@ -61,8 +57,7 @@ public class SmwEntry public string? UsageHint { get; init; } } - public class SmwExportOptions - { + public class SmwExportOptions { public string Name { get; init; } = string.Empty; public string Author { get; init; } = string.Empty; public string Group { get; init; } = string.Empty; @@ -77,41 +72,36 @@ public class SmwExportOptions public static readonly string[] PreviewExtensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"]; - public static string PreviewEntryName(string sourcePath) - { + public static string PreviewEntryName(string sourcePath) { string ext = Path.GetExtension(sourcePath).ToLowerInvariant(); if (!PreviewExtensions.Contains(ext)) ext = ".png"; return "preview" + ext; } - - public static string? ExtractExistingPreview(Widget widget) - { - var location = WidgetPackPaths.Resolve(widget.HtmlPath); + + public static string? ExtractExistingPreview(Widget widget) { + WidgetPackPaths.PackLocation? location = WidgetPackPaths.Resolve(widget.HtmlPath); if (location == null) return null; - var manifest = WidgetPackInstaller.ReadManifest(location.PackFileStr); + WidgetPackManifest? manifest = WidgetPackInstaller.ReadManifest(location.PackFileStr); if (manifest == null || string.IsNullOrWhiteSpace(manifest.PreviewImage)) return null; - var bytes = WidgetFiles.Current.ReadAllBytes( + byte[]? bytes = WidgetFiles.Current.ReadAllBytes( WidgetPackPaths.EntryPathIn(location.MountRootStr, manifest.PreviewImage)); if (bytes == null) return null; - try - { + try { string temp = Path.Combine(Path.GetTempPath(), $"smw-preview-{Guid.NewGuid():N}{Path.GetExtension(manifest.PreviewImage)}"); File.WriteAllBytes(temp, bytes); return temp; } - catch - { + catch { return null; } } - public static List ParseTags(string? raw) - { + public static List ParseTags(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return []; var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -120,8 +110,7 @@ public static List ParseTags(string? raw) .ToList(); } - public class ExportPlan - { + public class ExportPlan { public List Entries { get; } = []; public Dictionary VariableRewrites { get; } = new(StringComparer.OrdinalIgnoreCase); public string EntryZipPath { get; set; } = string.Empty; @@ -129,14 +118,15 @@ public class ExportPlan public Dictionary OptionalRewrites { get; } = new(StringComparer.OrdinalIgnoreCase); - public bool IsSelected(string zipEntry) - => Entries.Any(e => e.ZipEntry.Equals(zipEntry, StringComparison.OrdinalIgnoreCase) - && (e.Locked || e.DefaultSelected)); + public bool IsSelected(string zipEntry) { + return Entries.Any(e => e.ZipEntry.Equals(zipEntry, StringComparison.OrdinalIgnoreCase) + && (e.Locked || e.DefaultSelected)); + } - public string ResolveVarValue(JsVariable jsVar) - { - if (VariableRewrites.TryGetValue(jsVar.Name, out var rewritten)) return rewritten; - if (OptionalRewrites.TryGetValue(jsVar.Name, out var optional) && IsSelected(optional.ZipEntry)) + public string ResolveVarValue(JsVariable jsVar) { + if (VariableRewrites.TryGetValue(jsVar.Name, out string? rewritten)) return rewritten; + if (OptionalRewrites.TryGetValue(jsVar.Name, out (string ZipEntry, string Value) optional) && + IsSelected(optional.ZipEntry)) return optional.Value; return jsVar.Value ?? string.Empty; } @@ -146,34 +136,31 @@ public string ResolveVarValue(JsVariable jsVar) #region PLAN - public static ExportPlan BuildPlan(Widget widget) - { + public static ExportPlan BuildPlan(Widget widget) { var plan = new ExportPlan(); if (widget.Type.IsAsset() || !WidgetFiles.Current.Exists(widget.HtmlPath)) return plan; string widgetRoot = widget.GetPath(); - string htmlEntry = $"{ContentFolder}/{Path.GetFileName(widget.HtmlPath)}"; + var htmlEntry = $"{ContentFolder}/{Path.GetFileName(widget.HtmlPath)}"; plan.EntryZipPath = htmlEntry; - var cssPaths = widget.GetLinkedCssPaths(); + List cssPaths = widget.GetLinkedCssPaths(); var cssZipMap = new Dictionary(StringComparer.OrdinalIgnoreCase); var htmlLinkRewrites = new Dictionary(StringComparer.Ordinal); - foreach (var cssPath in cssPaths) - { + foreach (string cssPath in cssPaths) { string? entry = ToContentEntry(widgetRoot, cssPath); - if (entry == null) - { + if (entry == null) { entry = $"{ContentFolder}/{SharedFolder}/{Path.GetFileName(cssPath)}"; string original = GetHtmlLinkFor(widget, cssPath); if (!string.IsNullOrEmpty(original)) htmlLinkRewrites[original] = $"./{SharedFolder}/{Path.GetFileName(cssPath)}"; } + cssZipMap[cssPath] = entry; } - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = ManifestFileName, Kind = SmwEntryKind.Manifest, DefaultSelected = true, @@ -184,7 +171,7 @@ public static ExportPlan BuildPlan(Widget widget) string htmlText = WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty; var hardcodedResources = new HashSet(ResourcePaths.FindReferences(htmlText), StringComparer.OrdinalIgnoreCase); - foreach (var cssPath in cssPaths) + foreach (string cssPath in cssPaths) hardcodedResources.UnionWith(ResourcePaths.FindReferences(WidgetFiles.Current.ReadAllText(cssPath))); Func? htmlGenerator = null; @@ -192,8 +179,7 @@ public static ExportPlan BuildPlan(Widget widget) htmlGenerator = _ => Encoding.UTF8.GetBytes( RewriteResourceUrls(RewriteHtmlLinks(htmlText, htmlLinkRewrites), htmlEntry, plan)); - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = htmlEntry, AbsSource = widget.HtmlPath, Kind = SmwEntryKind.Entry, @@ -202,8 +188,7 @@ public static ExportPlan BuildPlan(Widget widget) Generator = htmlGenerator }); - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = $"{htmlEntry}.json", AbsSource = WidgetFiles.Current.Exists(widget.HtmlPath + ".json") ? widget.HtmlPath + ".json" : null, Kind = SmwEntryKind.Meta, @@ -212,25 +197,22 @@ public static ExportPlan BuildPlan(Widget widget) }); var consumed = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var cssPath in cssPaths) - { + foreach (string cssPath in cssPaths) { string cssEntry = cssZipMap[cssPath]; - var varsInFile = ClaimCssVariables(widget, cssPath, consumed); + List varsInFile = ClaimCssVariables(widget, cssPath, consumed); string cssText = WidgetFiles.Current.ReadAllText(cssPath) ?? string.Empty; bool cssUsesResources = ResourcePaths.FindReferences(cssText).Any(); Func? cssGenerator = null; - if (varsInFile.Count > 0 || cssUsesResources) - { - var capturedVars = varsInFile; + if (varsInFile.Count > 0 || cssUsesResources) { + List capturedVars = varsInFile; string capturedEntry = cssEntry; cssGenerator = _ => Encoding.UTF8.GetBytes( RewriteResourceUrls(OverrideCssValues(cssText, capturedVars), capturedEntry, plan)); } - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = cssEntry, AbsSource = cssPath, Kind = SmwEntryKind.Css, @@ -246,9 +228,8 @@ public static ExportPlan BuildPlan(Widget widget) if (!hasMeta && !worthWriting) continue; string capturedMetaPath = cssMetaPath; - var capturedMetaVars = varsInFile; - plan.Entries.Add(new SmwEntry - { + List capturedMetaVars = varsInFile; + plan.Entries.Add(new SmwEntry { ZipEntry = $"{cssEntry}.json", AbsSource = hasMeta ? cssMetaPath : null, Kind = SmwEntryKind.Meta, @@ -259,13 +240,11 @@ public static ExportPlan BuildPlan(Widget widget) var claimed = new HashSet(plan.Entries.Select(en => en.ZipEntry), StringComparer.OrdinalIgnoreCase); - foreach (var file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) - { + foreach (string file in WidgetFiles.Current.EnumerateFiles(widgetRoot)) { string? entry = ToContentEntry(widgetRoot, file); if (entry == null || !claimed.Add(entry)) continue; - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = entry, AbsSource = file, Kind = SmwEntryKind.Asset, @@ -279,25 +258,21 @@ public static ExportPlan BuildPlan(Widget widget) } private static void AddSharedResourceEntries(Widget widget, ExportPlan plan, HashSet claimed, - HashSet hardcodedResources) - { + HashSet hardcodedResources) { var varUsed = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var jsVar in widget.JsVariables) - { + foreach (JsVariable jsVar in widget.JsVariables) { if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; if (ResourcePaths.RelativeFromUrl(jsVar.Value) is { } rel) varUsed[rel] = jsVar.Name; } - foreach (var rel in ResourcePaths.EnumerateRelative()) - { - string entry = $"{ContentFolder}/{ExternalFolder}/{ResourcePaths.BundleFolder}/{rel}"; + foreach (string rel in ResourcePaths.EnumerateRelative()) { + var entry = $"{ContentFolder}/{ExternalFolder}/{ResourcePaths.BundleFolder}/{rel}"; if (!claimed.Add(entry)) continue; - bool byVar = varUsed.TryGetValue(rel, out var varName); + bool byVar = varUsed.TryGetValue(rel, out string? varName); bool byMarkup = hardcodedResources.Contains(rel); - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = entry, AbsSource = ResourcePaths.ToLocalPath(ResourcePaths.UrlPrefix + rel), Kind = SmwEntryKind.External, @@ -305,7 +280,9 @@ private static void AddSharedResourceEntries(Widget widget, ExportPlan plan, Has InUse = byVar || byMarkup, UsageHint = byVar ? $"Used by variable \"{varName}\"" - : byMarkup ? "Referenced directly by this widget's html/css" : null + : byMarkup + ? "Referenced directly by this widget's html/css" + : null }); if (byVar) @@ -314,10 +291,9 @@ private static void AddSharedResourceEntries(Widget widget, ExportPlan plan, Has } } - private static void AddFileVariableEntries(Widget widget, string widgetRoot, ExportPlan plan, HashSet claimed) - { - foreach (var jsVar in widget.JsVariables) - { + private static void AddFileVariableEntries(Widget widget, string widgetRoot, ExportPlan plan, + HashSet claimed) { + foreach (JsVariable jsVar in widget.JsVariables) { if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; if (string.IsNullOrWhiteSpace(jsVar.Value)) continue; if (ResourcePaths.IsResourceUrl(jsVar.Value)) continue; @@ -325,58 +301,48 @@ private static void AddFileVariableEntries(Widget widget, string widgetRoot, Exp bool isFolderType = jsVar.Type == WidgetVariableType.FolderPath; bool isRelative = jsVar.Value.StartsWith("./") || jsVar.Value.StartsWith("../"); - if (isRelative) - { + if (isRelative) { string rel = jsVar.Value.TrimStart('.').TrimStart('/'); - string prefix = $"{ContentFolder}/{rel}"; - foreach (var entry in plan.Entries.Where(en => + var prefix = $"{ContentFolder}/{rel}"; + foreach (SmwEntry entry in plan.Entries.Where(en => en.Kind == SmwEntryKind.Asset && (en.ZipEntry.Equals(prefix, StringComparison.OrdinalIgnoreCase) || en.ZipEntry.StartsWith(prefix.TrimEnd('/') + "/", StringComparison.OrdinalIgnoreCase)))) - { entry.DefaultSelected = true; - } continue; } if (!Path.IsPathRooted(jsVar.Value)) continue; - if (isFolderType && Directory.Exists(jsVar.Value)) - { + if (isFolderType && Directory.Exists(jsVar.Value)) { string varFolder = SanitizeName(jsVar.Name); - foreach (var file in Directory.EnumerateFiles(jsVar.Value, "*", - SearchOption.AllDirectories)) - { + foreach (string file in Directory.EnumerateFiles(jsVar.Value, "*", + SearchOption.AllDirectories)) { string rel = Path.GetRelativePath(jsVar.Value, file).Replace('\\', '/'); - string entry = $"{ContentFolder}/{ExternalFolder}/{varFolder}/{rel}"; - + var entry = $"{ContentFolder}/{ExternalFolder}/{varFolder}/{rel}"; + if (!claimed.Add(entry)) continue; - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = entry, AbsSource = file, Kind = SmwEntryKind.External, DefaultSelected = true }); } - + plan.VariableRewrites[jsVar.Name] = $"./{ExternalFolder}/{varFolder}"; } - else if (!isFolderType && File.Exists(jsVar.Value)) - { + else if (!isFolderType && File.Exists(jsVar.Value)) { string fileName = Path.GetFileName(jsVar.Value); - string entry = $"{ContentFolder}/{ExternalFolder}/{fileName}"; + var entry = $"{ContentFolder}/{ExternalFolder}/{fileName}"; if (claimed.Add(entry)) - { - plan.Entries.Add(new SmwEntry - { + plan.Entries.Add(new SmwEntry { ZipEntry = entry, AbsSource = jsVar.Value, Kind = SmwEntryKind.External, DefaultSelected = true }); - } plan.VariableRewrites[jsVar.Name] = $"./{ExternalFolder}/{fileName}"; } } @@ -386,13 +352,10 @@ private static void AddFileVariableEntries(Widget widget, string widgetRoot, Exp #region EXPORT - public static async Task ExportWidgetAsync(ExportPlan plan, SmwExportOptions options, string outputPath) - { - var opts = options; + public static async Task ExportWidgetAsync(ExportPlan plan, SmwExportOptions options, string outputPath) { + SmwExportOptions opts = options; if (string.IsNullOrWhiteSpace(opts.AppVersion)) - { - opts = new SmwExportOptions - { + opts = new SmwExportOptions { Name = options.Name, Author = options.Author, Group = options.Group, @@ -401,90 +364,78 @@ public static async Task ExportWidgetAsync(ExportPlan plan, SmwExportOptions opt PreviewImagePath = options.PreviewImagePath, AppVersion = AppServices.AppVersion }; - } Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath))!); await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); - await using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false); + await using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, false); var written = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in plan.Entries) - { + foreach (SmwEntry entry in plan.Entries) { if (entry is { Locked: false, DefaultSelected: false }) continue; if (!written.Add(entry.ZipEntry)) continue; byte[]? bytes = null; - if (entry.Generator != null) - { + if (entry.Generator != null) { bytes = entry.Generator(opts); } - else if (entry.AbsSource != null) - { - if (File.Exists(entry.AbsSource)) - { + else if (entry.AbsSource != null) { + if (File.Exists(entry.AbsSource)) { await archive.CreateEntryFromFileAsync(entry.AbsSource, entry.ZipEntry, CompressionLevel.Optimal); continue; } + bytes = WidgetFiles.Current.ReadAllBytes(entry.AbsSource); } if (bytes == null) continue; - var zipEntry = archive.CreateEntry(entry.ZipEntry, CompressionLevel.Optimal); - await using var stream = await zipEntry.OpenAsync(); + ZipArchiveEntry zipEntry = archive.CreateEntry(entry.ZipEntry, CompressionLevel.Optimal); + await using Stream stream = await zipEntry.OpenAsync(); await stream.WriteAsync(bytes); } - if (!string.IsNullOrWhiteSpace(opts.PreviewImagePath) && File.Exists(opts.PreviewImagePath)) - { + if (!string.IsNullOrWhiteSpace(opts.PreviewImagePath) && File.Exists(opts.PreviewImagePath)) { string previewEntry = PreviewEntryName(opts.PreviewImagePath); if (written.Add(previewEntry)) await archive.CreateEntryFromFileAsync(opts.PreviewImagePath, previewEntry, CompressionLevel.Optimal); } } - + public static string ExportsDirectory => Path.GetFullPath(Path.Combine("./exports", "widgets")); - public static WidgetMeta ReadExistingMeta(Widget widget) - { - var json = WidgetFiles.Current.ReadAllText(widget.HtmlPath + ".json"); + public static WidgetMeta ReadExistingMeta(Widget widget) { + string? json = WidgetFiles.Current.ReadAllText(widget.HtmlPath + ".json"); if (json == null) return new WidgetMeta(); - try - { + try { return JsonSerializer.Deserialize(json, MetaOptions) ?? new WidgetMeta(); } - catch - { + catch { return new WidgetMeta(); } } - public static string BuildFileName(string author, string group, string name, string version) - { - var parts = new[] { author, WidgetPackPaths.NormalizeGroup(group), name, version } + public static string BuildFileName(string author, string group, string name, string version) { + IEnumerable parts = new[] { author, WidgetPackPaths.NormalizeGroup(group), name, version } .Select(p => SanitizeName(p?.Trim() ?? string.Empty).Replace(' ', '-')) .Where(p => !string.IsNullOrWhiteSpace(p)); return $"{string.Join('_', parts)}.smw"; } - private static string BuildManifest(Widget widget, ExportPlan plan, SmwExportOptions opts) - { + private static string BuildManifest(Widget widget, ExportPlan plan, SmwExportOptions opts) { string name = string.IsNullOrWhiteSpace(opts.Name) ? widget.Name : opts.Name; string group = WidgetPackPaths.NormalizeGroup(opts.Group); - string packId = WidgetPackPaths.TryResolve(widget.HtmlPath, - out _, out _, out var existingPackId, out _) + string packId = WidgetPackPaths.TryResolve(widget.HtmlPath, + out _, out _, out string existingPackId, out _) ? existingPackId : WidgetPackPaths.MakePackId(opts.Author, group, name); - var obj = new - { + var obj = new { version = FormatVersion, app_version = opts.AppVersion, exported_at = DateTime.UtcNow, - widget = new - { + widget = new { pack_id = packId, name, author = opts.Author, @@ -508,17 +459,15 @@ private static string BuildManifest(Widget widget, ExportPlan plan, SmwExportOpt #region OVERRIDES - private static List ClaimCssVariables(Widget widget, string cssPath, HashSet consumed) - { + private static List ClaimCssVariables(Widget widget, string cssPath, HashSet consumed) { var result = new List(); string? content = WidgetFiles.Current.ReadAllText(cssPath); if (content == null) return result; - foreach (Match match in CssVarRegex().Matches(content)) - { + foreach (Match match in CssVarRegex().Matches(content)) { string name = match.Groups[1].Value.Trim(); if (!consumed.Add(name)) continue; - var dbVar = widget.CssVariables.FirstOrDefault(v => + CssVariable? dbVar = widget.CssVariables.FirstOrDefault(v => string.Equals(v.Name, name, StringComparison.OrdinalIgnoreCase)); if (dbVar != null) result.Add(dbVar); } @@ -526,8 +475,7 @@ private static List ClaimCssVariables(Widget widget, string cssPath return result; } - private static string RewriteResourceUrls(string text, string ownZipEntry, ExportPlan plan) - { + private static string RewriteResourceUrls(string text, string ownZipEntry, ExportPlan plan) { if (string.IsNullOrEmpty(text)) return text; int depth = ownZipEntry.Split('/').Length - 2; @@ -539,37 +487,33 @@ private static string RewriteResourceUrls(string text, string ownZipEntry, Expor : null); } - private static string OverrideCssValues(string css, List variables) - { - foreach (var variable in variables) - { + private static string OverrideCssValues(string css, List variables) { + foreach (CssVariable variable in variables) { if (string.IsNullOrWhiteSpace(variable.Value)) continue; var pattern = new Regex($@"(--{Regex.Escape(variable.Name)}\s*:\s*)([^;]*)(;)"); css = pattern.Replace(css, m => $"{m.Groups[1].Value}{variable.Value}{m.Groups[3].Value}", 1); } + return css; } - private static string BuildCssMetaJson(string existingMetaPath, List variables) - { + private static string BuildCssMetaJson(string existingMetaPath, List variables) { Dictionary> meta = new(StringComparer.Ordinal); - var existingJson = WidgetFiles.Current.ReadAllText(existingMetaPath); + string? existingJson = WidgetFiles.Current.ReadAllText(existingMetaPath); if (existingJson != null) - { - try - { + try { meta = JsonSerializer.Deserialize>>(existingJson) ?? meta; } - catch { /**/ } - } + catch { + /**/ + } - foreach (var variable in variables) - { - if (!meta.TryGetValue(variable.Name, out var entry)) - { + foreach (CssVariable variable in variables) { + if (!meta.TryGetValue(variable.Name, out Dictionary? entry)) { entry = new Dictionary(StringComparer.Ordinal); meta[variable.Name] = entry; } + entry["type"] = variable.Type.ToString(); entry["description"] = variable.Description ?? string.Empty; } @@ -577,8 +521,7 @@ private static string BuildCssMetaJson(string existingMetaPath, List items = value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .ToList(); - switch (type) - { + switch (type) { case WidgetVariableType.StringSelect: metaVar.Options = items; return items.Count > 0 ? items[0] : string.Empty; case WidgetVariableType.Boolean: - return bool.TryParse(value, out var b) && b; + return bool.TryParse(value, out bool b) && b; case WidgetVariableType.Int: case WidgetVariableType.Percent: - return int.TryParse(value, out var i) ? i : 0; + return int.TryParse(value, out int i) ? i : 0; case WidgetVariableType.Float: - return float.TryParse(value, out var f) ? f : 0f; + return float.TryParse(value, out float f) ? f : 0f; default: if (type.IsListType()) return items; @@ -634,40 +574,39 @@ private static object ToMetaValue(WidgetVariableType type, string value, WidgetM #region HELPERS - private static string? ToContentEntry(string widgetRoot, string absPath) - { + private static string? ToContentEntry(string widgetRoot, string absPath) { string rel = Path.GetRelativePath(widgetRoot, absPath).Replace('\\', '/'); if (rel.StartsWith("../") || rel == ".." || Path.IsPathRooted(rel)) return null; return $"{ContentFolder}/{rel}"; } - private static string GetHtmlLinkFor(Widget widget, string cssPath) - { + private static string GetHtmlLinkFor(Widget widget, string cssPath) { string baseDir = Path.GetDirectoryName(widget.HtmlPath)!; - foreach (Match match in Widget.CssLinkMatches(WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty)) - { + foreach (Match match in + Widget.CssLinkMatches(WidgetFiles.Current.ReadAllText(widget.HtmlPath) ?? string.Empty)) { string href = match.Groups[1].Value; string resolved = Path.IsPathRooted(href) ? href : Path.Combine(baseDir, href); - if (string.Equals(Path.GetFullPath(resolved), Path.GetFullPath(cssPath), StringComparison.OrdinalIgnoreCase)) + if (string.Equals(Path.GetFullPath(resolved), Path.GetFullPath(cssPath), + StringComparison.OrdinalIgnoreCase)) return href; } + return string.Empty; } - private static string RewriteHtmlLinks(string html, Dictionary rewrites) - { - foreach (var (original, replacement) in rewrites) - { + private static string RewriteHtmlLinks(string html, Dictionary rewrites) { + foreach ((string original, string replacement) in rewrites) html = html.Replace($"\"{original}\"", $"\"{replacement}\"") - .Replace($"'{original}'", $"'{replacement}'"); - } + .Replace($"'{original}'", $"'{replacement}'"); return html; } - private static string SanitizeName(string name) => SafeFileName.Sanitize(name); + private static string SanitizeName(string name) { + return SafeFileName.Sanitize(name); + } [GeneratedRegex(@"--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);")] private static partial Regex CssVarRegex(); #endregion -} +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetCatalog.cs b/SubathonManager.Data/Widgets/WidgetCatalog.cs index 82f8471d..cf255314 100644 --- a/SubathonManager.Data/Widgets/WidgetCatalog.cs +++ b/SubathonManager.Data/Widgets/WidgetCatalog.cs @@ -4,98 +4,92 @@ using Microsoft.EntityFrameworkCore; using SubathonManager.Core.Enums; using SubathonManager.Core.Models; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data.Widgets; -public static class WidgetCatalog -{ +public static class WidgetCatalog { public static string PresetsRoot => WidgetPackPaths.PresetsRoot; public static string ImportsRoot => WidgetPackPaths.ImportsRoot; public static string PreviewCacheRoot => Path.GetFullPath(Path.Combine("./cache", "widget-previews")); - public record ScanRoot(string Path, WidgetCatalogSource Source); - - public static IEnumerable Roots() - { + public static IEnumerable Roots() { yield return new ScanRoot(PresetsRoot, WidgetCatalogSource.Preset); yield return new ScanRoot(ImportsRoot, WidgetCatalogSource.Imported); } public static async Task> RefreshAsync( - IDbContextFactory factory, CancellationToken ct = default) - { - await using var db = await factory.CreateDbContextAsync(ct); + IDbContextFactory factory, CancellationToken ct = default) { + await using AppDbContext db = await factory.CreateDbContextAsync(ct); - var rows = await db.WidgetCatalogEntries.ToListAsync(ct); + List rows = await db.WidgetCatalogEntries.ToListAsync(ct); var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var row in rows) + foreach (WidgetCatalogEntry row in rows) byPath.TryAdd(row.PackPath, row); var live = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var now = DateTime.UtcNow; - - foreach (var root in Roots()) - { - foreach (var file in EnumeratePackages(root.Path)) - { - ct.ThrowIfCancellationRequested(); + DateTime now = DateTime.UtcNow; - string stored = ToStoredPath(file); - if (!seen.Add(stored)) continue; + foreach (ScanRoot root in Roots()) + foreach (string file in EnumeratePackages(root.Path)) { + ct.ThrowIfCancellationRequested(); - FileInfo info; - try { info = new FileInfo(file); } - catch { continue; } + string stored = ToStoredPath(file); + if (!seen.Add(stored)) continue; - byPath.TryGetValue(stored, out var entry); + FileInfo info; + try { + info = new FileInfo(file); + } + catch { + continue; + } - bool unchanged = entry != null && - entry.FileSize == info.Length && - entry.FileModifiedTicks == info.LastWriteTimeUtc.Ticks; + byPath.TryGetValue(stored, out WidgetCatalogEntry? entry); - if (unchanged) - { - if (!string.IsNullOrWhiteSpace(entry!.PreviewImage) && - (string.IsNullOrWhiteSpace(entry.PreviewCachePath) || !File.Exists(entry.PreviewCachePath))) - entry.PreviewCachePath = ExtractPreview(file, entry.PreviewImage) ?? string.Empty; + bool unchanged = entry != null && + entry.FileSize == info.Length && + entry.FileModifiedTicks == info.LastWriteTimeUtc.Ticks; - entry.Source = root.Source; - entry.LastSeenUtc = now; - live.Add(entry); - continue; - } + if (unchanged) { + if (!string.IsNullOrWhiteSpace(entry!.PreviewImage) && + (string.IsNullOrWhiteSpace(entry.PreviewCachePath) || !File.Exists(entry.PreviewCachePath))) + entry.PreviewCachePath = ExtractPreview(file, entry.PreviewImage) ?? string.Empty; - var manifest = WidgetPackInstaller.ReadManifest(file); - if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) - { - if (entry != null) db.WidgetCatalogEntries.Remove(entry); - continue; - } + entry.Source = root.Source; + entry.LastSeenUtc = now; + live.Add(entry); + continue; + } - if (entry == null) - { - entry = new WidgetCatalogEntry { PackPath = stored }; - db.WidgetCatalogEntries.Add(entry); - } + WidgetPackManifest? manifest = WidgetPackInstaller.ReadManifest(file); + if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) { + if (entry != null) db.WidgetCatalogEntries.Remove(entry); + continue; + } - Fill(entry, file, info, manifest, root.Source, now); - live.Add(entry); + if (entry == null) { + entry = new WidgetCatalogEntry { PackPath = stored }; + db.WidgetCatalogEntries.Add(entry); } + + Fill(entry, file, info, manifest, root.Source, now); + live.Add(entry); } - foreach (var row in rows.Where(row => !seen.Contains(row.PackPath))) - { + foreach (WidgetCatalogEntry row in rows.Where(row => !seen.Contains(row.PackPath))) { db.WidgetCatalogEntries.Remove(row); DeletePreview(row.PreviewCachePath); } - try - { + try { await db.SaveChangesAsync(ct); } - catch { /**/ } + catch { + /**/ + } BuildIndex(live); @@ -108,8 +102,7 @@ public static async Task> RefreshAsync( } private static void Fill(WidgetCatalogEntry entry, string file, FileInfo info, - WidgetPackManifest manifest, WidgetCatalogSource source, DateTime now) - { + WidgetPackManifest manifest, WidgetCatalogSource source, DateTime now) { entry.FileSize = info.Length; entry.FileModifiedTicks = info.LastWriteTimeUtc.Ticks; entry.Source = source; @@ -122,93 +115,98 @@ private static void Fill(WidgetCatalogEntry entry, string file, FileInfo info, entry.DocsUrl = manifest.DocsUrl; entry.Tags = string.Join(", ", manifest.Tags); entry.PreviewImage = manifest.PreviewImage; - entry.PreviewCachePath = ExtractPreview(file, manifest.PreviewImage, force: true) ?? string.Empty; + entry.PreviewCachePath = ExtractPreview(file, manifest.PreviewImage, true) ?? string.Empty; entry.ScaleX = manifest.ScaleX; entry.ScaleY = manifest.ScaleY; entry.LastSeenUtc = now; } public static async Task RefreshEntryAsync(IDbContextFactory factory, - string packPath, CancellationToken ct = default) - { + string packPath, CancellationToken ct = default) { string file = ToAbsolutePath(packPath); - await using var db = await factory.CreateDbContextAsync(ct); - var entry = await db.WidgetCatalogEntries.FirstOrDefaultAsync(e => e.PackPath == packPath, ct); + await using AppDbContext db = await factory.CreateDbContextAsync(ct); + WidgetCatalogEntry? entry = await db.WidgetCatalogEntries.FirstOrDefaultAsync(e => e.PackPath == packPath, ct); - if (!File.Exists(file)) - { - if (entry != null) - { + if (!File.Exists(file)) { + if (entry != null) { db.WidgetCatalogEntries.Remove(entry); DeletePreview(entry.PreviewCachePath); - try { await db.SaveChangesAsync(ct); } catch { /**/ } + try { + await db.SaveChangesAsync(ct); + } + catch { + /**/ + } } return null; } - var manifest = WidgetPackInstaller.ReadManifest(file); + WidgetPackManifest? manifest = WidgetPackInstaller.ReadManifest(file); if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; FileInfo info; - try { info = new FileInfo(file); } - catch { return null; } + try { + info = new FileInfo(file); + } + catch { + return null; + } - if (entry == null) - { + if (entry == null) { entry = new WidgetCatalogEntry { PackPath = ToStoredPath(file) }; db.WidgetCatalogEntries.Add(entry); } Fill(entry, file, info, manifest, SourceFor(file), DateTime.UtcNow); - try - { + try { string cacheDir = WidgetPackPaths.CacheDirFor(file); - if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); + } + catch { + /**/ } - catch { /**/ } WidgetPackMemoryCache.InvalidatePack(file); WidgetPackPaths.InvalidateVersionCache(); WidgetPackPaths.InvalidateResolveCache(); - try { await db.SaveChangesAsync(ct); } catch { /**/ } + try { + await db.SaveChangesAsync(ct); + } + catch { + /**/ + } return entry; } - private static WidgetCatalogSource SourceFor(string file) - { - try - { + private static WidgetCatalogSource SourceFor(string file) { + try { return Path.GetFullPath(file).StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ? WidgetCatalogSource.Preset : WidgetCatalogSource.Imported; } - catch - { + catch { return WidgetCatalogSource.Imported; } } public static async Task DeleteAsync(IDbContextFactory factory, WidgetCatalogEntry entry, - CancellationToken ct = default) - { + CancellationToken ct = default) { if (entry.Source == WidgetCatalogSource.Preset) return false; string file = ToAbsolutePath(entry.PackPath); - try - { + try { if (File.Exists(file)) File.Delete(file); string cacheDir = WidgetPackPaths.CacheDirFor(file); - if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); } - catch - { + catch { return false; } @@ -217,105 +215,41 @@ public static async Task DeleteAsync(IDbContextFactory facto WidgetPackPaths.InvalidateVersionCache(); WidgetPackPaths.InvalidateResolveCache(); - try - { - await using var db = await factory.CreateDbContextAsync(ct); + try { + await using AppDbContext db = await factory.CreateDbContextAsync(ct); await db.WidgetCatalogEntries.Where(e => e.PackPath == entry.PackPath).ExecuteDeleteAsync(ct); } - catch { /**/ } + catch { + /**/ + } - try - { + try { string? folder = Path.GetDirectoryName(file); if (folder != null && Directory.Exists(folder) && !Directory.EnumerateFileSystemEntries(folder).Any() && WidgetPackPaths.IsInGlobalStore(folder)) Directory.Delete(folder); } - catch { /**/ } - - return true; - } - - #region pack id index - - private static readonly Lock IndexLock = new(); - private static Dictionary> _byPackId = new(StringComparer.OrdinalIgnoreCase); - private static Dictionary _byPath = new(StringComparer.OrdinalIgnoreCase); - - public static void BuildIndex(IEnumerable entries) - { - var byPackId = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var entry in entries) - { - byPath[entry.PackPath] = entry; - - if (string.IsNullOrWhiteSpace(entry.PackId)) continue; - - if (!byPackId.TryGetValue(entry.PackId, out var list)) - byPackId[entry.PackId] = list = new List(); - - list.Add(entry); - } - - lock (IndexLock) - { - _byPackId = byPackId; - _byPath = byPath; - } - } - - public static async Task LoadIndexAsync(IDbContextFactory factory, CancellationToken ct = default) - { - try - { - await using var db = await factory.CreateDbContextAsync(ct); - BuildIndex(await db.WidgetCatalogEntries.AsNoTracking().ToListAsync(ct)); - } - catch {/**/} - } - - public static WidgetCatalogEntry? EntryForPackFile(string packFile) - { - string stored = ToStoredPath(packFile); - - lock (IndexLock) - { - if (_byPath.TryGetValue(stored, out var byStored)) - return byStored; - - return _byPath.GetValueOrDefault(Path.GetFullPath(packFile)); + catch { + /**/ } - } - public static List EntriesForPackId(string packId) - { - lock (IndexLock) - { - return _byPackId.TryGetValue(packId, out var list) - ? [..list] : []; - } + return true; } - #endregion - - public static string ToAbsolutePath(string storedPath) - { - try - { + public static string ToAbsolutePath(string storedPath) { + try { return Path.IsPathRooted(storedPath) ? storedPath : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), storedPath)); } - catch { return storedPath; } + catch { + return storedPath; + } } - private static string ToStoredPath(string absolute) - { - try - { + private static string ToStoredPath(string absolute) { + try { string full = Path.GetFullPath(absolute); string baseDir = Directory.GetCurrentDirectory(); string relative = Path.GetRelativePath(baseDir, full); @@ -324,26 +258,27 @@ private static string ToStoredPath(string absolute) ? full : relative; } - catch { return absolute; } + catch { + return absolute; + } } - private static IEnumerable EnumeratePackages(string root) - { + private static IEnumerable EnumeratePackages(string root) { if (!Directory.Exists(root)) yield break; IEnumerable files; - try - { + try { files = Directory.EnumerateFiles(root, "*" + WidgetPackPaths.PackExtension, SearchOption.AllDirectories); } - catch { yield break; } + catch { + yield break; + } - foreach (var file in files) + foreach (string file in files) yield return file; } - private static string? ExtractPreview(string packFile, string previewEntry, bool force = false) - { + private static string? ExtractPreview(string packFile, string previewEntry, bool force = false) { if (string.IsNullOrWhiteSpace(previewEntry)) return null; string ext = Path.GetExtension(previewEntry); @@ -352,46 +287,108 @@ private static IEnumerable EnumeratePackages(string root) string target = Path.Combine(PreviewCacheRoot, PreviewKey(packFile) + ext); if (!force && File.Exists(target)) return target; - try - { + try { Directory.CreateDirectory(PreviewCacheRoot); using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); string wanted = previewEntry.Replace('\\', '/').TrimStart('/'); - var entry = archive.Entries.FirstOrDefault(e => + ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(e => string.Equals(e.FullName.Replace('\\', '/'), wanted, StringComparison.OrdinalIgnoreCase)); if (entry == null) return null; - entry.ExtractToFile(target, overwrite: true); + entry.ExtractToFile(target, true); return target; } - catch - { + catch { return null; } } - private static void DeletePreview(string? path) - { + private static void DeletePreview(string? path) { if (string.IsNullOrWhiteSpace(path)) return; - try - { + try { string full = Path.GetFullPath(path); if (!full.StartsWith(PreviewCacheRoot, StringComparison.OrdinalIgnoreCase)) return; if (File.Exists(full)) File.Delete(full); } - catch { /**/ } + catch { + /**/ + } } - private static string PreviewKey(string packFile) - { + private static string PreviewKey(string packFile) { string key; - try { key = Path.GetFullPath(packFile).ToLowerInvariant(); } - catch { key = packFile.ToLowerInvariant(); } + try { + key = Path.GetFullPath(packFile).ToLowerInvariant(); + } + catch { + key = packFile.ToLowerInvariant(); + } byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); - return $"{WidgetPackPaths.Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..12].ToLowerInvariant()}"; + return + $"{WidgetPackPaths.Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..12].ToLowerInvariant()}"; } -} + + public record ScanRoot(string Path, WidgetCatalogSource Source); + + #region pack id index + + private static readonly Lock IndexLock = new(); + private static Dictionary> _byPackId = new(StringComparer.OrdinalIgnoreCase); + private static Dictionary _byPath = new(StringComparer.OrdinalIgnoreCase); + + public static void BuildIndex(IEnumerable entries) { + var byPackId = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (WidgetCatalogEntry entry in entries) { + byPath[entry.PackPath] = entry; + + if (string.IsNullOrWhiteSpace(entry.PackId)) continue; + + if (!byPackId.TryGetValue(entry.PackId, out List? list)) + byPackId[entry.PackId] = list = new List(); + + list.Add(entry); + } + + lock (IndexLock) { + _byPackId = byPackId; + _byPath = byPath; + } + } + + public static async Task LoadIndexAsync(IDbContextFactory factory, CancellationToken ct = default) { + try { + await using AppDbContext db = await factory.CreateDbContextAsync(ct); + BuildIndex(await db.WidgetCatalogEntries.AsNoTracking().ToListAsync(ct)); + } + catch { + /**/ + } + } + + public static WidgetCatalogEntry? EntryForPackFile(string packFile) { + string stored = ToStoredPath(packFile); + + lock (IndexLock) { + if (_byPath.TryGetValue(stored, out WidgetCatalogEntry? byStored)) + return byStored; + + return _byPath.GetValueOrDefault(Path.GetFullPath(packFile)); + } + } + + public static List EntriesForPackId(string packId) { + lock (IndexLock) { + return _byPackId.TryGetValue(packId, out List? list) + ? [..list] + : []; + } + } + + #endregion +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs b/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs index 85678a15..3cffda48 100644 --- a/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs +++ b/SubathonManager.Data/Widgets/WidgetCollectionInstaller.cs @@ -3,8 +3,7 @@ namespace SubathonManager.Data.Widgets; -public class WidgetCollectionManifest -{ +public class WidgetCollectionManifest { public string FormatVersion { get; init; } = "1"; public string AppVersion { get; init; } = string.Empty; public string Name { get; init; } = string.Empty; @@ -14,87 +13,72 @@ public class WidgetCollectionManifest public List Tags { get; init; } = new(); } -public static class WidgetCollectionInstaller -{ +public static class WidgetCollectionInstaller { public const string CollectionExtension = ".smwc"; public const string ManifestFileName = "collection.json"; - public record InstalledCollection( - WidgetCollectionManifest? Manifest, - List Packs, - int Failed); - - public static WidgetCollectionManifest? ReadManifest(string smwcPath) - { - try - { + public static WidgetCollectionManifest? ReadManifest(string smwcPath) { + try { using var stream = new FileStream(smwcPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - var entry = archive.Entries.FirstOrDefault(e => + ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(e => string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); if (entry == null) return null; using var reader = new StreamReader(entry.Open()); - using var doc = JsonDocument.Parse(reader.ReadToEnd()); + using JsonDocument doc = JsonDocument.Parse(reader.ReadToEnd()); return Parse(doc.RootElement, smwcPath); } - catch - { + catch { return null; } } - public static InstalledCollection? InstallAll(string smwcPath) - { + public static InstalledCollection? InstallAll(string smwcPath) { if (!File.Exists(smwcPath)) return null; - var manifest = ReadManifest(smwcPath); + WidgetCollectionManifest? manifest = ReadManifest(smwcPath); var installed = new List(); - int failed = 0; + var failed = 0; string scratch = Path.Combine(Path.GetTempPath(), "smwc-" + Guid.NewGuid().ToString("N")); - try - { + try { Directory.CreateDirectory(scratch); using var stream = new FileStream(smwcPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - foreach (var entry in archive.Entries) - { + foreach (ZipArchiveEntry entry in archive.Entries) { if (string.IsNullOrEmpty(entry.Name)) continue; if (!entry.Name.EndsWith(WidgetPackPaths.PackExtension, StringComparison.OrdinalIgnoreCase)) continue; string staged = Path.Combine(scratch, $"{installed.Count + failed}-{entry.Name}"); - try - { - entry.ExtractToFile(staged, overwrite: true); - var pack = WidgetPackInstaller.Install(staged); + try { + entry.ExtractToFile(staged, true); + WidgetPackInstaller.InstalledPack? pack = WidgetPackInstaller.Install(staged); if (pack != null) installed.Add(pack); else failed++; } - catch - { + catch { failed++; } } } - catch - { + catch { return null; } - finally - { - try - { + finally { + try { if (Directory.Exists(scratch)) - Directory.Delete(scratch, recursive: true); + Directory.Delete(scratch, true); + } + catch { + /**/ } - catch { /**/ } } if (installed.Count == 0 && failed == 0) return null; @@ -102,9 +86,8 @@ public record InstalledCollection( return new InstalledCollection(manifest, installed, failed); } - private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) - { - var collection = root.TryGetProperty("collection", out var c) ? c : default; + private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) { + JsonElement collection = root.TryGetProperty("collection", out JsonElement c) ? c : default; string name = Str(collection, "name"); if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smwcPath); @@ -112,8 +95,7 @@ private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) string version = Str(collection, "version"); if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; - return new WidgetCollectionManifest - { + return new WidgetCollectionManifest { FormatVersion = Str(root, "format_version"), AppVersion = Str(root, "app_version"), Name = name, @@ -124,16 +106,16 @@ private static WidgetCollectionManifest Parse(JsonElement root, string smwcPath) }; } - private static string Str(JsonElement el, string name) - => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && - v.ValueKind == JsonValueKind.String + private static string Str(JsonElement el, string name) { + return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out JsonElement v) && + v.ValueKind == JsonValueKind.String ? v.GetString() ?? string.Empty : string.Empty; + } - private static List StrList(JsonElement el, string name) - { + private static List StrList(JsonElement el, string name) { if (el.ValueKind != JsonValueKind.Object || - !el.TryGetProperty(name, out var v) || + !el.TryGetProperty(name, out JsonElement v) || v.ValueKind != JsonValueKind.Array) return new List(); @@ -143,4 +125,9 @@ private static List StrList(JsonElement el, string name) .Where(s => !string.IsNullOrWhiteSpace(s)) .ToList(); } -} + + public record InstalledCollection( + WidgetCollectionManifest? Manifest, + List Packs, + int Failed); +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs b/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs index ef339d20..02d66539 100644 --- a/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs +++ b/SubathonManager.Data/Widgets/WidgetDisplayHelper.cs @@ -3,23 +3,24 @@ namespace SubathonManager.Data.Widgets; -public static class WidgetDisplayHelper -{ - public static WidgetDisplayKind GetDisplayKind(this Widget widget) => widget.Type switch - { - WidgetType.Image => WidgetDisplayKind.Image, - WidgetType.Video => WidgetDisplayKind.Video, - _ => WidgetPackPaths.TryResolve(widget.HtmlPath, - out _, out _, out _, out _) - ? WidgetDisplayKind.Widget - : WidgetDisplayKind.UnpackedWidget - }; +public static class WidgetDisplayHelper { + public static WidgetDisplayKind GetDisplayKind(this Widget widget) { + return widget.Type switch { + WidgetType.Image => WidgetDisplayKind.Image, + WidgetType.Video => WidgetDisplayKind.Video, + _ => WidgetPackPaths.TryResolve(widget.HtmlPath, + out _, out _, out _, out _) + ? WidgetDisplayKind.Widget + : WidgetDisplayKind.UnpackedWidget + }; + } - public static string GetLabel(this WidgetDisplayKind kind) => kind switch - { - WidgetDisplayKind.Image => "Image", - WidgetDisplayKind.Video => "Video", - WidgetDisplayKind.UnpackedWidget => "Unpacked Widget", - _ => "Widget" - }; -} + public static string GetLabel(this WidgetDisplayKind kind) { + return kind switch { + WidgetDisplayKind.Image => "Image", + WidgetDisplayKind.Video => "Video", + WidgetDisplayKind.UnpackedWidget => "Unpacked Widget", + _ => "Widget" + }; + } +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs b/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs index b3247b1a..0ecb2939 100644 --- a/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs +++ b/SubathonManager.Data/Widgets/WidgetPackFileSystem.cs @@ -3,42 +3,38 @@ namespace SubathonManager.Data.Widgets; -public sealed class WidgetPackFileSystem : IWidgetFileSystem -{ +public sealed class WidgetPackFileSystem : IWidgetFileSystem { private readonly DiskWidgetFileSystem _disk = new(); private readonly ConcurrentDictionary _readers = new(StringComparer.OrdinalIgnoreCase); - public bool Exists(string path) - { - var reader = ReaderFor(path, out var entry); + public bool Exists(string path) { + WidgetPackReader? reader = ReaderFor(path, out string entry); return reader?.Contains(entry) ?? _disk.Exists(path); } - public string? ReadAllText(string path) - { - var reader = ReaderFor(path, out var entry); + public string? ReadAllText(string path) { + WidgetPackReader? reader = ReaderFor(path, out string entry); return reader != null ? reader.ReadText(entry) : _disk.ReadAllText(path); } - public byte[]? ReadAllBytes(string path) - { - var reader = ReaderFor(path, out var entry); + public byte[]? ReadAllBytes(string path) { + WidgetPackReader? reader = ReaderFor(path, out string entry); return reader != null ? reader.Read(entry) : _disk.ReadAllBytes(path); } - public bool IsPacked(string path) => ReaderFor(path, out _) != null; + public bool IsPacked(string path) { + return ReaderFor(path, out _) != null; + } - public string? GetRealFilePath(string path) - { - var reader = ReaderFor(path, out var entry); + public string? GetRealFilePath(string path) { + WidgetPackReader? reader = ReaderFor(path, out string entry); return reader != null ? reader.Materialize(entry) : _disk.GetRealFilePath(path); } - public IEnumerable EnumerateFiles(string directory) - { - var reader = ReaderFor(directory, out var prefix, out var packId, - out var version, requireEntry: false); - + public IEnumerable EnumerateFiles(string directory) { + WidgetPackReader? reader = ReaderFor(directory, out string prefix, out string packId, + out string version, false); + if (reader == null) return _disk.EnumerateFiles(directory); string? mountRoot = MountRootFor(Path.Combine(directory, "_")); @@ -51,18 +47,17 @@ public IEnumerable EnumerateFiles(string directory) .ToList(); } - public bool Unpack(string packedPath, string targetDir) - { - var reader = ReaderFor(packedPath, out _, out _, out _, requireEntry: false); + public bool Unpack(string packedPath, string targetDir) { + WidgetPackReader? reader = ReaderFor(packedPath, out _, out _, out _, false); return reader != null && reader.ExtractAll(targetDir); } - private WidgetPackReader? ReaderFor(string path, out string entry) => - ReaderFor(path, out entry, out _, out _); + private WidgetPackReader? ReaderFor(string path, out string entry) { + return ReaderFor(path, out entry, out _, out _); + } private WidgetPackReader? ReaderFor(string path, out string entry, out string packId, out string version, - bool requireEntry = true) - { + bool requireEntry = true) { entry = string.Empty; packId = string.Empty; version = string.Empty; @@ -70,7 +65,7 @@ public bool Unpack(string packedPath, string targetDir) if (string.IsNullOrWhiteSpace(path)) return null; string probe = requireEntry ? path : Path.Combine(path, "_"); - var location = WidgetPackPaths.Resolve(probe); + WidgetPackPaths.PackLocation? location = WidgetPackPaths.Resolve(probe); if (location == null) return null; packId = location.PackIdStr; @@ -83,11 +78,12 @@ public bool Unpack(string packedPath, string targetDir) key => new WidgetPackReader(key, WidgetPackPaths.CacheDirFor(key))); } - private static string? MountRootFor(string path) => WidgetPackPaths.Resolve(path)?.MountRootStr; + private static string? MountRootFor(string path) { + return WidgetPackPaths.Resolve(path)?.MountRootStr; + } - private static string TrimProbe(string resolved) - { + private static string TrimProbe(string resolved) { int slash = resolved.LastIndexOf('/'); return slash < 0 ? string.Empty : resolved[..slash]; } -} +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetPackInstaller.cs b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs index aedf71a2..67f56f90 100644 --- a/SubathonManager.Data/Widgets/WidgetPackInstaller.cs +++ b/SubathonManager.Data/Widgets/WidgetPackInstaller.cs @@ -1,12 +1,13 @@ using System.IO.Compression; using System.Text.Json; using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data.Widgets; -public class WidgetPackManifest -{ +public class WidgetPackManifest { public string FormatVersion { get; init; } = "1"; public string AppVersion { get; init; } = string.Empty; public string PackId { get; init; } = string.Empty; @@ -27,35 +28,29 @@ public class WidgetPackManifest public float ScaleY { get; init; } = 1; } -public static class WidgetPackInstaller -{ +public static class WidgetPackInstaller { public const string ManifestFileName = "widget.json"; - public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string PackFile); - public static WidgetPackManifest? ReadManifest(string smwPath) - { - try - { + public static WidgetPackManifest? ReadManifest(string smwPath) { + try { using var stream = new FileStream(smwPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - var entry = archive.Entries.FirstOrDefault(e => + ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(e => string.Equals(e.FullName.Replace('\\', '/'), ManifestFileName, StringComparison.OrdinalIgnoreCase)); if (entry == null) return null; using var reader = new StreamReader(entry.Open()); - using var doc = JsonDocument.Parse(reader.ReadToEnd()); + using JsonDocument doc = JsonDocument.Parse(reader.ReadToEnd()); return Parse(doc.RootElement, smwPath); } - catch - { + catch { return null; } } - public static InstalledPack? Install(string smwPath) - { - var manifest = ReadManifest(smwPath); + public static InstalledPack? Install(string smwPath) { + WidgetPackManifest? manifest = ReadManifest(smwPath); if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; string packId = manifest.PackId; @@ -64,21 +59,19 @@ public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string string target = WidgetPackPaths.PackFile(packId, version); - try - { + try { Directory.CreateDirectory(Path.GetDirectoryName(target)!); if (!string.Equals(Path.GetFullPath(smwPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase)) - File.Copy(smwPath, target, overwrite: true); + File.Copy(smwPath, target, true); string cacheDir = WidgetPackPaths.CacheDirFor(target); - if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, recursive: true); + if (Directory.Exists(cacheDir)) Directory.Delete(cacheDir, true); WidgetPackMemoryCache.InvalidatePack(target); WidgetPackPaths.InvalidateVersionCache(packId); WidgetPackPaths.InvalidateResolveCache(); } - catch - { + catch { return null; } @@ -86,11 +79,10 @@ public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string return new InstalledPack(manifest, htmlPath, target); } - public static InstalledPack? MountInPlace(string smwPath) - { + public static InstalledPack? MountInPlace(string smwPath) { if (!File.Exists(smwPath)) return null; - var manifest = ReadManifest(smwPath); + WidgetPackManifest? manifest = ReadManifest(smwPath); if (manifest == null || string.IsNullOrWhiteSpace(manifest.Entry)) return null; string full = Path.GetFullPath(smwPath); @@ -103,13 +95,11 @@ public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string return new InstalledPack(manifest, WidgetPackPaths.EntryPathIn(mountRoot, manifest.Entry), full); } - public static string? DropIntoImports(string smwPath) - { - try - { + public static string? DropIntoImports(string smwPath) { + try { if (!File.Exists(smwPath)) return null; - var installed = Install(smwPath); + InstalledPack? installed = Install(smwPath); if (installed != null) return installed.PackFile; Directory.CreateDirectory(WidgetPackPaths.PackedRoot); @@ -117,52 +107,47 @@ public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string if (string.Equals(Path.GetFullPath(smwPath), Path.GetFullPath(target), StringComparison.OrdinalIgnoreCase)) return target; - File.Copy(smwPath, target, overwrite: true); + File.Copy(smwPath, target, true); return target; } - catch - { + catch { return null; } } - public static int SweepCache(IEnumerable liveWidgetPaths) - { + public static int SweepCache(IEnumerable liveWidgetPaths) { string cacheRoot = WidgetPackPaths.CacheRoot; if (!Directory.Exists(cacheRoot)) return 0; var live = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var path in liveWidgetPaths) - { - var location = WidgetPackPaths.Resolve(path); + foreach (string path in liveWidgetPaths) { + WidgetPackPaths.PackLocation? location = WidgetPackPaths.Resolve(path); if (location != null) live.Add(WidgetPackPaths.CacheDirFor(location.PackFileStr)); } - int removed = 0; + var removed = 0; - try - { - foreach (var dir in Directory.EnumerateDirectories(cacheRoot)) - { + try { + foreach (string dir in Directory.EnumerateDirectories(cacheRoot)) { if (live.Contains(dir)) continue; - try - { - Directory.Delete(dir, recursive: true); + try { + Directory.Delete(dir, true); removed++; } - catch { /**/ } + catch { + /**/ + } } } - catch { /**/ } + catch { + /**/ + } return removed; } - public record PackUpdate(string PackFile, string Version, string Entry); - - public static PackUpdate? FindUpdate(string widgetHtmlPath) - { - var location = WidgetPackPaths.Resolve(widgetHtmlPath); + public static PackUpdate? FindUpdate(string widgetHtmlPath) { + WidgetPackPaths.PackLocation? location = WidgetPackPaths.Resolve(widgetHtmlPath); if (location == null) return null; return WidgetPackPaths.IsInPresets(location.PackFileStr) @@ -170,16 +155,14 @@ public record PackUpdate(string PackFile, string Version, string Entry); : FindVersionedFolderUpdate(location); } - private static PackUpdate? FindPresetUpdate(WidgetPackPaths.PackLocation location) - { - var current = WidgetCatalog.EntryForPackFile(location.PackFileStr); + private static PackUpdate? FindPresetUpdate(WidgetPackPaths.PackLocation location) { + WidgetCatalogEntry? current = WidgetCatalog.EntryForPackFile(location.PackFileStr); if (current == null || string.IsNullOrWhiteSpace(current.PackId)) return null; PackUpdate? best = null; string bestVersion = current.Version; - foreach (var candidate in WidgetCatalog.EntriesForPackId(current.PackId)) - { + foreach (WidgetCatalogEntry candidate in WidgetCatalog.EntriesForPackId(current.PackId)) { if (candidate.Source != WidgetCatalogSource.Preset) continue; if (string.Equals(candidate.PackPath, current.PackPath, StringComparison.OrdinalIgnoreCase)) continue; if (WidgetPackPaths.CompareVersions(candidate.Version, bestVersion) <= 0) continue; @@ -191,13 +174,11 @@ public record PackUpdate(string PackFile, string Version, string Entry); return best; } - private static PackUpdate? FindVersionedFolderUpdate(WidgetPackPaths.PackLocation location) - { + private static PackUpdate? FindVersionedFolderUpdate(WidgetPackPaths.PackLocation location) { if (!WidgetPackPaths.IsVersionName(location.VersionStr)) return null; string? best = null; - foreach (var candidate in WidgetPackPaths.VersionsIn(location.PackFolderStr)) - { + foreach (string candidate in WidgetPackPaths.VersionsIn(location.PackFolderStr)) { if (!WidgetPackPaths.IsVersionName(candidate)) continue; if (WidgetPackPaths.CompareVersions(candidate, location.VersionStr) <= 0) continue; if (best == null || WidgetPackPaths.CompareVersions(candidate, best) > 0) best = candidate; @@ -209,11 +190,12 @@ public record PackUpdate(string PackFile, string Version, string Entry); return new PackUpdate(file, best, ReadManifest(file)?.Entry ?? string.Empty); } - public static string? FindNewerVersion(string widgetHtmlPath) => FindUpdate(widgetHtmlPath)?.Version; + public static string? FindNewerVersion(string widgetHtmlPath) { + return FindUpdate(widgetHtmlPath)?.Version; + } - private static WidgetPackManifest Parse(JsonElement root, string smwPath) - { - var widget = root.TryGetProperty("widget", out var w) ? w : default; + private static WidgetPackManifest Parse(JsonElement root, string smwPath) { + JsonElement widget = root.TryGetProperty("widget", out JsonElement w) ? w : default; string name = Str(widget, "name"); if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileNameWithoutExtension(smwPath); @@ -226,13 +208,16 @@ private static WidgetPackManifest Parse(JsonElement root, string smwPath) string version = Str(widget, "widget_version"); if (string.IsNullOrWhiteSpace(version)) version = "1.0.0"; - var size = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("size", - out var s) ? s : default; - var scale = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("scale", - out var c) ? c : default; + JsonElement size = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("size", + out JsonElement s) + ? s + : default; + JsonElement scale = widget.ValueKind == JsonValueKind.Object && widget.TryGetProperty("scale", + out JsonElement c) + ? c + : default; - return new WidgetPackManifest - { + return new WidgetPackManifest { FormatVersion = Str(root, "version"), AppVersion = Str(root, "app_version"), PackId = packId, @@ -251,15 +236,16 @@ private static WidgetPackManifest Parse(JsonElement root, string smwPath) }; } - private static string Str(JsonElement el, string name) - => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + private static string Str(JsonElement el, string name) { + return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, out JsonElement v) && + v.ValueKind == JsonValueKind.String ? v.GetString() ?? string.Empty : string.Empty; + } - private static List StrList(JsonElement el, string name) - { + private static List StrList(JsonElement el, string name) { if (el.ValueKind != JsonValueKind.Object || - !el.TryGetProperty(name, out var v) || + !el.TryGetProperty(name, out JsonElement v) || v.ValueKind != JsonValueKind.Array) return new List(); @@ -270,15 +256,21 @@ private static List StrList(JsonElement el, string name) .ToList(); } - private static int Int(JsonElement el, string name, int fallback) - => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, - out var v) && v.TryGetInt32(out var i) + private static int Int(JsonElement el, string name, int fallback) { + return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, + out JsonElement v) && v.TryGetInt32(out int i) ? i : fallback; + } - private static float Flt(JsonElement el, string name, float fallback) - => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, - out var v) && v.TryGetSingle(out var f) && f > 0 + private static float Flt(JsonElement el, string name, float fallback) { + return el.ValueKind == JsonValueKind.Object && el.TryGetProperty(name, + out JsonElement v) && v.TryGetSingle(out float f) && f > 0 ? f : fallback; -} + } + + public record InstalledPack(WidgetPackManifest Manifest, string HtmlPath, string PackFile); + + public record PackUpdate(string PackFile, string Version, string Entry); +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs b/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs index 052a4d23..2d9bfd62 100644 --- a/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs +++ b/SubathonManager.Data/Widgets/WidgetPackMemoryCache.cs @@ -1,7 +1,6 @@ namespace SubathonManager.Data.Widgets; -internal static class WidgetPackMemoryCache -{ +internal static class WidgetPackMemoryCache { private const long Budget = 32 * 1024 * 1024; public const long MaxEntrySize = 2 * 1024 * 1024; @@ -12,20 +11,21 @@ internal static class WidgetPackMemoryCache private static long _size; - private sealed class CacheItem(string key, byte[] data) - { - public string Key { get; } = key; - public byte[] Data { get; } = data; + internal static long CurrentSize { + get { + lock (CacheLock) { + return _size; + } + } } - public static string MakeKey(string packFile, string entry) => $"{packFile}|{entry}"; + public static string MakeKey(string packFile, string entry) { + return $"{packFile}|{entry}"; + } - public static bool TryGet(string key, out byte[] data) - { - lock (CacheLock) - { - if (!Map.TryGetValue(key, out var node)) - { + public static bool TryGet(string key, out byte[] data) { + lock (CacheLock) { + if (!Map.TryGetValue(key, out LinkedListNode? node)) { data = []; return false; } @@ -37,25 +37,21 @@ public static bool TryGet(string key, out byte[] data) } } - public static void Set(string key, byte[] data) - { + public static void Set(string key, byte[] data) { if (data.LongLength >= MaxEntrySize) return; - lock (CacheLock) - { - if (Map.TryGetValue(key, out var existing)) - { + lock (CacheLock) { + if (Map.TryGetValue(key, out LinkedListNode? existing)) { _size -= existing.Value.Data.LongLength; Order.Remove(existing); Map.Remove(key); } - var node = Order.AddFirst(new CacheItem(key, data)); + LinkedListNode node = Order.AddFirst(new CacheItem(key, data)); Map[key] = node; _size += data.LongLength; - while (_size > Budget && Order.Last is { } tail) - { + while (_size > Budget && Order.Last is { } tail) { Order.RemoveLast(); Map.Remove(tail.Value.Key); _size -= tail.Value.Data.LongLength; @@ -63,19 +59,16 @@ public static void Set(string key, byte[] data) } } - public static void InvalidatePack(string packFile) - { + public static void InvalidatePack(string packFile) { string prefix = packFile + "|"; - lock (CacheLock) - { - var doomed = Map.Keys + lock (CacheLock) { + List doomed = Map.Keys .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .ToList(); - foreach (var key in doomed) - { - if (!Map.TryGetValue(key, out var node)) continue; + foreach (string key in doomed) { + if (!Map.TryGetValue(key, out LinkedListNode? node)) continue; Order.Remove(node); Map.Remove(key); _size -= node.Value.Data.LongLength; @@ -83,18 +76,16 @@ public static void InvalidatePack(string packFile) } } - public static void Clear() - { - lock (CacheLock) - { + public static void Clear() { + lock (CacheLock) { Map.Clear(); Order.Clear(); _size = 0; } } - internal static long CurrentSize - { - get { lock (CacheLock) return _size; } + private sealed class CacheItem(string key, byte[] data) { + public string Key { get; } = key; + public byte[] Data { get; } = data; } -} +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetPackPaths.cs b/SubathonManager.Data/Widgets/WidgetPackPaths.cs index 2f2f39cb..ce83c848 100644 --- a/SubathonManager.Data/Widgets/WidgetPackPaths.cs +++ b/SubathonManager.Data/Widgets/WidgetPackPaths.cs @@ -1,12 +1,22 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.RegularExpressions; using SubathonManager.Core; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Data.Widgets; -public static partial class WidgetPackPaths -{ +public static partial class WidgetPackPaths { public const string PackExtension = ".smw"; + public const string DefaultGroup = "widgets"; + private const int MaxMountDepth = 16; + + private static readonly Lock VersionCacheLock = new(); + private static readonly Dictionary> VersionCache = new(StringComparer.OrdinalIgnoreCase); + + private static readonly Lock ResolveCacheLock = new(); + private static readonly Dictionary ResolveCache = new(StringComparer.OrdinalIgnoreCase); public static string ImportsRoot => Path.GetFullPath(Path.Combine("./imports", "widgets")); public static string PackedRoot => Path.Combine(ImportsRoot, "packed"); @@ -14,65 +24,63 @@ public static partial class WidgetPackPaths public static string CacheRoot => Path.GetFullPath(Path.Combine("./cache", "widgets")); public static string PresetsRoot => Path.GetFullPath("./presets"); - public static string MountRoot(string packId, string version) - => Path.Combine(PackedRoot, packId, version); + public static string MountRoot(string packId, string version) { + return Path.Combine(PackedRoot, packId, version); + } - public static string PackFile(string packId, string version) - => Path.Combine(PackedRoot, packId, version + PackExtension); + public static string PackFile(string packId, string version) { + return Path.Combine(PackedRoot, packId, version + PackExtension); + } - public static string PackFolder(string packId) => Path.Combine(PackedRoot, packId); + public static string PackFolder(string packId) { + return Path.Combine(PackedRoot, packId); + } - private static readonly Lock VersionCacheLock = new(); - private static readonly Dictionary> VersionCache = new(StringComparer.OrdinalIgnoreCase); + public static List InstalledVersions(string packId) { + return VersionsIn(PackFolder(packId)); + } - public static List InstalledVersions(string packId) => VersionsIn(PackFolder(packId)); - public static List VersionsIn(string folder) - { - lock (VersionCacheLock) - { - if (VersionCache.TryGetValue(folder, out var cached)) return cached; + public static List VersionsIn(string folder) { + lock (VersionCacheLock) { + if (VersionCache.TryGetValue(folder, out List? cached)) return cached; var versions = new List(); if (Directory.Exists(folder)) - { - try - { + try { versions = Directory.EnumerateFiles(folder, "*" + PackExtension) .Select(Path.GetFileNameWithoutExtension) .Where(v => !string.IsNullOrWhiteSpace(v)) .Select(v => v!) .ToList(); } - catch { /**/ } - } + catch { + /**/ + } VersionCache[folder] = versions; return versions; } } - public static string DisplayVersion(string? version) - => string.IsNullOrWhiteSpace(version) + public static string DisplayVersion(string? version) { + return string.IsNullOrWhiteSpace(version) ? string.Empty : VersionSeparatorRegex().Replace(version, "."); + } - public static void InvalidateVersionCache(string? packId = null) - { - lock (VersionCacheLock) - { + public static void InvalidateVersionCache(string? packId = null) { + lock (VersionCacheLock) { if (packId == null) VersionCache.Clear(); else VersionCache.Remove(PackFolder(packId)); } } - public static int CompareVersions(string left, string right) - { - var a = VersionSegments(left); - var b = VersionSegments(right); + public static int CompareVersions(string left, string right) { + List a = VersionSegments(left); + List b = VersionSegments(right); - for (int i = 0; i < Math.Max(a.Count, b.Count); i++) - { + for (var i = 0; i < Math.Max(a.Count, b.Count); i++) { long x = i < a.Count ? a[i] : 0; long y = i < b.Count ? b[i] : 0; if (x != y) return x.CompareTo(y); @@ -81,42 +89,43 @@ public static int CompareVersions(string left, string right) return string.Compare(left, right, StringComparison.OrdinalIgnoreCase); } - private static List VersionSegments(string version) - => VersionSegmentRegex().Matches(version ?? string.Empty) - .Select(m => long.TryParse(m.Value, out var n) ? n : 0L) + private static List VersionSegments(string version) { + return VersionSegmentRegex().Matches(version ?? string.Empty) + .Select(m => long.TryParse(m.Value, out long n) ? n : 0L) .ToList(); - public const string DefaultGroup = "widgets"; + } + + public static string NormalizeGroup(string? group) { + return string.IsNullOrWhiteSpace(group) ? DefaultGroup : group.Trim(); + } - public static string NormalizeGroup(string? group) - => string.IsNullOrWhiteSpace(group) ? DefaultGroup : group.Trim(); - - public static bool IsInPresets(string packFileOrFolder) - { - try - { + public static bool IsInPresets(string packFileOrFolder) { + try { return Path.GetFullPath(packFileOrFolder) .StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); } - catch { return false; } + catch { + return false; + } } - public static bool IsVersionName(string? name) - => !string.IsNullOrWhiteSpace(name) && VersionNameRegex().IsMatch(name); + public static bool IsVersionName(string? name) { + return !string.IsNullOrWhiteSpace(name) && VersionNameRegex().IsMatch(name); + } - public static bool IsInGlobalStore(string packFileOrFolder) - { - try - { + public static bool IsInGlobalStore(string packFileOrFolder) { + try { string full = Path.GetFullPath(packFileOrFolder); return full.StartsWith(PackedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) - || full.StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + || full.StartsWith(PresetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + catch { + return false; } - catch { return false; } } - public static string UnpackRootFor(PackLocation location, string author, string group, string name) - { + public static string UnpackRootFor(PackLocation location, string author, string group, string name) { if (IsInGlobalStore(location.PackFolderStr)) return UnpackRoot(author, group, name, location.VersionStr); @@ -124,8 +133,7 @@ public static string UnpackRootFor(PackLocation location, string author, string return Path.Combine(container, "unpacked", location.PackIdStr, location.VersionStr); } - public static string UnpackRoot(string author, string group, string name, string version) - { + public static string UnpackRoot(string author, string group, string name, string version) { string authorFolder = Slug(author); if (string.IsNullOrEmpty(authorFolder)) authorFolder = "unknown"; @@ -141,85 +149,77 @@ public static string UnpackRoot(string author, string group, string name, string return Path.Combine(UnpackedRoot, authorFolder, groupFolder, nameFolder, versionFolder); } - private static string SanitizeSegment(string? value) => SafeFileName.Sanitize(value); - - public static string EntryPath(string packId, string version, string entry) - => Path.Combine(MountRoot(packId, version), entry.Replace('/', Path.DirectorySeparatorChar)); - - public record PackLocation(string PackFileStr, string PackFolderStr, string MountRootStr, string PackIdStr, string VersionStr); + private static string SanitizeSegment(string? value) { + return SafeFileName.Sanitize(value); + } - private static readonly Lock ResolveCacheLock = new(); - private static readonly Dictionary ResolveCache = new(StringComparer.OrdinalIgnoreCase); - private const int MaxMountDepth = 16; + public static string EntryPath(string packId, string version, string entry) { + return Path.Combine(MountRoot(packId, version), entry.Replace('/', Path.DirectorySeparatorChar)); + } - public static PackLocation? Resolve(string widgetPath) - { + public static PackLocation? Resolve(string widgetPath) { if (string.IsNullOrWhiteSpace(widgetPath)) return null; string full; - try { full = Path.GetFullPath(widgetPath); } - catch { return null; } + try { + full = Path.GetFullPath(widgetPath); + } + catch { + return null; + } string? dir = Path.GetDirectoryName(full); if (dir == null) return null; - var found = ResolveDirectory(dir); + PackLocation? found = ResolveDirectory(dir); return found; } - private static PackLocation? ResolveDirectory(string directory) - { - lock (ResolveCacheLock) - { - if (ResolveCache.TryGetValue(directory, out var cached)) return cached; + private static PackLocation? ResolveDirectory(string directory) { + lock (ResolveCacheLock) { + if (ResolveCache.TryGetValue(directory, out PackLocation? cached)) return cached; } PackLocation? result = null; string? current = directory; - for (int depth = 0; depth < MaxMountDepth && current != null; depth++) - { + for (var depth = 0; depth < MaxMountDepth && current != null; depth++) { string candidate = current + PackExtension; - if (File.Exists(candidate)) - { + if (File.Exists(candidate)) { string packFolder = Path.GetDirectoryName(current) ?? current; result = new PackLocation( - PackFileStr: candidate, - PackFolderStr: packFolder, - MountRootStr: current, - PackIdStr: Path.GetFileName(packFolder), - VersionStr: Path.GetFileName(current)); + candidate, + packFolder, + current, + Path.GetFileName(packFolder), + Path.GetFileName(current)); break; } current = Path.GetDirectoryName(current); } - lock (ResolveCacheLock) - { + lock (ResolveCacheLock) { ResolveCache[directory] = result; } return result; } - public static void InvalidateResolveCache() - { - lock (ResolveCacheLock) - { + public static void InvalidateResolveCache() { + lock (ResolveCacheLock) { ResolveCache.Clear(); } } public static bool TryResolve(string widgetPath, out string packFile, out string entry, - out string packId, out string version) - { + out string packId, out string version) { packFile = string.Empty; entry = string.Empty; packId = string.Empty; version = string.Empty; - var found = Resolve(widgetPath); + PackLocation? found = Resolve(widgetPath); if (found == null) return false; packFile = found.PackFileStr; @@ -230,35 +230,38 @@ public static bool TryResolve(string widgetPath, out string packFile, out string return true; } - public static string EntryPathIn(string mountRoot, string entry) - => Path.Combine(mountRoot, entry.Replace('/', Path.DirectorySeparatorChar)); - - public static string CacheDirFor(string packFile) - { + public static string EntryPathIn(string mountRoot, string entry) { + return Path.Combine(mountRoot, entry.Replace('/', Path.DirectorySeparatorChar)); + } + + public static string CacheDirFor(string packFile) { string key; - try { key = Path.GetFullPath(packFile).ToLowerInvariant(); } - catch { key = packFile.ToLowerInvariant(); } + try { + key = Path.GetFullPath(packFile).ToLowerInvariant(); + } + catch { + key = packFile.ToLowerInvariant(); + } - byte[] hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(key)); - string folder = $"{Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..8].ToLowerInvariant()}"; + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + var folder = + $"{Slug(Path.GetFileNameWithoutExtension(packFile))}-{Convert.ToHexString(hash)[..8].ToLowerInvariant()}"; return Path.Combine(CacheRoot, folder); } - public static string MakePackId(string author, string group, string name) - { + public static string MakePackId(string author, string group, string name) { string n = Slug(name); if (string.IsNullOrEmpty(n)) n = "widget"; - var parts = new[] { Slug(author), Slug(NormalizeGroup(group)), n } + IEnumerable parts = new[] { Slug(author), Slug(NormalizeGroup(group)), n } .Where(p => !string.IsNullOrEmpty(p)); return string.Join('.', parts); } - public static string Slug(string? value) - { + public static string Slug(string? value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; string slug = SlugStripRegex().Replace(value.Trim().ToLowerInvariant(), "-"); slug = SlugCollapseRegex().Replace(slug, "-").Trim('-'); @@ -279,4 +282,11 @@ public static string Slug(string? value) [GeneratedRegex(@"(?<=\d)-(?=\d)")] private static partial Regex VersionSeparatorRegex(); -} + + public record PackLocation( + string PackFileStr, + string PackFolderStr, + string MountRootStr, + string PackIdStr, + string VersionStr); +} \ No newline at end of file diff --git a/SubathonManager.Data/Widgets/WidgetPackReader.cs b/SubathonManager.Data/Widgets/WidgetPackReader.cs index 93df97bd..f5eed730 100644 --- a/SubathonManager.Data/Widgets/WidgetPackReader.cs +++ b/SubathonManager.Data/Widgets/WidgetPackReader.cs @@ -3,37 +3,31 @@ namespace SubathonManager.Data.Widgets; -internal sealed class WidgetPackReader(string packFile, string cacheDir) -{ +internal sealed class WidgetPackReader(string packFile, string cacheDir) { private const long MaterializeThreshold = WidgetPackMemoryCache.MaxEntrySize; private readonly Lock _indexLock = new(); private Dictionary? _index; private DateTime _indexedAt; - private readonly record struct IndexedEntry(string FullName, long Length); - - private Dictionary Index - { - get - { - lock (_indexLock) - { - var stamp = File.Exists(packFile) ? File.GetLastWriteTimeUtc(packFile) : DateTime.MinValue; + private Dictionary Index { + get { + lock (_indexLock) { + DateTime stamp = File.Exists(packFile) ? File.GetLastWriteTimeUtc(packFile) : DateTime.MinValue; if (_index != null && stamp == _indexedAt) return _index; var index = new Dictionary(StringComparer.OrdinalIgnoreCase); - try - { + try { using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - foreach (var entry in archive.Entries) - { + foreach (ZipArchiveEntry entry in archive.Entries) { if (string.IsNullOrEmpty(entry.Name)) continue; // dir index[Normalize(entry.FullName)] = new IndexedEntry(entry.FullName, entry.Length); } } - catch { /**/ } + catch { + /**/ + } WidgetPackMemoryCache.InvalidatePack(packFile); _index = index; @@ -43,20 +37,22 @@ private Dictionary Index } } - public bool Contains(string entry) => Index.ContainsKey(Normalize(entry)); - public IEnumerable Entries => Index.Keys; - public long LengthOf(string entry) - => Index.TryGetValue(Normalize(entry), out var found) ? found.Length : -1; + public bool Contains(string entry) { + return Index.ContainsKey(Normalize(entry)); + } + + public long LengthOf(string entry) { + return Index.TryGetValue(Normalize(entry), out IndexedEntry found) ? found.Length : -1; + } - public byte[]? Read(string entry) - { + public byte[]? Read(string entry) { string key = Normalize(entry); string cacheKey = WidgetPackMemoryCache.MakeKey(packFile, key); - if (WidgetPackMemoryCache.TryGet(cacheKey, out var cached)) return cached; - if (!Index.TryGetValue(key, out var indexed)) return null; + if (WidgetPackMemoryCache.TryGet(cacheKey, out byte[] cached)) return cached; + if (!Index.TryGetValue(key, out IndexedEntry indexed)) return null; byte[]? bytes = ReadRaw(indexed.FullName); if (bytes == null) return null; @@ -67,57 +63,49 @@ public long LengthOf(string entry) return bytes; } - public string? ReadText(string entry) - { - var bytes = Read(entry); + public string? ReadText(string entry) { + byte[]? bytes = Read(entry); if (bytes == null) return null; return bytes is [0xEF, 0xBB, 0xBF, ..] ? Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3) : Encoding.UTF8.GetString(bytes); } - public string? Materialize(string entry) - { + public string? Materialize(string entry) { string key = Normalize(entry); - if (!Index.TryGetValue(key, out var indexed)) return null; + if (!Index.TryGetValue(key, out IndexedEntry indexed)) return null; if (indexed.Length < MaterializeThreshold) return null; string target = Path.Combine(cacheDir, key.Replace('/', Path.DirectorySeparatorChar)); - try - { + try { var info = new FileInfo(target); if (info.Exists && info.Length == indexed.Length) return target; Directory.CreateDirectory(Path.GetDirectoryName(target)!); string temp = target + ".partial"; using (var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) - { - var zipEntry = archive.GetEntry(indexed.FullName); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) { + ZipArchiveEntry? zipEntry = archive.GetEntry(indexed.FullName); if (zipEntry == null) return null; - zipEntry.ExtractToFile(temp, overwrite: true); + zipEntry.ExtractToFile(temp, true); } - File.Move(temp, target, overwrite: true); + File.Move(temp, target, true); return target; } - catch - { + catch { return null; } } - public bool ExtractAll(string targetDir) - { - try - { + public bool ExtractAll(string targetDir) { + try { Directory.CreateDirectory(targetDir); using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - foreach (var entry in archive.Entries) - { + foreach (ZipArchiveEntry entry in archive.Entries) { if (string.IsNullOrEmpty(entry.Name)) continue; string destination = Path.GetFullPath( @@ -128,36 +116,36 @@ public bool ExtractAll(string targetDir) continue; Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - entry.ExtractToFile(destination, overwrite: true); + entry.ExtractToFile(destination, true); } return true; } - catch - { + catch { return false; } } - private byte[]? ReadRaw(string fullName) - { - try - { + private byte[]? ReadRaw(string fullName) { + try { using var stream = new FileStream(packFile, FileMode.Open, FileAccess.Read, FileShare.Read); using var archive = new ZipArchive(stream, ZipArchiveMode.Read); - var entry = archive.GetEntry(fullName); + ZipArchiveEntry? entry = archive.GetEntry(fullName); if (entry == null) return null; - using var entryStream = entry.Open(); + using Stream entryStream = entry.Open(); using var buffer = new MemoryStream(entry.Length > 0 ? (int)Math.Min(entry.Length, int.MaxValue) : 0); entryStream.CopyTo(buffer); return buffer.ToArray(); } - catch - { + catch { return null; } } - private static string Normalize(string entry) => entry.Replace('\\', '/').TrimStart('/'); + private static string Normalize(string entry) { + return entry.Replace('\\', '/').TrimStart('/'); + } + + private readonly record struct IndexedEntry(string FullName, long Length); } \ No newline at end of file From ebe11f60af1154e39e76713de0891beb126a86ce Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:09:16 -0300 Subject: [PATCH 08/39] integration formatting changes --- .../BlerpChatService.cs | 39 +- .../DevTunnelsService.cs | 244 ++++----- .../ExternalEventService.cs | 189 +++---- .../FourthWallService.cs | 514 +++++++++--------- .../GoAffProService.cs | 235 ++++---- SubathonManager.Integration/JuniperService.cs | 161 +++--- SubathonManager.Integration/KoFiService.cs | 238 ++++---- .../MakeShipService.cs | 304 +++++------ SubathonManager.Integration/OBSService.cs | 481 +++++++--------- SubathonManager.Integration/PallyService.cs | 196 +++---- SubathonManager.Integration/PicartoService.cs | 242 ++++----- .../StreamElementsService.cs | 164 +++--- .../StreamLabsService.cs | 110 ++-- SubathonManager.Integration/TangiaService.cs | 199 +++---- SubathonManager.Integration/ThroneService.cs | 197 ++++--- .../TipeeeStreamService.cs | 489 +++++++---------- .../TreatStreamService.cs | 351 ++++++------ SubathonManager.Integration/YouTubeService.cs | 318 +++++------ 18 files changed, 2027 insertions(+), 2644 deletions(-) diff --git a/SubathonManager.Integration/BlerpChatService.cs b/SubathonManager.Integration/BlerpChatService.cs index 009bcda9..729fda91 100644 --- a/SubathonManager.Integration/BlerpChatService.cs +++ b/SubathonManager.Integration/BlerpChatService.cs @@ -1,44 +1,43 @@ -using SubathonManager.Core.Models; +using System.Text.RegularExpressions; using SubathonManager.Core.Enums; -using System.Text.RegularExpressions; using SubathonManager.Core.Events; +using SubathonManager.Core.Models; + namespace SubathonManager.Integration; -public static class BlerpChatService -{ - public static bool ParseMessage(string message, SubathonEventSource source) - { +public static class BlerpChatService { + public static bool ParseMessage(string message, SubathonEventSource source) { // user Blerp is verified prior to here var regex = new Regex( @"^([^\s]+)\s+\w+\s+(\d+)\s+(bits|beets)\b", RegexOptions.IgnoreCase ); - var match = regex.Match(message); + Match match = regex.Match(message); if (!match.Success) return false; - - var user = match.Groups[1].Value; - var amount = match.Groups[2].Value; - var currency = match.Groups[3].Value; - - SubathonEvent subathonEvent = new SubathonEvent() - { + + string user = match.Groups[1].Value; + string amount = match.Groups[2].Value; + string currency = match.Groups[3].Value; + + var subathonEvent = new SubathonEvent { User = user, Currency = currency.ToLower(), Value = $"{amount}", - Source = source == SubathonEventSource.Simulated ? SubathonEventSource.Simulated : SubathonEventSource.Blerp, + Source = + source == SubathonEventSource.Simulated ? SubathonEventSource.Simulated : SubathonEventSource.Blerp, EventType = currency.Equals("bits", StringComparison.OrdinalIgnoreCase) - ? SubathonEventType.BlerpBits : SubathonEventType.BlerpBeets + ? SubathonEventType.BlerpBits + : SubathonEventType.BlerpBeets }; - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return true; } - public static void SimulateBlerpMessage(long amount, string bitsBeets) - { - if (!bitsBeets.Equals("bits", StringComparison.CurrentCultureIgnoreCase) && + public static void SimulateBlerpMessage(long amount, string bitsBeets) { + if (!bitsBeets.Equals("bits", StringComparison.CurrentCultureIgnoreCase) && !bitsBeets.Equals("beets", StringComparison.CurrentCultureIgnoreCase)) return; var message = $"SYSTEM used {amount} {bitsBeets} to play a thing"; diff --git a/SubathonManager.Integration/DevTunnelsService.cs b/SubathonManager.Integration/DevTunnelsService.cs index b769af85..98377a57 100644 --- a/SubathonManager.Integration/DevTunnelsService.cs +++ b/SubathonManager.Integration/DevTunnelsService.cs @@ -2,9 +2,9 @@ using DevTunnels.Client; using DevTunnels.Client.Authentication; using DevTunnels.Client.Hosting; +using DevTunnels.Client.Installer; using DevTunnels.Client.Ports; using DevTunnels.Client.Tunnels; -using DevTunnels.Client.Installer; using Microsoft.Extensions.Logging; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -16,19 +16,18 @@ namespace SubathonManager.Integration; public class DevTunnelsService( ILogger? logger, IConfig config, - IDevTunnelsClient client) : IDisposable, IAppService -{ + IDevTunnelsClient client) : IDisposable, IAppService { private readonly string _configSection = "DevTunnels"; - private bool _disposed = false; - - private IDevTunnelHostSession? _session; - private CancellationTokenSource? _cts; - private DateTime? _lastTokenErrorAt; // Serialises concurrent StartTunnelAsync calls so at most one session-start attempt // runs at a time. Without this, two webhook integrations firing StartTunnelAsync // concurrently could both pass the _session != null guard and launch two CLI processes. private readonly SemaphoreSlim _startLock = new(1, 1); + private CancellationTokenSource? _cts; + private bool _disposed; + private DateTime? _lastTokenErrorAt; + + private IDevTunnelHostSession? _session; // Publicly readable so the UI can show the live URL without subscribing to events. public string? PublicBaseUrl { get; private set; } @@ -36,16 +35,14 @@ public class DevTunnelsService( public bool IsLoggedIn { get; private set; } public bool IsTunnelRunning { get; private set; } - public async Task StartAsync(CancellationToken ct = default) - { + public async Task StartAsync(CancellationToken ct = default) { // 1. Probe CLI - var probe = await client.ProbeCliAsync(ct); + DevTunnelCliProbeResult probe = await client.ProbeCliAsync(ct); IsCliInstalled = probe.IsInstalled; BroadcastCliStatus(probe); - if (!probe.IsInstalled) - { + if (!probe.IsInstalled) { logger?.LogInformation("[DevTunnels] CLI not installed, skipping auto-start"); BroadcastLoginStatus(null); BroadcastTunnelStatus(false, null); @@ -53,14 +50,14 @@ public async Task StartAsync(CancellationToken ct = default) } // 2. Check login - var login = await client.GetLoginStatusAsync(ct); + DevTunnelLoginStatus login = await client.GetLoginStatusAsync(ct); IsLoggedIn = login.IsLoggedIn; BroadcastLoginStatus(login); - if (!login.IsLoggedIn) - { - logger?.LogInformation("[DevTunnels] Not logged in; tunnel will start on demand when a webhook integration is enabled"); + if (!login.IsLoggedIn) { + logger?.LogInformation( + "[DevTunnels] Not logged in; tunnel will start on demand when a webhook integration is enabled"); BroadcastTunnelStatus(false, null); return; } @@ -72,65 +69,63 @@ public async Task StartAsync(CancellationToken ct = default) BroadcastTunnelStatus(false, null); } + public async Task StopAsync(CancellationToken ct = default) { + await StopTunnelAsync(); + + IsCliInstalled = false; + IsLoggedIn = false; + + BroadcastCliStatus(null); + BroadcastLoginStatus(null); + BroadcastTunnelStatus(false, null); + } + [ExcludeFromCodeCoverage] - public async Task TryInstallAsync(CancellationToken ct = default) - { - var probe = await client.ProbeCliAsync(ct); - if (probe.IsInstalled) - { + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + [ExcludeFromCodeCoverage] + public async Task TryInstallAsync(CancellationToken ct = default) { + DevTunnelCliProbeResult probe = await client.ProbeCliAsync(ct); + if (probe.IsInstalled) { logger?.LogInformation("[DevTunnels] Install requested but already installed..."); return true; } + var installer = new DevTunnelCliInstaller(); logger?.LogInformation("[DevTunnels] Installing detected installer..."); string? detected = await installer.DetectInstallerAsync(ct); logger?.LogInformation("[DevTunnels] Installer: {Detected}", detected ?? "none"); - - var result = await installer.InstallAsync(ct); + + DevTunnelCliInstallResult result = await installer.InstallAsync(ct); if (result.Success) - { - logger?.LogInformation("[DevTunnels] Installed successfully via {Method}. Path: {Path}", result.InstallerUsed, result.InstalledPath); - } + logger?.LogInformation("[DevTunnels] Installed successfully via {Method}. Path: {Path}", + result.InstallerUsed, result.InstalledPath); else - { logger?.LogError("[DevTunnels] Install failed: {Reason}", result.FailureReason); - } return result.Success; } - public async Task StopAsync(CancellationToken ct = default) - { - await StopTunnelAsync(); - - IsCliInstalled = false; - IsLoggedIn = false; - - BroadcastCliStatus(null); - BroadcastLoginStatus(null); - BroadcastTunnelStatus(false, null); - } - // Interactive actions called from the UI - public async Task RefreshCliStatusAsync(CancellationToken ct = default) - { - var probe = await client.ProbeCliAsync(ct); + public async Task RefreshCliStatusAsync(CancellationToken ct = default) { + DevTunnelCliProbeResult probe = await client.ProbeCliAsync(ct); IsCliInstalled = probe.IsInstalled; BroadcastCliStatus(probe); return probe; } - public async Task LoginAsync(LoginProvider provider, CancellationToken ct = default) - { - var status = await client.LoginAsync(provider, ct); + public async Task LoginAsync(LoginProvider provider, CancellationToken ct = default) { + DevTunnelLoginStatus status = await client.LoginAsync(provider, ct); IsLoggedIn = status.IsLoggedIn; BroadcastLoginStatus(status); return status; } - public async Task LogoutAsync(CancellationToken ct = default) - { + public async Task LogoutAsync(CancellationToken ct = default) { await StopTunnelAsync(); await client.LogoutAsync(ct); IsLoggedIn = false; @@ -139,36 +134,32 @@ public async Task LogoutAsync(CancellationToken ct = defau return new DevTunnelLoginStatus { Status = "Logged out" }; } - public async Task StartTunnelAsync(CancellationToken ct = default) - { + public async Task StartTunnelAsync(CancellationToken ct = default) { // Fast path: no lock needed if a session is already running. if (_session != null) return; await _startLock.WaitAsync(ct); - try - { + try { // Re-check under the lock in case another caller just finished starting. if (_session != null) return; - BroadcastTunnelStatus(false, null, starting: true); + BroadcastTunnelStatus(false, null, true); - var serverPort = int.TryParse(config.Get("Server", "Port", "14040"), out var p) ? p : 14040; - var tunnelId = config.Get(_configSection, "TunnelId", string.Empty); + int serverPort = int.TryParse(config.Get("Server", "Port", "14040"), out int p) ? p : 14040; + string? tunnelId = config.Get(_configSection, "TunnelId", string.Empty); // The Azure DevTunnels CLI internally assigns a random tunnel ID that may not match // the short-name format required by StartHostSessionAsync validation. We always use // the label (name) we chose as the persistent ID; the CLI accepts it as a host arg. // Also clear any cluster-qualified IDs persisted by older code (e.g. "name.abc.usw2"). - if (!string.IsNullOrWhiteSpace(tunnelId) && !DevTunnelValidation.IsValidTunnelId(tunnelId)) - { + if (!string.IsNullOrWhiteSpace(tunnelId) && !DevTunnelValidation.IsValidTunnelId(tunnelId)) { logger?.LogInformation("[DevTunnels] Stored tunnel ID is not in short-name format; resetting"); tunnelId = string.Empty; if (config.Set(_configSection, "TunnelId", string.Empty)) config.Save(); } - - if (!string.IsNullOrWhiteSpace(tunnelId) && tunnelId.StartsWith("subathon-")) - { + + if (!string.IsNullOrWhiteSpace(tunnelId) && tunnelId.StartsWith("subathon-")) { logger?.LogWarning("[DevTunnels] Legacy tunnel ID found. Resetting"); tunnelId = string.Empty; if (config.Set(_configSection, "TunnelId", string.Empty)) @@ -176,28 +167,25 @@ public async Task StartTunnelAsync(CancellationToken ct = default) IntegrationEvents.RaiseDevTunnelLegacyNotification(); } - if (string.IsNullOrWhiteSpace(tunnelId)) - { - try - { + if (string.IsNullOrWhiteSpace(tunnelId)) { + try { var prefix = $"sm{serverPort}-"; - var list = await client.ListTunnelsAsync(ct); - var found = list.Tunnels.FirstOrDefault(t => t.TunnelId.StartsWith(prefix, StringComparison.Ordinal)); - if (found != null) - { + DevTunnelList list = await client.ListTunnelsAsync(ct); + DevTunnelStatus? found = + list.Tunnels.FirstOrDefault(t => t.TunnelId.StartsWith(prefix, StringComparison.Ordinal)); + if (found != null) { tunnelId = found.TunnelId.Split('.')[0]; - logger?.LogInformation("[DevTunnels] Recovered existing tunnel '{Id}' from account list", tunnelId); + logger?.LogInformation("[DevTunnels] Recovered existing tunnel '{Id}' from account list", + tunnelId); if (config.Set(_configSection, "TunnelId", tunnelId)) config.Save(); } } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[DevTunnels] Failed to list tunnels; will generate a new ID"); } - if (string.IsNullOrWhiteSpace(tunnelId)) - { + if (string.IsNullOrWhiteSpace(tunnelId)) { tunnelId = $"sm{serverPort}-{Guid.NewGuid():N}"; if (config.Set(_configSection, "TunnelId", tunnelId)) config.Save(); @@ -208,14 +196,13 @@ public async Task StartTunnelAsync(CancellationToken ct = default) // The port must be pre-registered on the tunnel; passing PortNumber to StartHostSessionAsync // is for ephemeral tunnels only and causes a service-side error on named persistent tunnels. logger?.LogInformation("[DevTunnels] Ensuring tunnel '{Id}' and port {Port} exist", tunnelId, serverPort); - - try - { + + try { await client.GetTunnelAsync(tunnelId, ct); } - catch (Exception ex) - { - logger?.LogWarning(ex, "[DevTunnels] Failed to find a pre-existing tunnel. Trying to create or update."); + catch (Exception ex) { + logger?.LogWarning(ex, + "[DevTunnels] Failed to find a pre-existing tunnel. Trying to create or update."); await client.CreateOrUpdateTunnelAsync( tunnelId, new DevTunnelOptions { AllowAnonymous = true, Description = "SubathonManager webhook tunnel" }, @@ -234,18 +221,16 @@ await client.CreateOrReplacePortAsync( new DevTunnelHostStartOptions { TunnelId = tunnelId, ReadyTimeout = TimeSpan.FromSeconds(30) }, _cts.Token); - try - { + try { await _session.WaitForReadyAsync(_cts.Token); } - catch (InvalidOperationException ex) when (ex.Message.Contains("access token") || (ex.Message.StartsWith("Request ID:"))) - { + catch (InvalidOperationException ex) when (ex.Message.Contains("access token") || + ex.Message.StartsWith("Request ID:")) { logger?.LogWarning(ex, "[DevTunnels] Host token missing for tunnel '{Id}'; re-login to the devtunnel CLI may be required", tunnelId); - if (_lastTokenErrorAt == null || DateTime.Now - _lastTokenErrorAt > TimeSpan.FromMinutes(10)) - { + if (_lastTokenErrorAt == null || DateTime.Now - _lastTokenErrorAt > TimeSpan.FromMinutes(10)) { _lastTokenErrorAt = DateTime.Now; ErrorMessageEvents.RaiseErrorEvent( "WARN", @@ -253,11 +238,12 @@ await client.CreateOrReplacePortAsync( "DevTunnel failed to start: missing host access token. Please re-login to the devtunnel.", DateTime.Now.ToLocalTime()); } + // // await LogoutAsync(ct); return; } - + PublicBaseUrl = _session.PublicUrl?.ToString()?.TrimEnd('/'); IsTunnelRunning = true; @@ -267,49 +253,40 @@ await client.CreateOrReplacePortAsync( // Monitor for unexpected exit in the background _ = MonitorSessionAsync(_cts.Token); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[DevTunnels] Failed to start tunnel"); await StopTunnelAsync(); BroadcastTunnelStatus(false, null); } - finally - { + finally { _startLock.Release(); } } - public async Task DeleteOldTunnelsAsync(CancellationToken ct = default) - { - var serverPort = int.TryParse(config.Get("Server", "Port", "14040"), out var p) ? p : 14040; + public async Task DeleteOldTunnelsAsync(CancellationToken ct = default) { + int serverPort = int.TryParse(config.Get("Server", "Port", "14040"), out int p) ? p : 14040; var prefix = $"sm{serverPort}-"; - var list = await client.ListTunnelsAsync(ct); - foreach (var tunnel in list.Tunnels) - { - var id = tunnel.TunnelId.Split('.')[0]; + DevTunnelList list = await client.ListTunnelsAsync(ct); + foreach (DevTunnelStatus tunnel in list.Tunnels) { + string id = tunnel.TunnelId.Split('.')[0]; if (!id.StartsWith(prefix, StringComparison.Ordinal)) continue; - try - { + try { await client.DeleteTunnelAsync(id, ct); logger?.LogInformation("[DevTunnels] Deleted old tunnel '{Id}'", id); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[DevTunnels] Failed to delete tunnel '{Id}'", id); } } - var storedId = config.Get(_configSection, "TunnelId", string.Empty); + string? storedId = config.Get(_configSection, "TunnelId", string.Empty); if (!string.IsNullOrWhiteSpace(storedId) && storedId.StartsWith(prefix, StringComparison.Ordinal)) - { if (config.Set(_configSection, "TunnelId", string.Empty)) config.Save(); - } } - public async Task StopTunnelAsync() - { + public async Task StopTunnelAsync() { if (_session == null) return; BroadcastTunnelStatus(false, null, stopping: true); @@ -317,11 +294,13 @@ public async Task StopTunnelAsync() PublicBaseUrl = null; IsTunnelRunning = false; - try { await _session.StopAsync(); } - catch (Exception ex) - { + try { + await _session.StopAsync(); + } + catch (Exception ex) { logger?.LogWarning(ex, "[DevTunnels] Failed to stop tunnel"); } + await _session.DisposeAsync(); _session = null; @@ -334,27 +313,24 @@ public async Task StopTunnelAsync() // Helpers - private async Task MonitorSessionAsync(CancellationToken ct) - { - try - { + private async Task MonitorSessionAsync(CancellationToken ct) { + try { if (_session == null) return; await _session.WaitForExitAsync(ct); - if (!ct.IsCancellationRequested && IsTunnelRunning) - { + if (!ct.IsCancellationRequested && IsTunnelRunning) { logger?.LogWarning("[DevTunnels] Host session exited unexpectedly: {Reason}", _session.FailureReason); await StopTunnelAsync(); BroadcastTunnelStatus(false, null); } } - catch (OperationCanceledException) { /**/ } + catch (OperationCanceledException) { + /**/ + } } - private void BroadcastCliStatus(DevTunnelCliProbeResult? probe) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastCliStatus(DevTunnelCliProbeResult? probe) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.DevTunnels, Service = "Cli", Name = probe?.IsInstalled == true ? probe.Version?.ToString() ?? "" : "", @@ -363,10 +339,8 @@ private void BroadcastCliStatus(DevTunnelCliProbeResult? probe) }); } - private void BroadcastLoginStatus(DevTunnelLoginStatus? login) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastLoginStatus(DevTunnelLoginStatus? login) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.DevTunnels, Service = "Login", Name = login?.Username ?? "", @@ -376,29 +350,19 @@ private void BroadcastLoginStatus(DevTunnelLoginStatus? login) }); } - private void BroadcastTunnelStatus(bool running, string? url, bool starting = false, bool stopping = false) - { + private void BroadcastTunnelStatus(bool running, string? url, bool starting = false, bool stopping = false) { IsTunnelRunning = running; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.DevTunnels, Service = "Tunnel", - Name = starting ? "(starting...)" : stopping ? "(stopping...)" : (url ?? ""), + Name = starting ? "(starting...)" : stopping ? "(stopping...)" : url ?? "", Status = running, Configured = running }); } [ExcludeFromCodeCoverage] - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - [ExcludeFromCodeCoverage] - protected virtual void Dispose(bool disposing) - { + protected virtual void Dispose(bool disposing) { if (_disposed) return; _cts?.Cancel(); _cts?.Dispose(); @@ -406,4 +370,4 @@ protected virtual void Dispose(bool disposing) _startLock.Dispose(); _disposed = true; } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/ExternalEventService.cs b/SubathonManager.Integration/ExternalEventService.cs index 9b8efb11..4c0c03ee 100644 --- a/SubathonManager.Integration/ExternalEventService.cs +++ b/SubathonManager.Integration/ExternalEventService.cs @@ -2,33 +2,29 @@ using Microsoft.Extensions.DependencyInjection; using SubathonManager.Core; using SubathonManager.Core.Enums; -using SubathonManager.Core.Models; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; using SubathonManager.Services; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; -public static class ExternalEventService -{ - - public static bool ProcessExternalCommand(Dictionary data) - { +public static class ExternalEventService { + public static bool ProcessExternalCommand(Dictionary data) { data.TryGetValue("command", out JsonElement elemCmd); - if (elemCmd.ValueKind == JsonValueKind.String && Enum.TryParse - (elemCmd.GetString(), ignoreCase: true, out SubathonCommandType cmd) ) - { + if (elemCmd.ValueKind == JsonValueKind.String && Enum.TryParse + (elemCmd.GetString(), true, out SubathonCommandType cmd)) { var source = SubathonEventSource.External; if (data.TryGetValue("source", out JsonElement elemSrc) && elemSrc.ValueKind == JsonValueKind.String - && Enum.TryParse(elemSrc.GetString(), ignoreCase: true, out SubathonEventSource parsedSrc) - && parsedSrc.IsExternalSource()) - { + && Enum.TryParse(elemSrc.GetString(), true, + out SubathonEventSource parsedSrc) + && parsedSrc.IsExternalSource()) source = parsedSrc; - } data.TryGetValue("user", out JsonElement elemUser); - string fallbackUser = "EXTERNAL"; + var fallbackUser = "EXTERNAL"; string user = string.IsNullOrWhiteSpace(elemUser.GetString()) ? fallbackUser : elemUser.GetString()!; data.TryGetValue("message", out JsonElement elemMsg); @@ -40,93 +36,79 @@ public static bool ProcessExternalCommand(Dictionary data) return false; } - - public static bool ProcessExternalSub(Dictionary data) - { + + public static bool ProcessExternalSub(Dictionary data) { data.TryGetValue("type", out JsonElement elemType); string typeStr = elemType.GetString()!; - Enum.TryParse(typeStr, ignoreCase: true, out var type); + Enum.TryParse(typeStr, true, out SubathonEventType type); if (!((SubathonEventType?)type).IsSubscription()) return false; data.TryGetValue("user", out JsonElement elemUser); string user = string.IsNullOrWhiteSpace(elemUser.GetString()) ? "EXTERNAL" : elemUser.GetString()!; - + if (type.GetSource() == SubathonEventSource.KoFi && !string.Equals(user, "SYSTEM")) - { if (Utils.GetConnection(SubathonEventSource.KoFiTunnel, nameof(SubathonEventSource.KoFiTunnel)).Status) - { return false; - } - } - + data.TryGetValue("value", out JsonElement elemValue); - - string value = elemValue.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(elemValue.GetString()) - ? elemValue.GetString()! : "DEFAULT"; + + string value = elemValue.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(elemValue.GetString()) + ? elemValue.GetString()! + : "DEFAULT"; if (string.IsNullOrWhiteSpace(value)) value = "DEFAULT"; - + data.TryGetValue("amount", out JsonElement elemAmount); - - SubathonEvent subathonEvent = new SubathonEvent - { + + var subathonEvent = new SubathonEvent { Currency = type == SubathonEventType.KoFiSub ? "member" : "sub", User = user, Value = $"{value}", EventTypeMeta = $"{value}" }; - if (type != SubathonEventType.KoFiSub) - { + if (type != SubathonEventType.KoFiSub) { data.TryGetValue("seconds", out JsonElement elemSeconds); data.TryGetValue("points", out JsonElement elemPoints); - if (elemSeconds.ValueKind == JsonValueKind.Number) - { - subathonEvent.SecondsValue = elemSeconds.GetDouble(); - } - if (elemPoints.ValueKind == JsonValueKind.Number) { - subathonEvent.PointsValue = elemPoints.GetInt16(); - } + if (elemSeconds.ValueKind == JsonValueKind.Number) subathonEvent.SecondsValue = elemSeconds.GetDouble(); + if (elemPoints.ValueKind == JsonValueKind.Number) subathonEvent.PointsValue = elemPoints.GetInt16(); } - if (elemAmount.ValueKind == JsonValueKind.String) - { + if (elemAmount.ValueKind == JsonValueKind.String) { subathonEvent.Amount = 1; - if (int.TryParse(elemAmount.GetString(), out var amtInt) && amtInt > 0) + if (int.TryParse(elemAmount.GetString(), out int amtInt) && amtInt > 0) subathonEvent.Amount = amtInt; } - else - { + else { subathonEvent.Amount = elemAmount.ValueKind == JsonValueKind.Number ? elemAmount.GetInt16() : 1; } - subathonEvent.Source = user == "SYSTEM" ? SubathonEventSource.Simulated : ((SubathonEventType?)type).GetSource(); + subathonEvent.Source = + user == "SYSTEM" ? SubathonEventSource.Simulated : ((SubathonEventType?)type).GetSource(); subathonEvent.EventType = type; - + data.TryGetValue("id", out JsonElement elemId); - if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out var id)) + if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out Guid id)) subathonEvent.Id = id; - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return true; } - - - public static bool ProcessExternalOrder(Dictionary data) - { + + + public static bool ProcessExternalOrder(Dictionary data) { data.TryGetValue("type", out JsonElement elemType); if (elemType.ValueKind != JsonValueKind.String) return false; string typeStr = elemType.GetString()!; - Enum.TryParse(typeStr, ignoreCase: true, out var type); + Enum.TryParse(typeStr, true, out SubathonEventType type); string? goAffProMeta = null; - if (type != SubathonEventType.GoAffProOrder && GoAffProOrderHelper.TryGetStoreByOrderKey(typeStr, out var keyStore)) - { + if (type != SubathonEventType.GoAffProOrder && + GoAffProOrderHelper.TryGetStoreByOrderKey(typeStr, out GoAffProStore? keyStore)) { type = SubathonEventType.GoAffProOrder; goAffProMeta = keyStore.SiteId.ToString(); } - else if (type.GetLegacyGoAffProSiteId() > 0) - { + else if (type.GetLegacyGoAffProSiteId() > 0) { goAffProMeta = type.GetLegacyGoAffProSiteId().ToString(); type = SubathonEventType.GoAffProOrder; } @@ -136,14 +118,10 @@ public static bool ProcessExternalOrder(Dictionary data) if (!((SubathonEventType?)type).IsOrder()) return false; if (type.GetSource() == SubathonEventSource.KoFi && !string.Equals(user, "SYSTEM")) - { if (Utils.GetConnection(SubathonEventSource.KoFiTunnel, nameof(SubathonEventSource.KoFiTunnel)).Status) - { return false; - } - } - + data.TryGetValue("currency", out JsonElement elemCurrency); if (elemCurrency.ValueKind != JsonValueKind.String) return false; @@ -151,49 +129,43 @@ public static bool ProcessExternalOrder(Dictionary data) data.TryGetValue("amount", out JsonElement elemValue); if (elemValue.ValueKind != JsonValueKind.String) return false; - - int amt = 1; - if(data.TryGetValue("quantity", out JsonElement elemQuant)) - { - if (elemQuant.ValueKind == JsonValueKind.String) - { + + var amt = 1; + if (data.TryGetValue("quantity", out JsonElement elemQuant)) { + if (elemQuant.ValueKind == JsonValueKind.String) { if (!int.TryParse(elemQuant.GetString()!, out amt)) return false; } - else if (elemQuant.ValueKind == JsonValueKind.Number) - { - amt = elemQuant.GetInt32(); + else if (elemQuant.ValueKind == JsonValueKind.Number) { + amt = elemQuant.GetInt32(); } - else - { + else { return false; } } - if (!double.TryParse(elemValue.GetString()!, out var value)) return false; - - string orderVal = $"{value}"; - if (type != SubathonEventType.KoFiCommissionOrder) - { - string section = $"{type.GetSource()}"; - string modeKey = $"{type}"; - if (type == SubathonEventType.GoAffProOrder) - { + + if (!double.TryParse(elemValue.GetString()!, out double value)) return false; + + var orderVal = $"{value}"; + if (type != SubathonEventType.KoFiCommissionOrder) { + var section = $"{type.GetSource()}"; + var modeKey = $"{type}"; + if (type == SubathonEventType.GoAffProOrder) { section = nameof(SubathonEventSource.GoAffPro); - modeKey = GoAffProOrderHelper.TryGetStore(goAffProMeta, out var store) + modeKey = GoAffProOrderHelper.TryGetStore(goAffProMeta, out GoAffProStore? store) ? store.InternalName : $"{type}"; } + var config = AppServices.Provider.GetRequiredService(); - var mode = config.GetOrderTypeMode(section, modeKey, OrderTypeModes.Dollar); - - currency = mode switch - { + OrderTypeModes mode = config.GetOrderTypeMode(section, modeKey, OrderTypeModes.Dollar); + + currency = mode switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", _ => currency }; - - switch (mode) - { + + switch (mode) { case OrderTypeModes.Item: orderVal = amt.ToString(); break; @@ -202,9 +174,8 @@ public static bool ProcessExternalOrder(Dictionary data) break; } } - - SubathonEvent subathonEvent = new SubathonEvent - { + + var subathonEvent = new SubathonEvent { Currency = currency, User = user, Value = orderVal, @@ -216,35 +187,30 @@ public static bool ProcessExternalOrder(Dictionary data) }; data.TryGetValue("id", out JsonElement elemId); - if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out var id)) + if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out Guid id)) subathonEvent.Id = id; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); - + return true; } - - public static bool ProcessExternalDonation(Dictionary data) - { + + public static bool ProcessExternalDonation(Dictionary data) { data.TryGetValue("type", out JsonElement elemType); if (elemType.ValueKind != JsonValueKind.String) return false; string typeStr = elemType.GetString()!; - Enum.TryParse(typeStr, ignoreCase: true, out var type); + Enum.TryParse(typeStr, true, out SubathonEventType type); if (!((SubathonEventType?)type).IsCurrencyDonation()) return false; data.TryGetValue("user", out JsonElement elemUser); string user = string.IsNullOrWhiteSpace(elemUser.GetString()) ? "EXTERNAL" : elemUser.GetString()!; - + if (type.GetSource() == SubathonEventSource.KoFi && !string.Equals(user, "SYSTEM")) - { if (Utils.GetConnection(SubathonEventSource.KoFiTunnel, nameof(SubathonEventSource.KoFiTunnel)).Status) - { return false; - } - } - + data.TryGetValue("currency", out JsonElement elemCurrency); if (elemCurrency.ValueKind != JsonValueKind.String) return false; @@ -253,10 +219,9 @@ public static bool ProcessExternalDonation(Dictionary data) data.TryGetValue("amount", out JsonElement elemValue); if (elemValue.ValueKind != JsonValueKind.String) return false; - if (!double.TryParse(elemValue.GetString()!, out var value)) return false; - - SubathonEvent subathonEvent = new SubathonEvent - { + if (!double.TryParse(elemValue.GetString()!, out double value)) return false; + + var subathonEvent = new SubathonEvent { Currency = currency, User = user, Value = $"{value}", @@ -265,11 +230,11 @@ public static bool ProcessExternalDonation(Dictionary data) }; data.TryGetValue("id", out JsonElement elemId); - if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out var id)) + if (elemId.ValueKind == JsonValueKind.String && Guid.TryParse(elemId.GetString()!, out Guid id)) subathonEvent.Id = id; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); - + return true; } } \ No newline at end of file diff --git a/SubathonManager.Integration/FourthWallService.cs b/SubathonManager.Integration/FourthWallService.cs index 6124edd4..4527b63f 100644 --- a/SubathonManager.Integration/FourthWallService.cs +++ b/SubathonManager.Integration/FourthWallService.cs @@ -1,12 +1,18 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.Json; using Agash.Webhook.Abstractions; -using Fourthwall.Client.Events; using Fourthwall.Client.Authentication; +using Fourthwall.Client.Events; using Fourthwall.Client.Generated; using Fourthwall.Client.Generated.Models.App.Openapi.Endpoint.OpenApiWebhookConfigurationEndpoint; using Fourthwall.Client.Generated.Models.Openapi.Model; +using Fourthwall.Client.Generated.Models.Openapi.Model.DonationV1; +using Fourthwall.Client.Generated.Models.Openapi.Model.GiftPurchaseV1; +using Fourthwall.Client.Generated.Models.Openapi.Model.MembershipSupporterV1; +using Fourthwall.Client.Generated.Models.Openapi.Model.MembershipSupporterV1.Subscription; +using Fourthwall.Client.Generated.Models.Openapi.Model.OrderV1; using Fourthwall.Client.Options; using Fourthwall.Client.Webhooks; using Microsoft.Extensions.Logging; @@ -19,143 +25,181 @@ using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; +using WebhookConfigurationV1 = + Fourthwall.Client.Generated.Models.Openapi.Model.OpenApiPageResponseCom.Fourthwall.Openapi.Model. + WebhookConfigurationV1; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; -public class FourthWallService(ILogger? logger, IConfig config, IHttpClientFactory httpClientFactory, DevTunnelsService devTunnels, ISecureStorage secureStorage) - : IWebhookIntegration -{ +public class FourthWallService( + ILogger? logger, + IConfig config, + IHttpClientFactory httpClientFactory, + DevTunnelsService devTunnels, + ISecureStorage secureStorage) + : IWebhookIntegration { private readonly string _configSection = "FourthWall"; - public string WebhookPath => "/api/webhooks/fourthwall"; + + private readonly FourthwallWebhookHandler _handler = new(new FourthwallWebhookSignatureVerifier()); internal readonly string _oAuthURl = "https://oauth.subathonmanager.app/auth/fourthwall/login"; internal readonly string _refreshURl = "https://oauth.subathonmanager.app/auth/fourthwall/refresh"; - - internal Action OpenBrowser = url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + + public readonly Dictionary MembershipNames = new(); + + internal Action OpenBrowser = + url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); private string? AccessToken => secureStorage.GetOrDefault(StorageKeys.FourthWallAccessToken, string.Empty); private string? RefreshToken => secureStorage.GetOrDefault(StorageKeys.FourthWallRefreshToken, string.Empty); private string? ShopName { get; set; } - - public readonly Dictionary MembershipNames = new(); + public string WebhookPath => "/api/webhooks/fourthwall"; + + public async Task StartAsync(CancellationToken ct = default) { + IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; + + Utils.PendingOAuthCallback = null; + bool enabled = HasTokenFile(); + + if (enabled) { + logger?.LogInformation("[FourthWall] Webhook listener ready at {Path}", WebhookPath); + _ = devTunnels.StartTunnelAsync(ct); + } + else { + logger?.LogInformation("[FourthWall] Has not been setup before. Integration is disabled"); + BroadcastStatus(false, null); + } + + await Task.CompletedTask; + } + + + public Task StopAsync(CancellationToken ct = default) { + IntegrationEvents.ConnectionUpdated -= OnTunnelUpdated; + BroadcastStatus(false, null); + + return Task.CompletedTask; + } + + public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct = default) { + string contentType = headers.TryGetValue("Content-Type", out string? ct2) + ? ct2 + : "application/x-www-form-urlencoded"; + + Dictionary newHeaders = headers.ToDictionary( + kvp => kvp.Key, + kvp => new[] { kvp.Value } + ); + IReadOnlyDictionary readOnlyHeaders = newHeaders; + + var request = new WebhookRequest { + Method = "POST", + Path = WebhookPath, + Body = rawBody, + ContentType = contentType, + Headers = readOnlyHeaders + }; + + WebhookHandleResult result = await _handler.HandleAsync(request, + new FourthwallWebhookOptions { + SigningSecret = "", + SignatureMode = FourthwallWebhookSignatureMode.PlatformAppWebhook + }, ct); + + if (!result.IsKnownEvent || result.Event is null) { + logger?.LogDebug("[FourthWall] Received unknown/unsupported FourthWall event type"); + return; + } + + SubathonEvent? ev = MapToSubathonEvent(result.Event); + if (ev != null) { + SubathonEvents.RaiseSubathonEventCreated(ev); + logger?.LogDebug("[FourthWall] Raised {EventType} from {User}", ev.EventType, ev.User); + } + } - private readonly FourthwallWebhookHandler _handler = new(signatureVerifier: new FourthwallWebhookSignatureVerifier()); - [ExcludeFromCodeCoverage] - private bool CheckExpiry() - { + private bool CheckExpiry() { if (string.IsNullOrWhiteSpace(AccessToken)) return false; - + DateTime? expires = Utils.GetAccessTokenExpiry(AccessToken); if (expires == null) return false; - bool isExpired = DateTime.UtcNow >= expires.Value.AddSeconds(-60); + bool isExpired = DateTime.UtcNow >= expires.Value.AddSeconds(-60); return isExpired; } [ExcludeFromCodeCoverage] - private async Task CheckForTokenAsync(CancellationToken ct = default) - { - if (!HasTokenFile()) - { + private async Task CheckForTokenAsync(CancellationToken ct = default) { + if (!HasTokenFile()) { await StartOAuthFlowAsync(); - - if (string.IsNullOrWhiteSpace(AccessToken) || string.IsNullOrWhiteSpace(RefreshToken)) - { - return false; - } + + if (string.IsNullOrWhiteSpace(AccessToken) || string.IsNullOrWhiteSpace(RefreshToken)) return false; } - else if (CheckExpiry()) - { - var success = await StartOAuthRefreshAsync(ct); - if (!success) - { + else if (CheckExpiry()) { + bool success = await StartOAuthRefreshAsync(ct); + if (!success) { RevokeTokenFile(); await StartOAuthFlowAsync(); } + if (string.IsNullOrWhiteSpace(AccessToken)) return false; } return !string.IsNullOrWhiteSpace(AccessToken) && !CheckExpiry(); } - public async Task StartAsync(CancellationToken ct = default) - { - IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; - - Utils.PendingOAuthCallback = null; - bool enabled = HasTokenFile(); - - if (enabled) - { - logger?.LogInformation("[FourthWall] Webhook listener ready at {Path}", WebhookPath); - _ = devTunnels.StartTunnelAsync(ct); - } - else - { - logger?.LogInformation("[FourthWall] Has not been setup before. Integration is disabled"); - BroadcastStatus(false, null); - } - await Task.CompletedTask; - } - - [ExcludeFromCodeCoverage] - public async Task Initialize(CancellationToken ct = default) - { - var tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); - if (!tunnelConn.Status) { - return; - } - - var canConnect = await CheckForTokenAsync(ct); - if (!canConnect || string.IsNullOrWhiteSpace(AccessToken)) - { + public async Task Initialize(CancellationToken ct = default) { + IntegrationConnection tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); + if (!tunnelConn.Status) return; + + bool canConnect = await CheckForTokenAsync(ct); + if (!canConnect || string.IsNullOrWhiteSpace(AccessToken)) { RevokeTokenFile(); BroadcastStatus(false, null); return; } - - var enabled = HasTokenFile(); - var client = new FourthwallApiClient(new HttpClientRequestAdapter(new FourthwallBearerAuthenticationProvider(accessToken: AccessToken))); - - var shop = await client.OpenApi.V10.Shops.Current.GetAsync(cancellationToken: ct); + + bool enabled = HasTokenFile(); + var client = + new FourthwallApiClient( + new HttpClientRequestAdapter(new FourthwallBearerAuthenticationProvider(AccessToken))); + + ShopV1? shop = await client.OpenApi.V10.Shops.Current.GetAsync(cancellationToken: ct); logger?.LogInformation("[FourthWall] connected to {shopName}", shop?.Name ?? "none"); - if (shop == null) - { + if (shop == null) { BroadcastStatus(false, null); return; - }; - + } + + ; + ShopName = shop.Name; - var webhooks = await client.OpenApi.V10.Webhooks.GetAsync(cancellationToken: ct); + WebhookConfigurationV1? webhooks = await client.OpenApi.V10.Webhooks.GetAsync(cancellationToken: ct); - bool hasWh = false; + var hasWh = false; if (webhooks is { Results: not null }) - { - foreach (var webhookConfigurationV1 in webhooks.Results) - { + foreach (Fourthwall.Client.Generated.Models.Openapi.Model.WebhookConfigurationV1 webhookConfigurationV1 in + webhooks.Results) if (!string.IsNullOrWhiteSpace(webhookConfigurationV1.Url) && - webhookConfigurationV1.Url.Contains(tunnelConn.Name.Replace("https://", ""), StringComparison.CurrentCultureIgnoreCase)) - { + webhookConfigurationV1.Url.Contains(tunnelConn.Name.Replace("https://", ""), + StringComparison.CurrentCultureIgnoreCase)) { // TODO what if we add more future scopes, we'd need to compare allowed_types. hasWh = true; logger?.LogDebug("Webhook found, no need to make a new one for fourthwall"); break; } - } - } - if (!hasWh) - { + if (!hasWh) { string? fullUrl = !string.IsNullOrWhiteSpace(tunnelConn.Name) && tunnelConn.Name != "(starting...)" ? tunnelConn.Name.TrimEnd('/') + WebhookPath : null; - WebhookConfigurationCreateRequest req = new WebhookConfigurationCreateRequest - { + var req = new WebhookConfigurationCreateRequest { Url = fullUrl, - AllowedTypes = - [ + AllowedTypes = [ WebhookConfigurationCreateRequest_allowedTypes.ORDER_PLACED, WebhookConfigurationCreateRequest_allowedTypes.DONATION, WebhookConfigurationCreateRequest_allowedTypes.SUBSCRIPTION_PURCHASED, @@ -163,76 +207,62 @@ public async Task Initialize(CancellationToken ct = default) WebhookConfigurationCreateRequest_allowedTypes.GIFT_PURCHASE ] }; - var resp = await client.OpenApi.V10.Webhooks.PostAsync(req, cancellationToken: ct); - if (resp == null || string.IsNullOrWhiteSpace(resp.Url)) - { + Fourthwall.Client.Generated.Models.Openapi.Model.WebhookConfigurationV1? resp = + await client.OpenApi.V10.Webhooks.PostAsync(req, cancellationToken: ct); + if (resp == null || string.IsNullOrWhiteSpace(resp.Url)) { logger?.LogWarning("[FourthWall] Unable to create Webhook configuration"); BroadcastStatus(false, null); return; } + hasWh = true; - logger?.LogInformation("[FourthWall] Webhook successfully made: {WHId}", resp.Id ); + logger?.LogInformation("[FourthWall] Webhook successfully made: {WHId}", resp.Id); } - var memberships = await client.OpenApi.V10.Memberships.Tiers.GetAsync(cancellationToken: ct); + List? memberships = + await client.OpenApi.V10.Memberships.Tiers.GetAsync(cancellationToken: ct); if (memberships is { Count: > 0 }) - { - memberships.ForEach(x => - { + memberships.ForEach(x => { if (string.IsNullOrWhiteSpace(x.Id) || string.IsNullOrWhiteSpace(x.Name)) return; - + MembershipNames[x.Id] = x.Name; }); - } IntegrationEvents.RaiseFourthWallMembershipsSynced(MembershipNames); - + // Seed status; include public URL if the tunnel is already running tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); BroadcastStatus(enabled, tunnelConn.Status ? tunnelConn.Name : null); await Task.CompletedTask; } - - public Task StopAsync(CancellationToken ct = default) - { - IntegrationEvents.ConnectionUpdated -= OnTunnelUpdated; - BroadcastStatus(false, null); - - return Task.CompletedTask; - } - [ExcludeFromCodeCoverage] - private async Task StartOAuthRefreshAsync(CancellationToken ct = default) - { + private async Task StartOAuthRefreshAsync(CancellationToken ct = default) { logger?.LogDebug("Refreshing FourthWall tokens..."); if (!HasTokenFile()) return false; if (string.IsNullOrWhiteSpace(RefreshToken)) return false; - using var client = httpClientFactory.CreateClient(nameof(FourthWallService)); - using var body = new FormUrlEncodedContent(new[] - { - new KeyValuePair("refresh_token", RefreshToken), + using HttpClient client = httpClientFactory.CreateClient(nameof(FourthWallService)); + using var body = new FormUrlEncodedContent(new[] { + new KeyValuePair("refresh_token", RefreshToken) }); - var response = await client.PostAsync(_refreshURl, body, ct); + HttpResponseMessage response = await client.PostAsync(_refreshURl, body, ct); - if (!response.IsSuccessStatusCode) - { - var error = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) { + string error = await response.Content.ReadAsStringAsync(ct); logger?.LogWarning("[FourthWall] Token refresh failed ({Status}): {Error}", response.StatusCode, error); return false; } - var responseJson = await response.Content.ReadAsStringAsync(ct); + string responseJson = await response.Content.ReadAsStringAsync(ct); var tokens = JsonSerializer.Deserialize>(responseJson); - var newAccess = tokens?.GetValueOrDefault("access_token").GetString(); - var newRefresh = tokens?.GetValueOrDefault("refresh_token").GetString() ?? RefreshToken; + string? newAccess = tokens?.GetValueOrDefault("access_token").GetString(); + string? newRefresh = tokens?.GetValueOrDefault("refresh_token").GetString() ?? RefreshToken; - if (string.IsNullOrWhiteSpace(newAccess)) - { + if (string.IsNullOrWhiteSpace(newAccess)) { logger?.LogWarning("[FourthWall] Token refresh returned no access_token"); return false; } @@ -243,72 +273,66 @@ private async Task StartOAuthRefreshAsync(CancellationToken ct = default) logger?.LogDebug("[FourthWall] Tokens refreshed successfully"); return true; } - - private async Task StartOAuthFlowAsync() - { + + private async Task StartOAuthFlowAsync() { RevokeTokenFile(); Utils.PendingOAuthCallback = null; logger?.LogDebug("Opening FourthWall OAuth..."); OpenBrowser(_oAuthURl); - var (newAccess, newRefresh) = await WaitForProtocolCallbackAsync(); - if (!string.IsNullOrEmpty(AccessToken) || string.IsNullOrEmpty(RefreshToken)) - { + (string? newAccess, string? newRefresh) = await WaitForProtocolCallbackAsync(); + if (!string.IsNullOrEmpty(AccessToken) || string.IsNullOrEmpty(RefreshToken)) { secureStorage.Set(StorageKeys.FourthWallAccessToken, newAccess!); secureStorage.Set(StorageKeys.FourthWallRefreshToken, newRefresh!); } } - private async Task<(string?, string?)> WaitForProtocolCallbackAsync(CancellationToken ct = default) - { - var timeout = DateTime.Now.AddMinutes(15); - while (DateTime.Now < timeout && !ct.IsCancellationRequested) - { - var cb = Utils.PendingOAuthCallback; - if (cb?.Provider == "fourthwall" && (!string.IsNullOrEmpty(cb.AccessToken) || !string.IsNullOrEmpty(cb.RefreshToken))) - { + + private async Task<(string?, string?)> WaitForProtocolCallbackAsync(CancellationToken ct = default) { + DateTime timeout = DateTime.Now.AddMinutes(15); + while (DateTime.Now < timeout && !ct.IsCancellationRequested) { + OAuthCallback? cb = Utils.PendingOAuthCallback; + if (cb?.Provider == "fourthwall" && + (!string.IsNullOrEmpty(cb.AccessToken) || !string.IsNullOrEmpty(cb.RefreshToken))) { Utils.PendingOAuthCallback = null; return (cb.AccessToken, cb.RefreshToken); } + await Task.Delay(250, ct); } + return (null, null); } - private bool HasTokenFile() - { + private bool HasTokenFile() { return secureStorage.Exists(StorageKeys.FourthWallAccessToken) && secureStorage.Exists(StorageKeys.FourthWallRefreshToken) && !string.IsNullOrWhiteSpace(AccessToken) && !string.IsNullOrWhiteSpace(RefreshToken); } [ExcludeFromCodeCoverage] - public void RevokeTokenFile() - { + public void RevokeTokenFile() { secureStorage.Delete(StorageKeys.FourthWallAccessToken); secureStorage.Delete(StorageKeys.FourthWallRefreshToken); } [ExcludeFromCodeCoverage] - private void OnTunnelUpdated(IntegrationConnection connection) - { + private void OnTunnelUpdated(IntegrationConnection connection) { if (connection is not { Source: SubathonEventSource.DevTunnels, Service: "Tunnel" }) return; bool enabled = HasTokenFile(); - if (enabled && connection.Status) - { + if (enabled && connection.Status) { Task.Run(() => Initialize()); return; } + BroadcastStatus(enabled, connection.Status ? connection.Name : null); } - private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) - { + private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" ? tunnelBaseUrl.TrimEnd('/') + WebhookPath : null; - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Name = fullUrl ?? "", Status = enabled && fullUrl != null, Source = SubathonEventSource.FourthWall, @@ -320,242 +344,194 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) logger?.LogInformation("[FourthWall] Public webhook URL: {Url}", fullUrl); } - public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, CancellationToken ct = default) - { - var contentType = headers.TryGetValue("Content-Type", out var ct2) ? ct2 - : "application/x-www-form-urlencoded"; - - var newHeaders = headers.ToDictionary( - kvp => kvp.Key, - kvp => new[] { kvp.Value } - ); - IReadOnlyDictionary readOnlyHeaders = newHeaders; - - var request = new WebhookRequest - { - Method = "POST", - Path = WebhookPath, - Body = rawBody, - ContentType = contentType, - Headers = readOnlyHeaders - }; - - var result = await _handler.HandleAsync(request, new FourthwallWebhookOptions - { - SigningSecret = "", - SignatureMode = FourthwallWebhookSignatureMode.PlatformAppWebhook - }, ct); - - if (!result.IsKnownEvent || result.Event is null) - { - logger?.LogDebug("[FourthWall] Received unknown/unsupported FourthWall event type"); - return; - } - var ev = MapToSubathonEvent(result.Event); - if (ev != null) - { - SubathonEvents.RaiseSubathonEventCreated(ev); - logger?.LogDebug("[FourthWall] Raised {EventType} from {User}", ev.EventType, ev.User); - } - } - - public SubathonEvent? MapToSubathonEvent(FourthwallWebhookEvent fwEvent) - { - try - { - var sourceMode = config.GetOrderTypeMode(_configSection, + public SubathonEvent? MapToSubathonEvent(FourthwallWebhookEvent fwEvent) { + try { + OrderTypeModes sourceMode = config.GetOrderTypeMode(_configSection, $"{SubathonEventType.FourthWallOrder}", OrderTypeModes.Dollar); - var sourceMode2 = config.GetOrderTypeMode(_configSection, + OrderTypeModes sourceMode2 = config.GetOrderTypeMode(_configSection, $"{SubathonEventType.FourthWallGiftOrder}", OrderTypeModes.Dollar); var defaultCurrency = "USD"; - string username = "FourthWall Customer"; - if (fwEvent.TestMode) username = "FourthWall Test";//"SYSTEM"; + var username = "FourthWall Customer"; + if (fwEvent.TestMode) username = "FourthWall Test"; //"SYSTEM"; SubathonEvent? ev = null; - if (fwEvent is FourthwallDonationWebhookEvent donationEvent) - { - var d = donationEvent.Data; + if (fwEvent is FourthwallDonationWebhookEvent donationEvent) { + DonationV1 d = donationEvent.Data; if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(d.Username)) username = d.Username.Split(' ').First(); - ev = new SubathonEvent - { + ev = new SubathonEvent { Id = Utils.TryParseGuid(d.Id), - Source = string.Equals(username,"SYSTEM") ? SubathonEventSource.Simulated : SubathonEventSource.FourthWall, + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, EventType = SubathonEventType.FourthWallDonation, User = username, Value = - d.Amounts?.Total?.Value?.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) ?? + d.Amounts?.Total?.Value?.ToString("F2", CultureInfo.InvariantCulture) ?? "0.00", Currency = !string.IsNullOrWhiteSpace(d.Amounts?.Total?.Currency) ? d.Amounts?.Total?.Currency : defaultCurrency, - EventTimestamp = d.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime(), + EventTimestamp = d.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime() }; } - else if (fwEvent is FourthwallOrderPlacedWebhookEvent orderPlacedEvent) - { - var order = orderPlacedEvent.Data; + else if (fwEvent is FourthwallOrderPlacedWebhookEvent orderPlacedEvent) { + OrderV1 order = orderPlacedEvent.Data; if (!string.Equals("ORDER", order.Source?.Order?.Type ?? "", StringComparison.CurrentCultureIgnoreCase) - && !string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", StringComparison.CurrentCultureIgnoreCase)) - { + && !string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", + StringComparison.CurrentCultureIgnoreCase)) return null; - } - + //if (order.Status != OrderV1_status.CONFIRMED) return null; if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(order.Username)) username = order.Username.Split(' ').First(); if (string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", StringComparison.CurrentCultureIgnoreCase)) - { username = "Internal Samples"; - } var itemCount = 0; double totalValue = 0; double totalDirect = 0; string currency = order.Amounts?.Subtotal?.Currency ?? defaultCurrency; - + double costs = 0; double prices = 0; - order.Offers?.ForEach(x => - { - itemCount += (x.Variant?.Quantity ?? 1); - costs += (x.Variant?.Cost?.Value ?? 0); - prices += (x.Variant?.Price?.Value ?? 0); + order.Offers?.ForEach(x => { + itemCount += x.Variant?.Quantity ?? 1; + costs += x.Variant?.Cost?.Value ?? 0; + prices += x.Variant?.Price?.Value ?? 0; }); - - totalValue += (order.Amounts?.Subtotal?.Value ?? 0); - totalDirect += (order.Amounts?.Donation?.Value ?? 0); - var profit = Math.Max(prices - costs, 0); + totalValue += order.Amounts?.Subtotal?.Value ?? 0; + totalDirect += order.Amounts?.Donation?.Value ?? 0; + + double profit = Math.Max(prices - costs, 0); if (string.Equals("SAMPLES_ORDER", order.Source?.Order?.Type ?? "", - StringComparison.CurrentCultureIgnoreCase)) - { - logger?.LogInformation("[FourthWall] Samples order placed. Setting profit from {Profit} to 0.", profit); + StringComparison.CurrentCultureIgnoreCase)) { + logger?.LogInformation("[FourthWall] Samples order placed. Setting profit from {Profit} to 0.", + profit); profit = 0; } + totalDirect += profit; - ev = new SubathonEvent - { + ev = new SubathonEvent { Id = Utils.TryParseGuid(order.Id), - Source = string.Equals(username,"SYSTEM") ? SubathonEventSource.Simulated : SubathonEventSource.FourthWall, + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, EventType = SubathonEventType.FourthWallOrder, User = username, - Value = sourceMode switch - { + Value = sourceMode switch { OrderTypeModes.Item => $"{itemCount}", OrderTypeModes.Order => "New", - _ => totalValue.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) + _ => totalValue.ToString("F2", CultureInfo.InvariantCulture) }, - Currency = sourceMode switch - { + Currency = sourceMode switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", _ => string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency }, Amount = Math.Max(itemCount, 1), - SecondaryValue = $"{totalDirect.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}|{ + SecondaryValue = $"{totalDirect.ToString("F2", CultureInfo.InvariantCulture)}|{ (!string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency)}", EventTimestamp = order.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime() }; - } - else if (fwEvent is FourthwallGiftPurchaseWebhookEvent giftPurchaseWebhookEvent) - { - var order = giftPurchaseWebhookEvent.Data; + else if (fwEvent is FourthwallGiftPurchaseWebhookEvent giftPurchaseWebhookEvent) { + GiftPurchaseV1 order = giftPurchaseWebhookEvent.Data; if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(order.Username)) username = order.Username.Split(' ').First(); var itemCount = 1; if (order.Quantity != null) itemCount = order.Quantity.Value; - + double totalValue = 0; double totalDirect = 0; string currency = order.Amounts?.Subtotal?.Currency ?? defaultCurrency; - + totalValue += order.Amounts?.Subtotal?.Value ?? 0; totalDirect += order.Amounts?.Profit?.Value ?? 0; - ev = new SubathonEvent - { + ev = new SubathonEvent { Id = Utils.TryParseGuid(order.Id), - Source = string.Equals(username,"SYSTEM") ? SubathonEventSource.Simulated : SubathonEventSource.FourthWall, + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, EventType = SubathonEventType.FourthWallGiftOrder, User = username, - Value = sourceMode2 switch - { + Value = sourceMode2 switch { OrderTypeModes.Item => $"{itemCount}", OrderTypeModes.Order => "New Gift", - _ => totalValue.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) + _ => totalValue.ToString("F2", CultureInfo.InvariantCulture) }, - Currency = sourceMode2 switch - { + Currency = sourceMode2 switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", _ => string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency }, Amount = Math.Max(itemCount, 1), - SecondaryValue = $"{totalDirect.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}|{ + SecondaryValue = $"{totalDirect.ToString("F2", CultureInfo.InvariantCulture)}|{ (!string.IsNullOrWhiteSpace(currency) ? currency : defaultCurrency)}", EventTimestamp = order.CreatedAt?.LocalDateTime ?? DateTime.Now.ToLocalTime() }; - } - else if (fwEvent is FourthwallSubscriptionPurchasedWebhookEvent subscriptionPurchasedWebhookEvent) - { - var data = subscriptionPurchasedWebhookEvent.Data; + else if (fwEvent is FourthwallSubscriptionPurchasedWebhookEvent subscriptionPurchasedWebhookEvent) { + MembershipSupporterV1 data = subscriptionPurchasedWebhookEvent.Data; if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(data.Nickname)) username = data.Nickname.Split(' ').First(); - var subscription = data.Subscription?.Active; + Active? subscription = data.Subscription?.Active; if (subscription == null) return null; var tierName = "DEFAULT"; MembershipNames.TryGetValue(subscription.Variant?.TierId ?? "DEFAULT", out tierName); if (string.IsNullOrWhiteSpace(tierName)) tierName = "DEFAULT"; - ev = new SubathonEvent - { + ev = new SubathonEvent { Id = fwEvent.TestMode ? Guid.NewGuid() : Utils.TryParseGuid(data.Id), - Source = string.Equals(username,"SYSTEM") ? SubathonEventSource.Simulated : SubathonEventSource.FourthWall, + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, EventType = SubathonEventType.FourthWallMembership, User = username, Value = tierName, EventTypeMeta = tierName, Currency = "member", Amount = subscription.Variant?.Interval == MembershipTierVariantV1_interval.ANNUAL ? 12 : 1, - SecondaryValue = $"{(subscription.Variant?.Amount?.Value ?? 0).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}|{ - (!string.IsNullOrWhiteSpace(subscription.Variant?.Amount?.Currency) ? subscription.Variant?.Amount?.Currency : defaultCurrency)}", + SecondaryValue = + $"{(subscription.Variant?.Amount?.Value ?? 0).ToString("F2", CultureInfo.InvariantCulture)}|{ + (!string.IsNullOrWhiteSpace(subscription.Variant?.Amount?.Currency) ? subscription.Variant?.Amount?.Currency : defaultCurrency)}", EventTimestamp = subscriptionPurchasedWebhookEvent.CreatedAt.LocalDateTime }; } - else if (fwEvent is FourthwallSubscriptionChangedWebhookEvent subscriptionChangedWebhookEvent) - { - var data = subscriptionChangedWebhookEvent.Data; + else if (fwEvent is FourthwallSubscriptionChangedWebhookEvent subscriptionChangedWebhookEvent) { + MembershipSupporterV1 data = subscriptionChangedWebhookEvent.Data; if (!fwEvent.TestMode && !string.IsNullOrWhiteSpace(data.Nickname)) username = data.Nickname.Split(' ').First(); - var subscription = data.Subscription?.Active; + Active? subscription = data.Subscription?.Active; if (subscription == null) return null; var tierName = "DEFAULT"; MembershipNames.TryGetValue(subscription.Variant?.TierId ?? "DEFAULT", out tierName); if (string.IsNullOrWhiteSpace(tierName)) tierName = "DEFAULT"; - ev = new SubathonEvent - { + ev = new SubathonEvent { Id = fwEvent.TestMode ? Guid.NewGuid() : Utils.TryParseGuid(data.Id), - Source = string.Equals(username,"SYSTEM") ? SubathonEventSource.Simulated : SubathonEventSource.FourthWall, + Source = string.Equals(username, "SYSTEM") + ? SubathonEventSource.Simulated + : SubathonEventSource.FourthWall, EventType = SubathonEventType.FourthWallMembership, User = username, Value = tierName, EventTypeMeta = tierName, Currency = "member", Amount = subscription.Variant?.Interval == MembershipTierVariantV1_interval.ANNUAL ? 12 : 1, - SecondaryValue = $"{(subscription.Variant?.Amount?.Value ?? 0).ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}|{ - (!string.IsNullOrWhiteSpace(subscription.Variant?.Amount?.Currency) ? subscription.Variant?.Amount?.Currency : defaultCurrency)}", + SecondaryValue = + $"{(subscription.Variant?.Amount?.Value ?? 0).ToString("F2", CultureInfo.InvariantCulture)}|{ + (!string.IsNullOrWhiteSpace(subscription.Variant?.Amount?.Currency) ? subscription.Variant?.Amount?.Currency : defaultCurrency)}", EventTimestamp = subscriptionChangedWebhookEvent.CreatedAt.LocalDateTime }; } + return ev; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[FourthWall] Failed to map FourthWall event to SubathonEvent"); return null; } } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/GoAffProService.cs b/SubathonManager.Integration/GoAffProService.cs index 68c83994..046e9278 100644 --- a/SubathonManager.Integration/GoAffProService.cs +++ b/SubathonManager.Integration/GoAffProService.cs @@ -1,10 +1,10 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text.Json; -using Microsoft.Extensions.Logging; using GoAffPro.Client; using GoAffPro.Client.Generated.Models; using GoAffPro.Client.Generated.User.Sites; +using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -13,51 +13,49 @@ using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; -using SubathonManager.Services; // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; -public class GoAffProService(ILogger? logger, IConfig config, ISecureStorage secureStorage, ITimerService? timerService = null) : IDisposable, IAppService -{ - private bool _disposed = false; +public class GoAffProService( + ILogger? logger, + IConfig config, + ISecureStorage secureStorage, + ITimerService? timerService = null) : IDisposable, IAppService { + private readonly string _configSection = "GoAffPro"; private GoAffProClient? _client; private CancellationTokenSource? _detectorCts; - private readonly string _configSection = "GoAffPro"; - - internal Uri Endpoint = new Uri("https://api.goaffpro.com/v1/", UriKind.Absolute); + private bool _disposed; + + + private readonly HashSet _siteIds = new(); + + internal Uri Endpoint = new("https://api.goaffpro.com/v1/", UriKind.Absolute); internal int MaxRetries = 20; private string? Email => secureStorage.GetOrDefault(StorageKeys.GoAffProEmail, string.Empty); private string? Password => secureStorage.GetOrDefault(StorageKeys.GoAffProPassword, string.Empty); - - private HashSet _siteIds = new(); - - public async Task StartAsync(CancellationToken ct = default) - { + public async Task StartAsync(CancellationToken ct = default) { await StopAsync(ct); timerService?.Register("goaffpro-auth-check", TimeSpan.FromHours(48), ReconnectCheck); - + if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Password)) return; _siteIds.Clear(); - try - { + try { _client = await GoAffProClient.CreateLoggedInAsync( - options: new GoAffProClientOptions() - { + options: new GoAffProClientOptions { MaxRetries = MaxRetries, Timeout = TimeSpan.FromSeconds(30), BaseUrl = Endpoint }, email: Email, password: Password, cancellationToken: ct); - - IntegrationConnection conn = new IntegrationConnection - { + + var conn = new IntegrationConnection { Name = "", Status = true, Source = SubathonEventSource.GoAffPro, @@ -65,20 +63,16 @@ public async Task StartAsync(CancellationToken ct = default) }; IntegrationEvents.RaiseConnectionUpdate(conn); } - catch (Exception e) - { + catch (Exception e) { _client = null; logger?.LogWarning(e, "[GoAffPro] Failed to login to GoAffPro"); return; } - + UserSiteListResponse? sitesResponse; - try - { - sitesResponse = await _client.Api.User.Sites.GetAsync(reqConfig => - { - reqConfig.QueryParameters.FieldsAsGetFieldsQueryParameterType = - [ + try { + sitesResponse = await _client.Api.User.Sites.GetAsync(reqConfig => { + reqConfig.QueryParameters.FieldsAsGetFieldsQueryParameterType = [ GetFieldsQueryParameterType.Currency, GetFieldsQueryParameterType.Id, GetFieldsQueryParameterType.Name, @@ -88,26 +82,24 @@ public async Task StartAsync(CancellationToken ct = default) reqConfig.QueryParameters.Offset = 0; }, ct); } - catch (Exception e) - { + catch (Exception e) { logger?.LogWarning(e, "[GoAffPro] Failed to fetch connected sites"); await StopAsync(ct); return; } - + if (sitesResponse?.Sites == null) return; - foreach (var site in sitesResponse.Sites.Where(site => site is { Id: not null, Status: UserSite_status.Approved })) - { + foreach (UserSite site in sitesResponse.Sites.Where(site => site is + { Id: not null, Status: UserSite_status.Approved })) { // dynamically provision any store on the account we haven't seen before - var store = GoAffProStoreRegistry.GetOrProvision(site.Id!.Value, site.Name ?? ""); + GoAffProStore store = GoAffProStoreRegistry.GetOrProvision(site.Id!.Value, site.Name ?? ""); if (!store.Enabled) continue; if (!_siteIds.Add(site.Id.Value)) continue; GoAffProStoreRegistry.MarkActiveOnAccount(site.Id.Value); string currency = !string.IsNullOrWhiteSpace(site.Currency) ? site.Currency : "USD"; - IntegrationConnection conn = new IntegrationConnection - { + var conn = new IntegrationConnection { Name = currency, Status = true, Source = SubathonEventSource.GoAffPro, @@ -115,35 +107,69 @@ public async Task StartAsync(CancellationToken ct = default) }; IntegrationEvents.RaiseConnectionUpdate(conn); } - + _detectorCts = new CancellationTokenSource(); - if (!int.TryParse(config.Get(_configSection, "DaysOffset", "0"), out var daysOffset)) daysOffset = 0; - + if (!int.TryParse(config.Get(_configSection, "DaysOffset", "0"), out int daysOffset)) daysOffset = 0; + _client.OrderObserverStartTime = DateTimeOffset.UtcNow - TimeSpan.FromDays(daysOffset); logger?.LogInformation("[GoAffPro] Started GoAffPro service with {Count} connected sites", _siteIds.Count); - - _ = Task.Run(async() => { + + _ = Task.Run(async () => { logger?.LogInformation("[GoAffPro] GoAffPro is now polling for orders..."); - await foreach (var order in _client.NewOrdersAsync( - pollingInterval: TimeSpan.FromSeconds(30), - pageSize: 100, - cancellationToken: _detectorCts.Token)) - { + await foreach (UserOrderFeedItem order in _client.NewOrdersAsync( + TimeSpan.FromSeconds(30), + 100, + _detectorCts.Token)) HandleOrder(order); - } logger?.LogInformation("[GoAffPro] GoAffPro polling finished"); }, _detectorCts.Token); } - public void SimulateOrder(decimal total, int itemCount, decimal commissionTotal, GoAffProStore? affilStore, string currency = "USD") - { - string id = Guid.NewGuid().ToString(); + public Task StopAsync(CancellationToken ct = default) { + timerService?.Unregister("goaffpro-auth-check"); + + foreach (GoAffProStore store in GoAffProStoreRegistry.All()) { + GoAffProStoreRegistry.MarkActiveOnAccount(store.SiteId, false); + var conn = new IntegrationConnection { + Name = "", + Status = false, + Source = SubathonEventSource.GoAffPro, + Service = store.InternalName, + Configured = false + }; + IntegrationEvents.RaiseConnectionUpdate(conn); + } + + var connection = new IntegrationConnection { + Name = "", + Status = false, + Source = SubathonEventSource.GoAffPro, + Service = nameof(SubathonEventSource.GoAffPro), + Configured = !string.IsNullOrWhiteSpace(Email) && !string.IsNullOrWhiteSpace(Password) + }; + IntegrationEvents.RaiseConnectionUpdate(connection); + + if (_client != null && _detectorCts is { IsCancellationRequested: false }) + _detectorCts.Cancel(); + _client = null; + return Task.CompletedTask; + } + + [ExcludeFromCodeCoverage] + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + public void SimulateOrder(decimal total, int itemCount, decimal commissionTotal, GoAffProStore? affilStore, + string currency = "USD") { + var id = Guid.NewGuid().ToString(); // id is meant to be a long but w/e - UserOrderFeedItem order = new UserOrderFeedItem(); + var order = new UserOrderFeedItem(); int idInt = affilStore?.SiteId > 0 ? affilStore.SiteId : int.MaxValue; - order.SiteId = new UserOrderFeedItem.UserOrderFeedItem_site_id() { Integer = idInt }; - order.Id = new UserOrderFeedItem.UserOrderFeedItem_id() { String = id}; + order.SiteId = new UserOrderFeedItem.UserOrderFeedItem_site_id { Integer = idInt }; + order.Id = new UserOrderFeedItem.UserOrderFeedItem_id { String = id }; order.Number = "SIMULATED"; order.Total = total.ToString(CultureInfo.InvariantCulture); order.Subtotal = total.ToString(CultureInfo.InvariantCulture); @@ -151,20 +177,16 @@ public void SimulateOrder(decimal total, int itemCount, decimal commissionTotal, order.Currency = currency; order.Status = "approved"; order.CreatedAt = DateTimeOffset.UtcNow; - order.LineItems = new List() - { - new UserOrderLineItem() - { + order.LineItems = new List { + new() { Quantity = itemCount } }; HandleOrder(order); } - private void HandleOrder(UserOrderFeedItem order) - { - try - { + private void HandleOrder(UserOrderFeedItem order) { + try { // If an order comes in as new and then approved, only one is added due to unique id's if (order.Id == null || order.SiteId == null || order.LineItems == null || @@ -173,8 +195,7 @@ private void HandleOrder(UserOrderFeedItem order) !string.Equals(order.Status, "new", StringComparison.OrdinalIgnoreCase))) return; // new and approved can both come in, but same id will mean it doesn't add twice - SubathonEvent ev = new SubathonEvent - { + var ev = new SubathonEvent { Id = Utils.CreateGuidFromUniqueString(!string.IsNullOrWhiteSpace(order.Id.String) ? order.Id.String : order.Id!.Integer.ToString()), @@ -184,46 +205,42 @@ private void HandleOrder(UserOrderFeedItem order) ev.EventTimestamp = order.CreatedAt.Value.LocalDateTime; ev.User = "New Order!"; - if (!string.IsNullOrWhiteSpace(order.Number) && order.Number == "SIMULATED") - { + if (!string.IsNullOrWhiteSpace(order.Number) && order.Number == "SIMULATED") { ev.User = "SIMULATED"; ev.Source = SubathonEventSource.Simulated; // check based on eventType in event service } - var site = order.SiteId!.Integer; - if (site == null || !GoAffProStoreRegistry.TryGetBySiteId((int)site, out var store)) return; + int? site = order.SiteId!.Integer; + if (site == null || !GoAffProStoreRegistry.TryGetBySiteId((int)site, out GoAffProStore? store)) return; if (!store.Enabled) return; // we will listen for these sites regardless in orders, but will ignore if not enabled. - var enabled = config.GetBool(_configSection, $"{store.InternalName}.Enabled", true); + bool enabled = config.GetBool(_configSection, $"{store.InternalName}.Enabled", true); if (!enabled) return; - var sourceMode = config.GetOrderTypeMode(_configSection, + OrderTypeModes sourceMode = config.GetOrderTypeMode(_configSection, store.InternalName, OrderTypeModes.Dollar); - ev.Currency = sourceMode switch - { + ev.Currency = sourceMode switch { OrderTypeModes.Item => "items", OrderTypeModes.Order => "order", _ => order.Currency }; - int itemCount = 0; - foreach (var item in order.LineItems) - { + var itemCount = 0; + foreach (UserOrderLineItem item in order.LineItems) { itemCount += item.Quantity ?? 0; itemCount -= item.RefundQuantity ?? 0; } + ev.Amount = itemCount; - switch (sourceMode) - { + switch (sourceMode) { case OrderTypeModes.Dollar: ev.Value = $"{order.Subtotal}"; break; case OrderTypeModes.Order: ev.Value = "New"; break; - default: - { + default: { ev.Value = $"{itemCount}"; break; } @@ -239,49 +256,15 @@ private void HandleOrder(UserOrderFeedItem order) SubathonEvents.RaiseSubathonEventCreated(ev); } - catch (Exception e) - { - logger?.LogWarning(e, "[GoAffPro] Failed to consume order. Data: {Serialize}", JsonSerializer.Serialize(order.AdditionalData)); + catch (Exception e) { + logger?.LogWarning(e, "[GoAffPro] Failed to consume order. Data: {Serialize}", + JsonSerializer.Serialize(order.AdditionalData)); } } - public Task StopAsync(CancellationToken ct = default) - { - timerService?.Unregister("goaffpro-auth-check"); - - foreach (var store in GoAffProStoreRegistry.All()) - { - GoAffProStoreRegistry.MarkActiveOnAccount(store.SiteId, false); - IntegrationConnection conn = new IntegrationConnection - { - Name = "", - Status = false, - Source = SubathonEventSource.GoAffPro, - Service = store.InternalName, - Configured = false - }; - IntegrationEvents.RaiseConnectionUpdate(conn); - } - - IntegrationConnection connection = new IntegrationConnection - { - Name = "", - Status = false, - Source = SubathonEventSource.GoAffPro, - Service = nameof(SubathonEventSource.GoAffPro), - Configured = !string.IsNullOrWhiteSpace(Email) && !string.IsNullOrWhiteSpace(Password) - }; - IntegrationEvents.RaiseConnectionUpdate(connection); - - if (_client != null && _detectorCts is { IsCancellationRequested: false }) - _detectorCts.Cancel(); - _client = null; - return Task.CompletedTask; - } - - public async Task ReconnectCheck(CancellationToken ct = default) - { - var conn = Utils.GetConnection(SubathonEventSource.GoAffPro, nameof(SubathonEventSource.GoAffPro)); + public async Task ReconnectCheck(CancellationToken ct = default) { + IntegrationConnection conn = + Utils.GetConnection(SubathonEventSource.GoAffPro, nameof(SubathonEventSource.GoAffPro)); if (!conn.Status) return; await StopAsync(ct); @@ -290,17 +273,9 @@ public async Task ReconnectCheck(CancellationToken ct = default) } [ExcludeFromCodeCoverage] - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - [ExcludeFromCodeCoverage] - protected virtual void Dispose(bool disposing) - { + protected virtual void Dispose(bool disposing) { if (_disposed) return; - if (_client != null && _detectorCts is { IsCancellationRequested: true }) + if (_client != null && _detectorCts is { IsCancellationRequested: true }) _detectorCts.Cancel(); _client?.Dispose(); _disposed = true; diff --git a/SubathonManager.Integration/JuniperService.cs b/SubathonManager.Integration/JuniperService.cs index 777afc45..9244357b 100644 --- a/SubathonManager.Integration/JuniperService.cs +++ b/SubathonManager.Integration/JuniperService.cs @@ -5,14 +5,15 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; -using SubathonManager.Core.Objects; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; namespace SubathonManager.Integration; -public class JuniperService(ILogger? logger, IHttpClientFactory httpClientFactory, - ITimerService? timerService = null) : IDisposable, IAppService -{ +public class JuniperService( + ILogger? logger, + IHttpClientFactory httpClientFactory, + ITimerService? timerService = null) : IDisposable, IAppService { internal const string OrdersSumBase = "https://prod.orders.junipercreates.com/orders/sum"; internal const string FixedEndTime = "2099-11-20T07:59:00.000Z"; private const int MaxIdsPerRequest = 10; @@ -20,37 +21,23 @@ public class JuniperService(ILogger? logger, IHttpClientFactory private static readonly TimeSpan Interval = TimeSpan.FromSeconds(180); private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(20); + private bool _errored; + private bool _lastBroadcastStatus; private DateTime _lastFetchTime = DateTime.UtcNow; private bool _running; - private bool _errored; - private bool _lastBroadcastStatus; public bool Connected => _running && !_errored; - internal static List MakeQueryUrls(IReadOnlyList productIds, DateTime startTime) - { - var urls = new List(); - string start = startTime.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); - for (int i = 0; i < productIds.Count; i += MaxIdsPerRequest) - { - string joined = string.Join(",", productIds.Skip(i).Take(MaxIdsPerRequest)); - urls.Add($"{OrdersSumBase}?productIds={joined}&startTime={start}&endTime={FixedEndTime}"); - } - return urls; - } - - public Task StartAsync(CancellationToken ct = default) - { + public Task StartAsync(CancellationToken ct = default) { timerService?.Unregister(TimerKey); _errored = false; - var productIds = JuniperStoreRegistry.AllValidProductIds(); - if (productIds.Count == 0) - { + List productIds = JuniperStoreRegistry.AllValidProductIds(); + if (productIds.Count == 0) { logger?.LogInformation("[Juniper] No products tracked. Integration inactive."); _running = false; - BroadcastStatus(force: true); + BroadcastStatus(true); return Task.CompletedTask; } @@ -58,45 +45,54 @@ public Task StartAsync(CancellationToken ct = default) _lastFetchTime = DateTime.UtcNow; timerService?.Register(TimerKey, Interval, PollAsync); logger?.LogInformation("[Juniper] Tracking started ({Count} products)", productIds.Count); - BroadcastStatus(force: true); + BroadcastStatus(true); return Task.CompletedTask; } - public Task StopAsync(CancellationToken ct = default) - { + public Task StopAsync(CancellationToken ct = default) { timerService?.Unregister(TimerKey); _running = false; - BroadcastStatus(force: true); + BroadcastStatus(true); return Task.CompletedTask; } - public async Task RestartAsync() - { + public void Dispose() { + timerService?.Unregister(TimerKey); + } + + internal static List MakeQueryUrls(IReadOnlyList productIds, DateTime startTime) { + var urls = new List(); + var start = startTime.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); + for (var i = 0; i < productIds.Count; i += MaxIdsPerRequest) { + string joined = string.Join(",", productIds.Skip(i).Take(MaxIdsPerRequest)); + urls.Add($"{OrdersSumBase}?productIds={joined}&startTime={start}&endTime={FixedEndTime}"); + } + + return urls; + } + + public async Task RestartAsync() { await StopAsync(); await StartAsync(); } - public void Dispose() => timerService?.Unregister(TimerKey); - - internal async Task PollAsync(CancellationToken ct) - { + internal async Task PollAsync(CancellationToken ct) { if (!_running) return; - var startTime = _lastFetchTime; + DateTime startTime = _lastFetchTime; _lastFetchTime = DateTime.UtcNow; - var productIds = JuniperStoreRegistry.AllValidProductIds(); + List productIds = JuniperStoreRegistry.AllValidProductIds(); if (productIds.Count == 0) return; - bool anyError = false; - foreach (var url in MakeQueryUrls(productIds, startTime)) - { + var anyError = false; + foreach (string url in MakeQueryUrls(productIds, startTime)) { string? json = await GetStringAsync(url, ct); - if (json == null) - { + if (json == null) { anyError = true; continue; } + if (!HandleOrdersResponse(json, startTime)) anyError = true; } @@ -105,41 +101,33 @@ internal async Task PollAsync(CancellationToken ct) BroadcastStatus(); } - internal bool HandleOrdersResponse(string json, DateTime windowStart) - { - try - { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + internal bool HandleOrdersResponse(string json, DateTime windowStart) { + try { + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; logger?.LogDebug("[Juniper] orders/sum response ({Kind}): {Body}", root.ValueKind, json); if (root.ValueKind != JsonValueKind.Object || - !root.TryGetProperty("products_details", out var products) || - products.ValueKind != JsonValueKind.Object) - { + !root.TryGetProperty("products_details", out JsonElement products) || + products.ValueKind != JsonValueKind.Object) { logger?.LogWarning("[Juniper] orders/sum response missing products_details: {Body}", json); return false; } - foreach (JsonProperty product in products.EnumerateObject()) - { + foreach (JsonProperty product in products.EnumerateObject()) { string productId = product.Name; - if (!product.Value.TryGetProperty("units_sold", out var unitsEl) || + if (!product.Value.TryGetProperty("units_sold", out JsonElement unitsEl) || !unitsEl.TryGetInt32(out int unitsSold)) continue; if (unitsSold <= 0) continue; - if (!JuniperStoreRegistry.TryGetProduct(productId, out var juniperProduct)) - { - continue; - } - - SubathonEvent ev = new SubathonEvent - { + if (!JuniperStoreRegistry.TryGetProduct(productId, out JuniperProduct? juniperProduct)) continue; + + var ev = new SubathonEvent { Amount = unitsSold, EventTypeMeta = productId, Currency = "sales", @@ -151,26 +139,23 @@ internal bool HandleOrdersResponse(string json, DateTime windowStart) }; SubathonEvents.RaiseSubathonEventCreated(ev); } - + return true; } - catch (JsonException ex) - { + catch (JsonException ex) { logger?.LogWarning(ex, "[Juniper] Bad orders/sum response: {Body}", json); return false; } } - public static void Simulate(string meta, int count) - { + public static void Simulate(string meta, int count) { if (count <= 0) count = 1; meta = meta?.Trim() ?? ""; - string name = JuniperOrderHelper.TryGetProduct(meta, out var product) + string name = JuniperOrderHelper.TryGetProduct(meta, out JuniperProduct? product) ? product.ProductName : "Test Product"; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.JuniperMerchSale, EventTypeMeta = meta, @@ -182,15 +167,13 @@ public static void Simulate(string meta, int count) }); } - private void BroadcastStatus(bool force = false) - { - var stores = JuniperStoreRegistry.AllStores(); + private void BroadcastStatus(bool force = false) { + IReadOnlyList stores = JuniperStoreRegistry.AllStores(); bool status = Connected; if (!force && status == _lastBroadcastStatus) return; _lastBroadcastStatus = status; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.JuniperCreates, Service = nameof(SubathonEventSource.JuniperCreates), Name = stores.Count == 0 ? "" : $"{stores.Count} store(s)", @@ -198,44 +181,38 @@ private void BroadcastStatus(bool force = false) Configured = stores.Count > 0 }); - foreach (var store in stores) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + foreach (JuniperStore store in stores) + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.JuniperCreates, Service = store.StoreName, Name = $"{store.Products.Count} tracked", Status = status && store.Enabled, Configured = true }); - } } - private async Task GetStringAsync(string url, CancellationToken ct) - { - using var client = httpClientFactory.CreateClient(nameof(JuniperService)); + private async Task GetStringAsync(string url, CancellationToken ct) { + using HttpClient client = httpClientFactory.CreateClient(nameof(JuniperService)); client.Timeout = HttpTimeout; if (!client.DefaultRequestHeaders.UserAgent.Any()) - client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) SubathonManager"); - try - { - var response = await client.GetAsync(url, ct); - if (!response.IsSuccessStatusCode) - { + client.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) SubathonManager"); + try { + HttpResponseMessage response = await client.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) { logger?.LogWarning("[Juniper] {Status} from {Url}", response.StatusCode, url); return null; } + return await response.Content.ReadAsStringAsync(ct); } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { logger?.LogWarning("[Juniper] Timeout fetching {Url}", url); return null; } - catch (HttpRequestException ex) - { + catch (HttpRequestException ex) { logger?.LogWarning(ex, "[Juniper] Request failed for {Url}", url); return null; } } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/KoFiService.cs b/SubathonManager.Integration/KoFiService.cs index 558e57c9..8dc3fe87 100644 --- a/SubathonManager.Integration/KoFiService.cs +++ b/SubathonManager.Integration/KoFiService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net.Http.Headers; using System.Text; using Agash.Webhook.Abstractions; @@ -16,161 +17,151 @@ namespace SubathonManager.Integration; -public class KoFiService(ILogger? logger, IConfig config, IHttpClientFactory httpClientFactory, DevTunnelsService devTunnels, ISecureStorage secureStorage) - : IWebhookIntegration -{ - private readonly string _configSection = "KoFi"; - +public class KoFiService( + ILogger? logger, + IConfig config, + IHttpClientFactory httpClientFactory, + DevTunnelsService devTunnels, + ISecureStorage secureStorage) + : IWebhookIntegration { // Headers forwarded verbatim to any configured forward URLs. // Hop-by-hop and transport headers are excluded; they don't apply end-to-end. - private static readonly HashSet _skipForwardHeaders = new(StringComparer.OrdinalIgnoreCase) - { + private static readonly HashSet _skipForwardHeaders = new(StringComparer.OrdinalIgnoreCase) { "host", "connection", "transfer-encoding", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "upgrade" }; - public string WebhookPath => "/api/webhooks/kofi"; + private readonly string _configSection = "KoFi"; private readonly KoFiWebhookHandler _handler = new(); - public async Task StartAsync(CancellationToken ct = default) - { + public string WebhookPath => "/api/webhooks/kofi"; + + public async Task StartAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; - var token = secureStorage.Get(StorageKeys.KoFiVerificationToken); // config.GetFromEncoded(_configSection, "VerificationToken", string.Empty); + string? + token = secureStorage.Get(StorageKeys + .KoFiVerificationToken); // config.GetFromEncoded(_configSection, "VerificationToken", string.Empty); bool enabled = !string.IsNullOrWhiteSpace(token); - if (enabled) - { + if (enabled) { logger?.LogInformation("[Ko-Fi] Webhook listener ready at {Path}", WebhookPath); // Start tunnel on demand; no need to open a public endpoint if no token is set. // Fire-and-forget: OnTunnelUpdated will broadcast the composed URL once ready. _ = devTunnels.StartTunnelAsync(ct); } - else - { + else { logger?.LogInformation("[Ko-Fi] No verification token configured; Ko-Fi integration is disabled"); } // Seed status; include public URL if the tunnel is already running - var tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); + IntegrationConnection tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); BroadcastStatus(enabled, tunnelConn.Status ? tunnelConn.Name : null); await Task.CompletedTask; } - public Task StopAsync(CancellationToken ct = default) - { + public Task StopAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated -= OnTunnelUpdated; BroadcastStatus(false, null); return Task.CompletedTask; } - // Re-broadcast our own status whenever the shared tunnel changes so consumers - // (e.g. KoFiWebhookSettings) always see the composed public URL without having - // to know about DevTunnels or manually concatenate paths. - private void OnTunnelUpdated(IntegrationConnection connection) - { - if (connection is not { Source: SubathonEventSource.DevTunnels, Service: "Tunnel" }) return; - var token = secureStorage.Get(StorageKeys.KoFiVerificationToken); - bool enabled = !string.IsNullOrWhiteSpace(token); - BroadcastStatus(enabled, connection.Status ? connection.Name : null); - } - - private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) - { - // Compose the full public URL from the tunnel base + our own path. - // WebhookPath is the single source of truth for the path segment. - string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" - ? tunnelBaseUrl.TrimEnd('/') + WebhookPath - : null; - - // Only report as connected when both the token is configured and the tunnel - // is actually up with a public URL. A token alone means nothing without reachability. - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { - Name = fullUrl ?? "", - Status = enabled && fullUrl != null, - Source = SubathonEventSource.KoFiTunnel, - Service = nameof(SubathonEventSource.KoFiTunnel), - Configured = enabled - }); - - if (fullUrl != null) - logger?.LogInformation("[Ko-Fi] Public webhook URL: {Url}", fullUrl); - } - - public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, CancellationToken ct = default) - { + public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct = default) { // Forward the raw request to any configured URLs before verifying; the // forwarded service (e.g. StreamerBot) needs the same unmodified payload. await ForwardRequestAsync(rawBody, headers, ct); - var token = secureStorage.Get(StorageKeys.KoFiVerificationToken); - if (string.IsNullOrWhiteSpace(token)) - { + string? token = secureStorage.Get(StorageKeys.KoFiVerificationToken); + if (string.IsNullOrWhiteSpace(token)) { logger?.LogWarning("[Ko-Fi] Received webhook but no verification token is configured"); return; } // Ko-Fi always sends application/x-www-form-urlencoded; use the inbound // Content-Type if present, fall back to the known Ko-Fi value. - var contentType = headers.TryGetValue("Content-Type", out var ct2) ? ct2 + string contentType = headers.TryGetValue("Content-Type", out string? ct2) + ? ct2 : "application/x-www-form-urlencoded"; - var request = new WebhookRequest - { + var request = new WebhookRequest { Method = "POST", Path = WebhookPath, Body = rawBody, ContentType = contentType }; - var result = await _handler.HandleAsync(request, new KoFiWebhookOptions { VerificationToken = token }, ct); + WebhookHandleResult result = + await _handler.HandleAsync(request, new KoFiWebhookOptions { VerificationToken = token }, ct); - if (!result.IsAuthenticated) - { + if (!result.IsAuthenticated) { logger?.LogWarning("[Ko-Fi] Webhook authentication failed: {Reason}", result.FailureReason); return; } - if (!result.IsKnownEvent || result.Event is null) - { + if (!result.IsKnownEvent || result.Event is null) { logger?.LogDebug("[Ko-Fi] Received unknown/unsupported Ko-Fi event type"); return; } - var ev = MapToSubathonEvent(result.Event); - if (ev != null) - { + SubathonEvent? ev = MapToSubathonEvent(result.Event); + if (ev != null) { SubathonEvents.RaiseSubathonEventCreated(ev); logger?.LogDebug("[Ko-Fi] Raised {EventType} from {User}", ev.EventType, ev.User); } } - private SubathonEvent? MapToSubathonEvent(KoFiWebhookEvent koFiEvent) - { - try - { - var sourceMode = config.GetOrderTypeMode(_configSection, + // Re-broadcast our own status whenever the shared tunnel changes so consumers + // (e.g. KoFiWebhookSettings) always see the composed public URL without having + // to know about DevTunnels or manually concatenate paths. + private void OnTunnelUpdated(IntegrationConnection connection) { + if (connection is not { Source: SubathonEventSource.DevTunnels, Service: "Tunnel" }) return; + string? token = secureStorage.Get(StorageKeys.KoFiVerificationToken); + bool enabled = !string.IsNullOrWhiteSpace(token); + BroadcastStatus(enabled, connection.Status ? connection.Name : null); + } + + private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { + // Compose the full public URL from the tunnel base + our own path. + // WebhookPath is the single source of truth for the path segment. + string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" + ? tunnelBaseUrl.TrimEnd('/') + WebhookPath + : null; + + // Only report as connected when both the token is configured and the tunnel + // is actually up with a public URL. A token alone means nothing without reachability. + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Name = fullUrl ?? "", + Status = enabled && fullUrl != null, + Source = SubathonEventSource.KoFiTunnel, + Service = nameof(SubathonEventSource.KoFiTunnel), + Configured = enabled + }); + + if (fullUrl != null) + logger?.LogInformation("[Ko-Fi] Public webhook URL: {Url}", fullUrl); + } + + private SubathonEvent? MapToSubathonEvent(KoFiWebhookEvent koFiEvent) { + try { + OrderTypeModes sourceMode = config.GetOrderTypeMode(_configSection, $"{SubathonEventType.KoFiShopOrder}", OrderTypeModes.Dollar); var defaultCurrency = "USD"; string username = koFiEvent.FromName ?? "Ko-Fi Supporter"; if (!koFiEvent.IsPublic) username = "Ko-Fi Supporter"; - return koFiEvent switch - { - KoFiDonationEvent d => new SubathonEvent - { + return koFiEvent switch { + KoFiDonationEvent d => new SubathonEvent { Id = Utils.TryParseGuid(d.MessageId), Source = SubathonEventSource.KoFi, EventType = SubathonEventType.KoFiDonation, User = username, - Value = d.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + Value = d.Amount.ToString("F2", CultureInfo.InvariantCulture), Currency = string.IsNullOrWhiteSpace(d.Currency) ? d.Currency : defaultCurrency, - EventTimestamp = d.Timestamp.LocalDateTime, + EventTimestamp = d.Timestamp.LocalDateTime }, - KoFiSubscriptionStartedEvent s => new SubathonEvent - { + KoFiSubscriptionStartedEvent s => new SubathonEvent { Id = Utils.TryParseGuid(s.MessageId), Source = SubathonEventSource.KoFi, EventType = SubathonEventType.KoFiSub, @@ -178,10 +169,9 @@ public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary new SubathonEvent - { + KoFiSubscriptionRenewedEvent r => new SubathonEvent { Id = Utils.TryParseGuid(r.MessageId), Source = SubathonEventSource.KoFi, EventType = SubathonEventType.KoFiSub, @@ -189,102 +179,88 @@ public async Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary new SubathonEvent - { + KoFiShopOrderEvent shop => new SubathonEvent { Id = Utils.TryParseGuid(shop.MessageId), Source = SubathonEventSource.KoFi, EventType = SubathonEventType.KoFiShopOrder, User = username, - Value = sourceMode switch - { - OrderTypeModes.Item => $"{shop.ShopItems?.Count ?? 1}", - OrderTypeModes.Order => "New", - _ => shop.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) - }, - Currency = sourceMode switch - { - OrderTypeModes.Item => "items", - OrderTypeModes.Order => "order", - _ => string.IsNullOrWhiteSpace(shop.Currency) ? shop.Currency : defaultCurrency - }, + Value = sourceMode switch { + OrderTypeModes.Item => $"{shop.ShopItems?.Count ?? 1}", + OrderTypeModes.Order => "New", + _ => shop.Amount.ToString("F2", CultureInfo.InvariantCulture) + }, + Currency = sourceMode switch { + OrderTypeModes.Item => "items", + OrderTypeModes.Order => "order", + _ => string.IsNullOrWhiteSpace(shop.Currency) ? shop.Currency : defaultCurrency + }, Amount = shop.ShopItems?.Count ?? 1, - SecondaryValue = $"{shop.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}|{ + SecondaryValue = $"{shop.Amount.ToString("F2", CultureInfo.InvariantCulture)}|{ (string.IsNullOrWhiteSpace(shop.Currency) ? shop.Currency : defaultCurrency)}", - EventTimestamp = shop.Timestamp.LocalDateTime, + EventTimestamp = shop.Timestamp.LocalDateTime }, - KoFiCommissionEvent comm => new SubathonEvent - { + KoFiCommissionEvent comm => new SubathonEvent { Id = Utils.TryParseGuid(comm.MessageId), Source = SubathonEventSource.KoFi, EventType = SubathonEventType.KoFiCommissionOrder, User = username, - Value = comm.Amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + Value = comm.Amount.ToString("F2", CultureInfo.InvariantCulture), Currency = string.IsNullOrWhiteSpace(comm.Currency) ? comm.Currency : defaultCurrency, Amount = 1, - EventTimestamp = comm.Timestamp.LocalDateTime, + EventTimestamp = comm.Timestamp.LocalDateTime }, _ => null }; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[Ko-Fi] Failed to map Ko-Fi event to SubathonEvent"); return null; } } - private async Task ForwardRequestAsync(byte[] rawBody, IReadOnlyDictionary headers, CancellationToken ct) - { + private async Task ForwardRequestAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct) { string? forwardUrlsRaw = config.Get(_configSection, "ForwardUrls", string.Empty); if (string.IsNullOrWhiteSpace(forwardUrlsRaw)) return; - var urls = forwardUrlsRaw + string[] urls = forwardUrlsRaw .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var client = httpClientFactory.CreateClient(nameof(KoFiService)); + HttpClient client = httpClientFactory.CreateClient(nameof(KoFiService)); - foreach (var url in urls) - { - try - { + foreach (string url in urls) + try { using var content = new ByteArrayContent(rawBody); // Forward all inbound headers except hop-by-hop ones. // Content-Type is applied to the content itself; everything else goes on the request. - foreach (var (key, value) in headers) - { + foreach ((string key, string value) in headers) { if (_skipForwardHeaders.Contains(key)) continue; - if (key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) - { - if (MediaTypeHeaderValue.TryParse(value, out var mt)) + if (key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) { + if (MediaTypeHeaderValue.TryParse(value, out MediaTypeHeaderValue? mt)) content.Headers.ContentType = mt; } - else if (key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) - { + else if (key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) { // HttpClient sets this automatically from ByteArrayContent } - else - { + else { content.Headers.TryAddWithoutValidation(key, value); } } // Ensure Content-Type is set even if the inbound request omitted it - content.Headers.ContentType ??= new MediaTypeHeaderValue("application/x-www-form-urlencoded") - { + content.Headers.ContentType ??= new MediaTypeHeaderValue("application/x-www-form-urlencoded") { CharSet = Encoding.UTF8.WebName }; - var response = await client.PostAsync(url, content, ct); + HttpResponseMessage response = await client.PostAsync(url, content, ct); logger?.LogDebug("[Ko-Fi] Forwarded webhook to {Url}: {Status}", url, response.StatusCode); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[Ko-Fi] Failed to forward webhook to {Url}", url); } - } } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/MakeShipService.cs b/SubathonManager.Integration/MakeShipService.cs index 08dc17e8..4c76b532 100644 --- a/SubathonManager.Integration/MakeShipService.cs +++ b/SubathonManager.Integration/MakeShipService.cs @@ -11,63 +11,52 @@ namespace SubathonManager.Integration; -public partial class MakeShipService(ILogger? logger, IHttpClientFactory httpClientFactory, - ITimerService? timerService) : IAppService -{ +public partial class MakeShipService( + ILogger? logger, + IHttpClientFactory httpClientFactory, + ITimerService? timerService) : IAppService { // pledges and campaign sales must always end up as checked for by item count / quantity // because we poll and wrap up delta between (ignoring bootup or initial number) private const string PreProductApiBase = "https://api.preproduct.io/api/preproducts/"; private const string PreProductShopQuery = "?shop=makeship-store.myshopify.com"; private const string StorefrontBase = "https://storefront.makeship.com"; + private const string TimerKey = "makeship-poll"; + private const int InvalidAfterConsecutiveFailures = 3; + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30); private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(20); - - [GeneratedRegex("""<[^>]*id="preproduct-pledge"[^>]*data-id="(\d+)""", RegexOptions.IgnoreCase)] - private static partial Regex PetitionPledgeIdRegex(); - - [GeneratedRegex("""<[^>]*data-id="(\d+)"[^>]*id="preproduct-pledge""", RegexOptions.IgnoreCase)] - private static partial Regex PetitionPledgeIdAltRegex(); - - [GeneratedRegex(@"gid://shopify/Product/(\d+)", RegexOptions.IgnoreCase)] - private static partial Regex CampaignProductIdRegex(); - - private const string TimerKey = "makeship-poll"; private static readonly TimeSpan StaggerDelay = TimeSpan.FromSeconds(2); - private int _pollSweepRunning; - private readonly HashSet _syncedSinceStart = new(); private readonly Dictionary _contentFailures = new(); - private const int InvalidAfterConsecutiveFailures = 3; + private readonly HashSet _syncedSinceStart = new(); private readonly Lock _syncLock = new(); - private bool _running; private bool _lastBroadcastStatus; + private int _pollSweepRunning; + private bool _running; public bool Connected { get; private set; } - public Task StartAsync(CancellationToken ct = default) - { + public Task StartAsync(CancellationToken ct = default) { UnregisterTimers(); - lock (_syncLock) - { + lock (_syncLock) { _syncedSinceStart.Clear(); _contentFailures.Clear(); } - var trackings = MakeShipTrackingRegistry.All(); - foreach (var tracking in trackings) - { - var classified = MakeShipTrackingRegistry.ClassifyUrl(tracking.Url); + List trackings = MakeShipTrackingRegistry.All(); + foreach (MakeShipTracking tracking in trackings) { + MakeShipProductType classified = MakeShipTrackingRegistry.ClassifyUrl(tracking.Url); if (classified == MakeShipProductType.Invalid) continue; if (!string.IsNullOrEmpty(tracking.ShopifyProductId)) tracking.ProductType = classified; else if (tracking.ProductType == MakeShipProductType.Invalid) tracking.ProductType = MakeShipProductType.Unknown; } - if (trackings.Count == 0) - { + + if (trackings.Count == 0) { logger?.LogInformation("[MakeShip] No urls tracked. Integration inactive."); _running = false; - BroadcastStatus(force: true); + BroadcastStatus(true); return Task.CompletedTask; } @@ -81,90 +70,99 @@ public Task StartAsync(CancellationToken ct = default) return Task.CompletedTask; } - public Task StopAsync(CancellationToken ct = default) - { + public Task StopAsync(CancellationToken ct = default) { UnregisterTimers(); _running = false; Connected = false; - BroadcastStatus(force: true); + BroadcastStatus(true); return Task.CompletedTask; } - public async Task RestartAsync() - { + [GeneratedRegex("""<[^>]*id="preproduct-pledge"[^>]*data-id="(\d+)""", RegexOptions.IgnoreCase)] + private static partial Regex PetitionPledgeIdRegex(); + + [GeneratedRegex("""<[^>]*data-id="(\d+)"[^>]*id="preproduct-pledge""", RegexOptions.IgnoreCase)] + private static partial Regex PetitionPledgeIdAltRegex(); + + [GeneratedRegex(@"gid://shopify/Product/(\d+)", RegexOptions.IgnoreCase)] + private static partial Regex CampaignProductIdRegex(); + + public async Task RestartAsync() { await StopAsync(); await StartAsync(); } - private void UnregisterTimers() => timerService?.Unregister(TimerKey); + private void UnregisterTimers() { + timerService?.Unregister(TimerKey); + } [ExcludeFromCodeCoverage] - private Task PollTimerTick(CancellationToken ct) - { + private Task PollTimerTick(CancellationToken ct) { if (Interlocked.CompareExchange(ref _pollSweepRunning, 1, 0) != 0) return Task.CompletedTask; - _ = Task.Run(async () => - { - try { await PollAllAsync(ct); } - finally { Interlocked.Exchange(ref _pollSweepRunning, 0); } + _ = Task.Run(async () => { + try { + await PollAllAsync(ct); + } + finally { + Interlocked.Exchange(ref _pollSweepRunning, 0); + } }, ct); return Task.CompletedTask; } [ExcludeFromCodeCoverage] - private async Task PollAllAsync(CancellationToken ct) - { - bool first = true; - foreach (var tracking in MakeShipTrackingRegistry.All()) - { + private async Task PollAllAsync(CancellationToken ct) { + var first = true; + foreach (MakeShipTracking tracking in MakeShipTrackingRegistry.All()) { if (!_running || ct.IsCancellationRequested) return; if (!first) - { - try { await Task.Delay(StaggerDelay, ct); } - catch (OperationCanceledException) { return; } - } + try { + await Task.Delay(StaggerDelay, ct); + } + catch (OperationCanceledException) { + return; + } + first = false; - try - { + try { await PollTrackingAsync(tracking, ct); } - catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { logger?.LogWarning(ex, "[MakeShip] Failed to poll {Url}", tracking.Url); } } - var trackings = MakeShipTrackingRegistry.All(); + List trackings = MakeShipTrackingRegistry.All(); Connected = trackings.Count > 0 && trackings.Any(IsTracked); BroadcastStatus(); } - private static bool IsTracked(MakeShipTracking t) => - t.ProductType is MakeShipProductType.Petition or MakeShipProductType.Campaign - && !string.IsNullOrEmpty(t.ShopifyProductId); + private static bool IsTracked(MakeShipTracking t) { + return t.ProductType is MakeShipProductType.Petition or MakeShipProductType.Campaign + && !string.IsNullOrEmpty(t.ShopifyProductId); + } - internal async Task PollTrackingAsync(MakeShipTracking tracking, CancellationToken ct) - { + internal async Task PollTrackingAsync(MakeShipTracking tracking, CancellationToken ct) { if (tracking.ProductType == MakeShipProductType.Invalid) return; - if (string.IsNullOrEmpty(tracking.ShopifyProductId) && !await ResolveTrackingAsync(tracking, ct)) - { + if (string.IsNullOrEmpty(tracking.ShopifyProductId) && !await ResolveTrackingAsync(tracking, ct)) { MakeShipTrackingRegistry.RaiseTrackingUpdated(tracking); return; } int previousPledges = tracking.Orders; int previousSales = tracking.Sales; - bool refreshed = tracking.ProductType switch - { + bool refreshed = tracking.ProductType switch { MakeShipProductType.Petition => await RefreshPetitionAsync(tracking, ct), MakeShipProductType.Campaign => await RefreshCampaignAsync(tracking, ct), _ => false }; tracking.PollFailing = !refreshed; - if (!refreshed) - { + if (!refreshed) { logger?.LogWarning("[MakeShip] Poll failed for {Type} '{Name}' ({Url})", tracking.ProductType, tracking.Name, tracking.Url); MakeShipTrackingRegistry.RaiseTrackingUpdated(tracking); @@ -172,10 +170,11 @@ internal async Task PollTrackingAsync(MakeShipTracking tracking, CancellationTok } bool firstSyncSinceStart; - lock (_syncLock) { firstSyncSinceStart = _syncedSinceStart.Add(tracking.Id); } + lock (_syncLock) { + firstSyncSinceStart = _syncedSinceStart.Add(tracking.Id); + } - if (!firstSyncSinceStart) - { + if (!firstSyncSinceStart) { (int previous, int current) = tracking.ProductType == MakeShipProductType.Petition ? (previousPledges, tracking.Orders) : (previousSales, tracking.Sales); @@ -186,34 +185,32 @@ internal async Task PollTrackingAsync(MakeShipTracking tracking, CancellationTok MakeShipTrackingRegistry.RaiseTrackingUpdated(tracking); } - private bool RegisterContentFailure(MakeShipTracking tracking) - { + private bool RegisterContentFailure(MakeShipTracking tracking) { int failures; - lock (_syncLock) - { + lock (_syncLock) { failures = _contentFailures.GetValueOrDefault(tracking.Id) + 1; _contentFailures[tracking.Id] = failures; } - if (failures < InvalidAfterConsecutiveFailures) - { + + if (failures < InvalidAfterConsecutiveFailures) { logger?.LogInformation("[MakeShip] Content failure {Count}/{Max} for {Url}; retrying next poll", failures, InvalidAfterConsecutiveFailures, tracking.Url); return false; } + tracking.ProductType = MakeShipProductType.Invalid; return true; } - private void ClearContentFailures(Guid trackingId) - { - lock (_syncLock) { _contentFailures.Remove(trackingId); } + private void ClearContentFailures(Guid trackingId) { + lock (_syncLock) { + _contentFailures.Remove(trackingId); + } } - private async Task ResolveTrackingAsync(MakeShipTracking tracking, CancellationToken ct) - { - var type = MakeShipTrackingRegistry.ClassifyUrl(tracking.Url); - if (type == MakeShipProductType.Invalid) - { + private async Task ResolveTrackingAsync(MakeShipTracking tracking, CancellationToken ct) { + MakeShipProductType type = MakeShipTrackingRegistry.ClassifyUrl(tracking.Url); + if (type == MakeShipProductType.Invalid) { tracking.ProductType = MakeShipProductType.Invalid; logger?.LogWarning("[MakeShip] Url is not a petition or product campaign: {Url}", tracking.Url); return false; @@ -226,8 +223,7 @@ private async Task ResolveTrackingAsync(MakeShipTracking tracking, Cancell ? MatchFirst(html, PetitionPledgeIdRegex(), PetitionPledgeIdAltRegex()) : MatchFirst(html, CampaignProductIdRegex()); - if (string.IsNullOrEmpty(productId)) - { + if (string.IsNullOrEmpty(productId)) { if (RegisterContentFailure(tracking)) logger?.LogWarning("[MakeShip] Could not find a product id on page (not a trackable {Type}?): {Url}", type, tracking.Url); @@ -244,25 +240,22 @@ private async Task ResolveTrackingAsync(MakeShipTracking tracking, Cancell return true; } - private async Task RefreshPetitionAsync(MakeShipTracking tracking, CancellationToken ct) - { + private async Task RefreshPetitionAsync(MakeShipTracking tracking, CancellationToken ct) { string? json = await GetStringAsync( $"{PreProductApiBase}{tracking.ShopifyProductId}.json{PreProductShopQuery}", ct); if (json == null) return false; - try - { - using var doc = JsonDocument.Parse(json); - if (FindProperty(doc.RootElement, "name") is { ValueKind: JsonValueKind.String } nameEl) - { - var name = nameEl.GetString(); + try { + using JsonDocument doc = JsonDocument.Parse(json); + if (FindProperty(doc.RootElement, "name") is { ValueKind: JsonValueKind.String } nameEl) { + string? name = nameEl.GetString(); if (!string.IsNullOrWhiteSpace(name) && !tracking.Name.Equals(name)) tracking.Name = name; } + if (FindProperty(doc.RootElement, "sales_actual") is { } salesEl && TryGetInt(salesEl, out int sales)) tracking.Sales = sales; } - catch (JsonException ex) - { + catch (JsonException ex) { logger?.LogWarning(ex, "[MakeShip] Bad preproduct json for {Name}", tracking.Name); return false; } @@ -271,25 +264,21 @@ private async Task RefreshPetitionAsync(MakeShipTracking tracking, Cancell $"{StorefrontBase}/orders/petitions/{tracking.ShopifyProductId}/pledges/count", ct); } - private async Task RefreshCampaignAsync(MakeShipTracking tracking, CancellationToken ct) - { + private async Task RefreshCampaignAsync(MakeShipTracking tracking, CancellationToken ct) { string? json = await GetStringAsync( $"{StorefrontBase}/products/{tracking.ShopifyProductId}/sales-quantity", ct); - if (json == null) - { + if (json == null) { logger?.LogWarning("[MakeShip] No sales-quantity for product {Id} ({Url})", tracking.ShopifyProductId, tracking.Url); return false; } - try - { - using var doc = JsonDocument.Parse(json); + try { + using JsonDocument doc = JsonDocument.Parse(json); if (FindProperty(doc.RootElement, "quantity") is { } qtyEl && TryGetInt(qtyEl, out int quantity)) tracking.Sales = quantity; } - catch (JsonException ex) - { + catch (JsonException ex) { logger?.LogWarning(ex, "[MakeShip] Bad sales-quantity json for {Name}", tracking.Name); return false; } @@ -298,56 +287,50 @@ private async Task RefreshCampaignAsync(MakeShipTracking tracking, Cancell $"{StorefrontBase}/orders/petitions/{tracking.ShopifyProductId}/pledges/count", ct); } - private async Task RefreshPledgeCountAsync(MakeShipTracking tracking, string url, CancellationToken ct) - { + private async Task RefreshPledgeCountAsync(MakeShipTracking tracking, string url, CancellationToken ct) { string? body = await GetStringAsync(url, ct); - if (body == null) - { + if (body == null) { logger?.LogWarning("[MakeShip] Pledge count request failed for '{Name}' ({Url})", tracking.Name, url); return false; } string trimmed = body.Trim().Trim('"'); if (!int.TryParse(trimmed, out int count)) - { - try - { - using var doc = JsonDocument.Parse(body); - if (FindProperty(doc.RootElement, "count") is { } el && TryGetInt(el, out count)) { } - else if (FindProperty(doc.RootElement, "quantity") is { } el2 && TryGetInt(el2, out count)) { } - else - { + try { + using JsonDocument doc = JsonDocument.Parse(body); + if (FindProperty(doc.RootElement, "count") is { } el && TryGetInt(el, out count)) { + } + else if (FindProperty(doc.RootElement, "quantity") is { } el2 && TryGetInt(el2, out count)) { + } + else { logger?.LogWarning("[MakeShip] Unrecognized pledge count response for '{Name}': {Body}", tracking.Name, Truncate(body, 200)); return false; } } - catch (JsonException) - { + catch (JsonException) { logger?.LogWarning("[MakeShip] Unrecognized pledge count response for '{Name}': {Body}", tracking.Name, Truncate(body, 200)); return false; } - } tracking.Orders = count; return true; } - private static string Truncate(string value, int max) - => value.Length <= max ? value : value[..max] + "..."; + private static string Truncate(string value, int max) { + return value.Length <= max ? value : value[..max] + "..."; + } - private void RaiseNewOrderEvent(MakeShipTracking tracking, int fromCount, int toCount) - { - var eventType = tracking.ProductType == MakeShipProductType.Petition + private void RaiseNewOrderEvent(MakeShipTracking tracking, int fromCount, int toCount) { + SubathonEventType eventType = tracking.ProductType == MakeShipProductType.Petition ? SubathonEventType.MakeShipPledge : SubathonEventType.MakeShipSale; - + string unit = eventType == SubathonEventType.MakeShipPledge ? "pledges" : "sales"; int delta = toCount - fromCount; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Id = Utils.CreateGuidFromUniqueString($"makeship-{tracking.Id}-{fromCount}-{toCount}"), Source = SubathonEventSource.MakeShip, EventType = eventType, @@ -363,15 +346,13 @@ private void RaiseNewOrderEvent(MakeShipTracking tracking, int fromCount, int to delta, unit, tracking.Name, fromCount, toCount); } - public static void Simulate(string name, bool isPetition, int count) - { + public static void Simulate(string name, bool isPetition, int count) { if (string.IsNullOrWhiteSpace(name)) name = "Test Plush"; if (count <= 0) count = 1; - var eventType = isPetition ? SubathonEventType.MakeShipPledge : SubathonEventType.MakeShipSale; + SubathonEventType eventType = isPetition ? SubathonEventType.MakeShipPledge : SubathonEventType.MakeShipSale; string unit = isPetition ? "pledges" : "sales"; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = eventType, EventTypeMeta = name.Trim(), @@ -384,15 +365,13 @@ public static void Simulate(string name, bool isPetition, int count) }); } - private void BroadcastStatus(bool force = false) - { - var trackings = MakeShipTrackingRegistry.All(); + private void BroadcastStatus(bool force = false) { + List trackings = MakeShipTrackingRegistry.All(); int tracked = trackings.Count(IsTracked); bool status = _running && tracked > 0; if (!force && status == _lastBroadcastStatus) return; _lastBroadcastStatus = status; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.MakeShip, Service = nameof(SubathonEventSource.MakeShip), Name = trackings.Count == 0 ? "" : $"{tracked}/{trackings.Count} tracked", @@ -401,64 +380,57 @@ private void BroadcastStatus(bool force = false) }); } - private async Task GetStringAsync(string url, CancellationToken ct) - { - using var client = httpClientFactory.CreateClient(nameof(MakeShipService)); + private async Task GetStringAsync(string url, CancellationToken ct) { + using HttpClient client = httpClientFactory.CreateClient(nameof(MakeShipService)); client.Timeout = HttpTimeout; if (!client.DefaultRequestHeaders.UserAgent.Any()) - client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) SubathonManager"); - try - { - var response = await client.GetAsync(url, ct); - if (!response.IsSuccessStatusCode) - { + client.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) SubathonManager"); + try { + HttpResponseMessage response = await client.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) { logger?.LogDebug("[MakeShip] {Status} from {Url}", response.StatusCode, url); return null; } + return await response.Content.ReadAsStringAsync(ct); } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { logger?.LogDebug("[MakeShip] Timeout fetching {Url}", url); return null; } - catch (HttpRequestException ex) - { + catch (HttpRequestException ex) { logger?.LogDebug(ex, "[MakeShip] Request failed for {Url}", url); return null; } } - private static string? MatchFirst(string input, params Regex[] regexes) - { - foreach (var regex in regexes) - { - var match = regex.Match(input); + private static string? MatchFirst(string input, params Regex[] regexes) { + foreach (Regex regex in regexes) { + Match match = regex.Match(input); if (match.Success) return match.Groups[1].Value; } + return null; } - private static JsonElement? FindProperty(JsonElement element, string name, int depth = 3) - { + private static JsonElement? FindProperty(JsonElement element, string name, int depth = 3) { if (element.ValueKind != JsonValueKind.Object || depth < 0) return null; - if (element.TryGetProperty(name, out var direct)) return direct; - foreach (var child in element.EnumerateObject()) - { + if (element.TryGetProperty(name, out JsonElement direct)) return direct; + foreach (JsonProperty child in element.EnumerateObject()) { if (child.Value.ValueKind != JsonValueKind.Object) continue; if (FindProperty(child.Value, name, depth - 1) is { } found) return found; } + return null; } - private static bool TryGetInt(JsonElement element, out int value) - { + private static bool TryGetInt(JsonElement element, out int value) { value = 0; - return element.ValueKind switch - { + return element.ValueKind switch { JsonValueKind.Number => element.TryGetInt32(out value), JsonValueKind.String => int.TryParse(element.GetString(), out value), _ => false }; } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/OBSService.cs b/SubathonManager.Integration/OBSService.cs index 76f6e8c7..254a1f44 100644 --- a/SubathonManager.Integration/OBSService.cs +++ b/SubathonManager.Integration/OBSService.cs @@ -1,8 +1,10 @@ using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; using OBSWebsocketDotNet; using OBSWebsocketDotNet.Communication; +using OBSWebsocketDotNet.Types; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -10,6 +12,7 @@ using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; @@ -28,47 +31,32 @@ public sealed record ObsBrowserSourceCard( Guid? RouteId); [ExcludeFromCodeCoverage] -public class OBSService : IAppService -{ - private readonly ILogger? _logger; - private readonly IConfig _config; - private readonly ISecureStorage _secureStorage; - private readonly OBSWebsocket _obs = new(); - private readonly Utils.ServiceReconnectState _reconnectState = - new(TimeSpan.FromSeconds(3), maxRetries: 1000, maxBackoff: TimeSpan.FromSeconds(10), infiniteRetries: true); - - public bool Connected => _obs.IsConnected; - private readonly Lock _scriptCheckLock = new(); - private Task? _scriptCheckTask; - private DateTime _lastScriptCheckedAt = DateTime.MinValue; - private static readonly TimeSpan ScriptCheckMinInterval = TimeSpan.FromSeconds(5); - +public class OBSService : IAppService { private const string HelperHotkeyName = "subathonmanager_apply_tweaks"; private const string HelperVersionHotkeyPrefix = "subathonmanager_version_"; private const string ManagedMarkerKey = "subathon_managed"; private const string ScriptFileName = "subathonmanager.lua"; private const string ScriptResourceName = "SubathonManager.Integration.obs.subathonmanager.lua"; + private static readonly TimeSpan ScriptCheckMinInterval = TimeSpan.FromSeconds(5); - public bool HelperScriptActive { get; private set; } - - public string? HelperScriptVersion { get; private set; } + private static readonly Lazy ExpectedScriptVersionLazy = new(ReadEmbeddedScriptVersion); + private readonly IConfig _config; + private readonly ILogger? _logger; + private readonly OBSWebsocket _obs = new(); - public bool HelperScriptOutdated => - HelperScriptActive - && ExpectedHelperScriptVersion != null - && !string.Equals(HelperScriptVersion, ExpectedHelperScriptVersion, StringComparison.Ordinal); + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(3), 1000, TimeSpan.FromSeconds(10), true); - private static readonly Lazy ExpectedScriptVersionLazy = new(ReadEmbeddedScriptVersion); - public static string? ExpectedHelperScriptVersion => ExpectedScriptVersionLazy.Value; + private readonly Lock _scriptCheckLock = new(); + private readonly ISecureStorage _secureStorage; + private DateTime _lastScriptCheckedAt = DateTime.MinValue; + private Task? _scriptCheckTask; - public event Action? HelperScriptStatusChanged; - - public event Action? BrowserSourcesChanged; + private int _startupRefreshDone; - public static string ScriptPath => Path.GetFullPath(Path.Combine("obs", ScriptFileName)); + private volatile bool _stopRequested; - public OBSService(ILogger? logger, IConfig config, ISecureStorage secureStorage) - { + public OBSService(ILogger? logger, IConfig config, ISecureStorage secureStorage) { _logger = logger; _config = config; _secureStorage = secureStorage; @@ -76,112 +64,110 @@ public OBSService(ILogger? logger, IConfig config, ISecureStorage se _obs.Connected += OnConnected; _obs.Disconnected += OnDisconnected; } - - public List GetScenes() - { - return _obs.GetSceneList().Scenes - .Select(s => s.Name) - .ToList(); - } - public string GetCurrentScene() - { - return _obs.GetCurrentProgramScene(); - } + public bool Connected => _obs.IsConnected; + + public bool HelperScriptActive { get; private set; } + + public string? HelperScriptVersion { get; private set; } + + public bool HelperScriptOutdated => + HelperScriptActive + && ExpectedHelperScriptVersion != null + && !string.Equals(HelperScriptVersion, ExpectedHelperScriptVersion, StringComparison.Ordinal); + + public static string? ExpectedHelperScriptVersion => ExpectedScriptVersionLazy.Value; - public Task StartAsync(CancellationToken cancellationToken = default) - { + public static string ScriptPath => Path.GetFullPath(Path.Combine("obs", ScriptFileName)); + + public Task StartAsync(CancellationToken cancellationToken = default) { EnsureScriptFileOnDisk(); _ = Task.Run(TryConnect, cancellationToken); return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken = default) - { + public Task StopAsync(CancellationToken cancellationToken = default) { _stopRequested = true; _reconnectState.Cts?.Cancel(); if (_obs.IsConnected) _obs.Disconnect(); return Task.CompletedTask; } - private volatile bool _stopRequested; + public event Action? HelperScriptStatusChanged; - private int _startupRefreshDone; + public event Action? BrowserSourcesChanged; + + public List GetScenes() { + return _obs.GetSceneList().Scenes + .Select(s => s.Name) + .ToList(); + } + + public string GetCurrentScene() { + return _obs.GetCurrentProgramScene(); + } - public void TryConnect() - { - var host = _config.Get("OBS", "Host", "localhost")!; - var port = _config.Get("OBS", "Port", "4455")!; - var password = _secureStorage.GetOrDefault(StorageKeys.OBSWebSocketPassword, string.Empty); + public void TryConnect() { + string host = _config.Get("OBS", "Host", "localhost")!; + string port = _config.Get("OBS", "Port", "4455")!; + string? password = _secureStorage.GetOrDefault(StorageKeys.OBSWebSocketPassword, string.Empty); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(port)) return; _stopRequested = false; - try - { + try { var url = $"ws://{host}:{port}"; _obs.ConnectAsync(url, password); } - catch (Exception ex) - { + catch (Exception ex) { if (_reconnectState.Retries < 3) _logger?.LogWarning(ex, "[OBSService] Connection attempt failed"); else - { _logger?.LogDebug(ex, "[OBSService] Connection attempt failed"); - } } _ = Task.Run(VerifyConnectionOrRetryAsync); } - private async Task VerifyConnectionOrRetryAsync() - { + private async Task VerifyConnectionOrRetryAsync() { await Task.Delay(TimeSpan.FromSeconds(3)); if (_stopRequested || _obs.IsConnected) return; await ReconnectWithBackoffAsync(); } - private void OnConnected(object? sender, EventArgs e) - { + private void OnConnected(object? sender, EventArgs e) { _logger?.LogInformation("[OBSService] Connected"); _reconnectState.Reset(); _reconnectState.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.OBS, Service = "OBS", Name = "WebSocket", Status = true }); - _ = Task.Run(async () => - { - await CheckHelperScriptAsync(force: true); + _ = Task.Run(async () => { + await CheckHelperScriptAsync(true); await RunStartupBrowserSourceRefreshAsync(); }); } - private async Task RunStartupBrowserSourceRefreshAsync() - { + private async Task RunStartupBrowserSourceRefreshAsync() { if (Interlocked.Exchange(ref _startupRefreshDone, 1) != 0) return; - try - { + try { await Task.Delay(TimeSpan.FromSeconds(5)); - if (_stopRequested || !_obs.IsConnected) - { + if (_stopRequested || !_obs.IsConnected) { Interlocked.Exchange(ref _startupRefreshDone, 0); return; } - var serverPort = _config.Get("Server", "Port", "14040")!; - var cards = await GetOverlayBrowserSourcesAsync(serverPort); + string serverPort = _config.Get("Server", "Port", "14040")!; + List cards = await GetOverlayBrowserSourcesAsync(serverPort); - int refreshed = 0; - foreach (var sourceName in cards.Select(c => c.SourceName).Distinct(StringComparer.Ordinal)) - { + var refreshed = 0; + foreach (string sourceName in cards.Select(c => c.SourceName).Distinct(StringComparer.Ordinal)) { if (!_obs.IsConnected) break; RefreshBrowserSource(sourceName); refreshed++; @@ -189,18 +175,16 @@ private async Task RunStartupBrowserSourceRefreshAsync() } if (refreshed > 0) - _logger?.LogInformation("[OBSService] Refreshed {Count} overlay browser source(s) on startup", refreshed); + _logger?.LogInformation("[OBSService] Refreshed {Count} overlay browser source(s) on startup", + refreshed); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] Startup browser source refresh failed"); } } - private void OnDisconnected(object? sender, ObsDisconnectionInfo e) - { - if (HelperScriptActive) - { + private void OnDisconnected(object? sender, ObsDisconnectionInfo e) { + if (HelperScriptActive) { HelperScriptActive = false; HelperScriptVersion = null; HelperScriptStatusChanged?.Invoke(false); @@ -208,61 +192,54 @@ private void OnDisconnected(object? sender, ObsDisconnectionInfo e) BroadcastHelperScriptStatus(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.OBS, Service = "OBS", Name = "WebSocket", Status = false }); - if (_reconnectState.Retries < 2) - { + if (_reconnectState.Retries < 2) { _logger?.LogWarning("[OBSService] Disconnected: {Reason}", e.DisconnectReason ?? "Not Running?"); _ = Task.Run(ReconnectWithBackoffAsync); } } - + [ExcludeFromCodeCoverage] - private async Task ReconnectWithBackoffAsync() - { + private async Task ReconnectWithBackoffAsync() { if (!await _reconnectState.Lock.WaitAsync(0)) return; - try - { + try { _reconnectState.Cts?.Cancel(); _reconnectState.Cts = new CancellationTokenSource(); - var token = _reconnectState.Cts.Token; + CancellationToken token = _reconnectState.Cts.Token; - var host = _config.Get("OBS", "Host", "")!; - var port = _config.Get("OBS", "Port", "")!; + string host = _config.Get("OBS", "Host")!; + string port = _config.Get("OBS", "Port")!; if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(port)) return; - while (!token.IsCancellationRequested && !_obs.IsConnected) - { - if (!_reconnectState.InfiniteRetries && _reconnectState.Retries >= _reconnectState.MaxRetries) - { + while (!token.IsCancellationRequested && !_obs.IsConnected) { + if (!_reconnectState.InfiniteRetries && _reconnectState.Retries >= _reconnectState.MaxRetries) { _logger?.LogError("[OBSService] Max reconnect retries reached"); return; } _reconnectState.Retries++; - var delay = _reconnectState.Backoff; + TimeSpan delay = _reconnectState.Backoff; - if (!_reconnectState.InfiniteRetries && (_reconnectState.Retries < 3 || _reconnectState.Retries % 10 == 0)) - { + if (!_reconnectState.InfiniteRetries && + (_reconnectState.Retries < 3 || _reconnectState.Retries % 10 == 0)) _logger?.LogDebug("[OBSService] Reconnect attempt {N} in {Delay}s", _reconnectState.Retries, delay.TotalSeconds); - } - try - { + try { await Task.Delay(delay, token); if (!_obs.IsConnected) TryConnect(); } - catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] Reconnect error"); } @@ -272,15 +249,13 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _reconnectState.Lock.Release(); } } - public bool SaveConfig(string host, string port, string password, bool forceSave = false) - { - bool hasUpdated = false; + public bool SaveConfig(string host, string port, string password, bool forceSave = false) { + var hasUpdated = false; hasUpdated |= _config.Set("OBS", "Host", host); hasUpdated |= _config.Set("OBS", "Port", port); hasUpdated |= _secureStorage.Set(StorageKeys.OBSWebSocketPassword, password); @@ -289,8 +264,7 @@ public bool SaveConfig(string host, string port, string password, bool forceSave return hasUpdated; } - public (string host, string port, string password) GetConfig() - { + public (string host, string port, string password) GetConfig() { return ( _config.Get("OBS", "Host", "localhost")!, _config.Get("OBS", "Port", "4455")!, @@ -298,10 +272,8 @@ public bool SaveConfig(string host, string port, string password, bool forceSave ); } - private Task CheckHelperScriptAsync(bool force = false) - { - lock (_scriptCheckLock) - { + private Task CheckHelperScriptAsync(bool force = false) { + lock (_scriptCheckLock) { if (_scriptCheckTask is { IsCompleted: false } inFlight) return inFlight; if (!force && DateTime.UtcNow - _lastScriptCheckedAt < ScriptCheckMinInterval) return Task.CompletedTask; @@ -311,20 +283,19 @@ private Task CheckHelperScriptAsync(bool force = false) } } - private async Task RunHelperScriptCheckAsync() - { + private async Task RunHelperScriptCheckAsync() { EnsureScriptFileOnDisk(); await Task.Delay(250); if (!_obs.IsConnected) return; - bool active = false; + var active = false; string? version = null; - bool obsStillStarting = false; + var obsStillStarting = false; try { - var response = _obs.SendRequest("GetHotkeyList"); + JObject? response = _obs.SendRequest("GetHotkeyList"); var hotkeys = response?["hotkeys"] as JArray; - var names = hotkeys?.Select(h => h?.ToString()?.Trim()).ToList(); + List? names = hotkeys?.Select(h => h?.ToString()?.Trim()).ToList(); active = names != null && names.Any(n => string.Equals(n, HelperHotkeyName, StringComparison.Ordinal)); version = names? @@ -359,7 +330,9 @@ private async Task RunHelperScriptCheckAsync() _logger?.LogWarning(ex, "[OBSService] Helper script check failed"); } finally { - lock (_scriptCheckLock) _lastScriptCheckedAt = DateTime.UtcNow; + lock (_scriptCheckLock) { + _lastScriptCheckedAt = DateTime.UtcNow; + } } if (obsStillStarting) return; @@ -370,10 +343,8 @@ private async Task RunHelperScriptCheckAsync() BroadcastHelperScriptStatus(); } - private void BroadcastHelperScriptStatus() - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastHelperScriptStatus() { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.OBS, Service = "HelperScript", Name = "Helper Script", @@ -382,93 +353,75 @@ private void BroadcastHelperScriptStatus() }); } - public void RecheckHelperScript() - { + public void RecheckHelperScript() { if (!_obs.IsConnected) return; _ = CheckHelperScriptAsync(); } - private static string? ReadEmbeddedScriptContent() - { - using var stream = typeof(OBSService).Assembly.GetManifestResourceStream(ScriptResourceName); + private static string? ReadEmbeddedScriptContent() { + using Stream? stream = typeof(OBSService).Assembly.GetManifestResourceStream(ScriptResourceName); if (stream == null) return null; using var reader = new StreamReader(stream); return reader.ReadToEnd(); } - private static string? ReadEmbeddedScriptVersion() - { - var content = ReadEmbeddedScriptContent(); + private static string? ReadEmbeddedScriptVersion() { + string? content = ReadEmbeddedScriptContent(); if (content == null) return null; - var match = System.Text.RegularExpressions.Regex.Match( + Match match = Regex.Match( content, "SCRIPT_VERSION\\s*=\\s*\"([^\"]+)\""); return match.Success ? match.Groups[1].Value : null; } - private void EnsureScriptFileOnDisk() - { - try - { - var content = ReadEmbeddedScriptContent(); - if (content == null) - { + private void EnsureScriptFileOnDisk() { + try { + string? content = ReadEmbeddedScriptContent(); + if (content == null) { _logger?.LogWarning("[OBSService] Embedded helper script resource not found"); return; } - var path = ScriptPath; + string path = ScriptPath; Directory.CreateDirectory(Path.GetDirectoryName(path)!); if (!File.Exists(path) || File.ReadAllText(path) != content) File.WriteAllText(path, content); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] Failed to write helper script to disk"); } } - - public async Task TryApplyHelperTweaksAsync() - { + + public async Task TryApplyHelperTweaksAsync() { if (!_obs.IsConnected) return; - if (!HelperScriptActive) - { - await CheckHelperScriptAsync(); - } - if (!HelperScriptActive) - { + if (!HelperScriptActive) await CheckHelperScriptAsync(); + if (!HelperScriptActive) { _logger?.LogDebug("[OBSService] Skipping helper tweaks - script not loaded in OBS"); return; } - try - { - var request = new JObject - { + try { + var request = new JObject { ["hotkeyName"] = HelperHotkeyName }; _obs.SendRequest("TriggerHotkeyByName", request); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] TriggerHotkeyByName for helper script failed"); } } - - public async Task> GetOverlayBrowserSourcesAsync(string serverPort) - { + + public async Task> GetOverlayBrowserSourcesAsync(string serverPort) { var cards = new List(); if (!_obs.IsConnected) return cards; - if (HelperScriptActive) - { + if (HelperScriptActive) { await TryApplyHelperTweaksAsync(); await Task.Delay(300); } bool adoptedAny = CollectBrowserSourceCards(serverPort, cards); - if (adoptedAny && HelperScriptActive) - { + if (adoptedAny && HelperScriptActive) { await TryApplyHelperTweaksAsync(); await Task.Delay(300); cards.Clear(); @@ -478,51 +431,44 @@ public async Task> GetOverlayBrowserSourcesAsync(stri return cards; } - private bool CollectBrowserSourceCards(string serverPort, List cards) - { - bool adoptedAny = false; + private bool CollectBrowserSourceCards(string serverPort, List cards) { + var adoptedAny = false; var settingsCache = new Dictionary(); var seenItems = new HashSet(); - foreach (var sceneName in GetScenes()) - { - adoptedAny |= CollectFromScene(sceneName, sceneName, isGroup: false, serverPort, + foreach (string sceneName in GetScenes()) + adoptedAny |= CollectFromScene(sceneName, sceneName, false, serverPort, cards, settingsCache, seenItems); - } return adoptedAny; } private bool CollectFromScene(string sceneName, string scenePath, bool isGroup, string serverPort, List cards, Dictionary settingsCache, - HashSet seenItems) - { - bool adoptedAny = false; + HashSet seenItems) { + var adoptedAny = false; JArray? items; - try - { - var response = _obs.SendRequest(isGroup ? "GetGroupSceneItemList" : "GetSceneItemList", + try { + JObject? response = _obs.SendRequest(isGroup ? "GetGroupSceneItemList" : "GetSceneItemList", new JObject { ["sceneName"] = sceneName }); items = response?["sceneItems"] as JArray; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogDebug(ex, "[OBSService] Failed to list scene items for '{Scene}'", sceneName); return false; } + if (items == null) return false; - foreach (var item in items) - { - if (item?["isGroup"]?.Value() == true) - { + foreach (var item in items) { + if (item?["isGroup"]?.Value() == true) { var groupName = item["sourceName"]?.ToString(); if (!string.IsNullOrEmpty(groupName)) - adoptedAny |= CollectFromScene(groupName, $"{scenePath} / {groupName}", isGroup: true, + adoptedAny |= CollectFromScene(groupName, $"{scenePath} / {groupName}", true, serverPort, cards, settingsCache, seenItems); continue; } - var inputKind = item?["inputKind"]?.ToString() ?? ""; + string inputKind = item?["inputKind"]?.ToString() ?? ""; if (!inputKind.StartsWith("browser_source")) continue; var sourceName = item?["sourceName"]?.ToString(); @@ -530,46 +476,42 @@ private bool CollectFromScene(string sceneName, string scenePath, bool isGroup, int itemId = item?["sceneItemId"]?.Value() ?? -1; bool visible = item?["sceneItemEnabled"]?.Value() ?? true; - if (!settingsCache.TryGetValue(sourceName, out var settings)) - { - try - { - var response = _obs.SendRequest("GetInputSettings", + if (!settingsCache.TryGetValue(sourceName, out JObject? settings)) { + try { + JObject? response = _obs.SendRequest("GetInputSettings", new JObject { ["inputName"] = sourceName }); settings = response?["inputSettings"] as JObject; } - catch { settings = null; } + catch { + settings = null; + } + settingsCache[sourceName] = settings; } + if (settings == null) continue; - var url = settings["url"]?.ToString() ?? ""; + string url = settings["url"]?.ToString() ?? ""; bool managed = settings[ManagedMarkerKey]?.Value() ?? false; - var routeId = TryParseRouteId(url, serverPort, out bool urlMatchesServer); + Guid? routeId = TryParseRouteId(url, serverPort, out bool urlMatchesServer); if (!managed && !urlMatchesServer) continue; if (!managed && urlMatchesServer) - { - try - { - _obs.SendRequest("SetInputSettings", new Newtonsoft.Json.Linq.JObject - { + try { + _obs.SendRequest("SetInputSettings", new JObject { ["inputName"] = sourceName, ["inputSettings"] = new JObject { [ManagedMarkerKey] = true }, ["overlay"] = true }); adoptedAny = true; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogDebug(ex, "[OBSService] Failed to adopt source '{Source}'", sourceName); } - } bool? srgbOff = null; - if (settings["subathon_blend_states"] is JObject states) - { + if (settings["subathon_blend_states"] is JObject states) { var state = states[$"{sceneName}|{itemId}"]?.ToString(); if (state == "srgb_off") srgbOff = true; else if (state == "default") srgbOff = false; @@ -587,105 +529,84 @@ private bool CollectFromScene(string sceneName, string scenePath, bool isGroup, return adoptedAny; } - private static Guid? TryParseRouteId(string url, string serverPort, out bool urlMatchesServer) - { + private static Guid? TryParseRouteId(string url, string serverPort, out bool urlMatchesServer) { urlMatchesServer = false; - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return null; + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) return null; if (uri.Port.ToString() != serverPort) return null; - var path = uri.AbsolutePath; + string path = uri.AbsolutePath; const string prefix = "/route/"; if (!path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null; urlMatchesServer = true; - var idPart = path[prefix.Length..].TrimEnd('/'); - return Guid.TryParse(idPart, out var id) ? id : null; + string idPart = path[prefix.Length..].TrimEnd('/'); + return Guid.TryParse(idPart, out Guid id) ? id : null; } - public void SetSceneItemVisible(string sceneName, int sceneItemId, bool visible) - { + public void SetSceneItemVisible(string sceneName, int sceneItemId, bool visible) { if (!_obs.IsConnected) return; - try - { - _obs.SendRequest("SetSceneItemEnabled", new Newtonsoft.Json.Linq.JObject - { + try { + _obs.SendRequest("SetSceneItemEnabled", new JObject { ["sceneName"] = sceneName, ["sceneItemId"] = sceneItemId, ["sceneItemEnabled"] = visible }); } - catch (Exception ex) - { - _logger?.LogWarning(ex, "[OBSService] SetSceneItemEnabled failed for '{Scene}'/{Id}", sceneName, sceneItemId); + catch (Exception ex) { + _logger?.LogWarning(ex, "[OBSService] SetSceneItemEnabled failed for '{Scene}'/{Id}", sceneName, + sceneItemId); } } - public void RefreshBrowserSource(string sourceName) - { + public void RefreshBrowserSource(string sourceName) { if (!_obs.IsConnected) return; - try - { - _obs.SendRequest("PressInputPropertiesButton", new JObject - { + try { + _obs.SendRequest("PressInputPropertiesButton", new JObject { ["inputName"] = sourceName, ["propertyName"] = "refreshnocache" }); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] RefreshBrowserSource failed for '{Source}'", sourceName); } } - public void RemoveBrowserSource(string sourceName) - { + public void RemoveBrowserSource(string sourceName) { if (!_obs.IsConnected) return; - try - { + try { _obs.SendRequest("RemoveInput", new JObject { ["inputName"] = sourceName }); BrowserSourcesChanged?.Invoke(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] RemoveInput failed for '{Source}'", sourceName); } } - public void SetBrowserSourceSize(string sourceName, int width, int height) - { + public void SetBrowserSourceSize(string sourceName, int width, int height) { if (!_obs.IsConnected) return; - try - { - _obs.SendRequest("SetInputSettings", new JObject - { + try { + _obs.SendRequest("SetInputSettings", new JObject { ["inputName"] = sourceName, - ["inputSettings"] = new JObject - { + ["inputSettings"] = new JObject { ["width"] = width, ["height"] = height }, ["overlay"] = true }); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] SetBrowserSourceSize failed for '{Source}'", sourceName); } } - public async Task RequestBlendMethodAsync(string sourceName, string sceneName, int sceneItemId, bool srgbOff) - { + public async Task RequestBlendMethodAsync(string sourceName, string sceneName, int sceneItemId, bool srgbOff) { if (!_obs.IsConnected) return; - try - { - _obs.SendRequest("SetInputSettings", new JObject - { + try { + _obs.SendRequest("SetInputSettings", new JObject { ["inputName"] = sourceName, - ["inputSettings"] = new JObject - { + ["inputSettings"] = new JObject { [ManagedMarkerKey] = true, - ["subathon_blend_request"] = new JObject - { + ["subathon_blend_request"] = new JObject { ["method"] = srgbOff ? "srgb_off" : "default", ["scene"] = sceneName, ["item"] = sceneItemId @@ -695,27 +616,24 @@ public async Task RequestBlendMethodAsync(string sourceName, string sceneName, i }); await TryApplyHelperTweaksAsync(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBSService] RequestBlendMethod failed for '{Source}'", sourceName); } } public async Task AddBrowserSource( string sourceName, string url, int width, int height, - string sceneName, bool fitToScreen = false) - { + string sceneName, bool fitToScreen = false) { if (!_obs.IsConnected) throw new InvalidOperationException("OBS is not connected"); - var existingInputs = _obs.GetInputList(); + List? existingInputs = _obs.GetInputList(); string finalName = sourceName; - int count = 1; + var count = 1; while (existingInputs.Any(i => i.InputName == finalName)) finalName = $"{sourceName} ({count++})"; - var settings = new JObject - { + var settings = new JObject { ["url"] = url, ["width"] = width, ["height"] = height, @@ -731,21 +649,16 @@ public async Task AddBrowserSource( _obs.CreateInput(sceneName, finalName, "browser_source", settings, true); await Task.Delay(500); if (fitToScreen) - { - try - { - var sceneItems = _obs.GetSceneItemList(sceneName); - var item = sceneItems.FirstOrDefault(si => si.SourceName == finalName); - if (item != null) - { - var videoSettings = _obs.GetVideoSettings(); - - var transformRequest = new JObject - { + try { + List? sceneItems = _obs.GetSceneItemList(sceneName); + SceneItemDetails? item = sceneItems.FirstOrDefault(si => si.SourceName == finalName); + if (item != null) { + ObsVideoSettings? videoSettings = _obs.GetVideoSettings(); + + var transformRequest = new JObject { ["sceneName"] = sceneName, ["sceneItemId"] = item.ItemId, - ["sceneItemTransform"] = new JObject - { + ["sceneItemTransform"] = new JObject { ["boundsType"] = "OBS_BOUNDS_SCALE_INNER", ["boundsWidth"] = (double)videoSettings.BaseWidth, ["boundsHeight"] = (double)videoSettings.BaseHeight, @@ -757,11 +670,9 @@ public async Task AddBrowserSource( _obs.SendRequest("SetSceneItemTransform", transformRequest); } } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[OBS] SetSceneItemTransform failed"); } - } await TryApplyHelperTweaksAsync(); BrowserSourcesChanged?.Invoke(); diff --git a/SubathonManager.Integration/PallyService.cs b/SubathonManager.Integration/PallyService.cs index 53d93bc8..258657ac 100644 --- a/SubathonManager.Integration/PallyService.cs +++ b/SubathonManager.Integration/PallyService.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; @@ -15,39 +16,35 @@ namespace SubathonManager.Integration; -public class PallyService : IAppService, IDisposable -{ +public class PallyService : IAppService, IDisposable { private const string ConfigSection = "PallyGG"; private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(60); - - internal string BaseUrl = "wss://events.pally.gg"; + private readonly IConfig _config; private readonly ILogger? _logger; - private readonly IConfig _config; private readonly ISecureStorage _secureStorage; - - private ClientWebSocket? _socket; private CancellationTokenSource? _cts; private Task? _runTask; - public bool Connected { get; private set; } + private ClientWebSocket? _socket; - private string? ApiKey => _secureStorage.GetOrDefault(StorageKeys.PallyApiKey, string.Empty); - private string Room => (_config.Get(ConfigSection, "Room", string.Empty) ?? string.Empty).Trim(); - private bool Enabled => _config.GetBool(ConfigSection, "Enabled", false); + internal string BaseUrl = "wss://events.pally.gg"; - public PallyService(ILogger? logger, IConfig config, ISecureStorage secureStorage) - { + public PallyService(ILogger? logger, IConfig config, ISecureStorage secureStorage) { _logger = logger; _config = config; _secureStorage = secureStorage; } - public Task StartAsync(CancellationToken ct = default) - { + public bool Connected { get; private set; } + + private string? ApiKey => _secureStorage.GetOrDefault(StorageKeys.PallyApiKey, string.Empty); + private string Room => (_config.Get(ConfigSection, "Room", string.Empty) ?? string.Empty).Trim(); + private bool Enabled => _config.GetBool(ConfigSection, "Enabled"); + + public Task StartAsync(CancellationToken ct = default) { BroadcastStatus(false); - if (!Enabled || string.IsNullOrWhiteSpace(ApiKey)) - { + if (!Enabled || string.IsNullOrWhiteSpace(ApiKey)) { _logger?.LogInformation("[PallyGG] Not configured or disabled. Integration inactive."); return Task.CompletedTask; } @@ -57,17 +54,16 @@ public Task StartAsync(CancellationToken ct = default) return Task.CompletedTask; } - public async Task StopAsync(CancellationToken ct = default) - { - try - { + public async Task StopAsync(CancellationToken ct = default) { + try { if (_cts != null) await _cts.CancelAsync(); if (_socket is { State: WebSocketState.Open }) await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Stopping", CancellationToken.None); } - catch { /**/ } - finally - { + catch { + /**/ + } + finally { _socket?.Dispose(); _socket = null; Connected = false; @@ -76,24 +72,30 @@ public async Task StopAsync(CancellationToken ct = default) } [ExcludeFromCodeCoverage] - public async Task RestartAsync() - { + public void Dispose() { + _cts?.Cancel(); + _socket?.Dispose(); + GC.SuppressFinalize(this); + } + + [ExcludeFromCodeCoverage] + public async Task RestartAsync() { await StopAsync(); await StartAsync(); } - public bool IsKeyEmpty() => string.IsNullOrWhiteSpace(ApiKey); + public bool IsKeyEmpty() { + return string.IsNullOrWhiteSpace(ApiKey); + } [ExcludeFromCodeCoverage] - public bool SaveConfig(string apiKey, string room) - { + public bool SaveConfig(string apiKey, string room) { bool updated = _secureStorage.Set(StorageKeys.PallyApiKey, apiKey.Trim()); updated |= _config.Set(ConfigSection, "Room", room.Trim()); return updated; } - internal Uri BuildUri() - { + internal Uri BuildUri() { string room = Room; string channel = string.IsNullOrWhiteSpace(room) ? "channel=firehose" @@ -102,13 +104,10 @@ internal Uri BuildUri() } [ExcludeFromCodeCoverage] - private async Task RunAsync(CancellationToken token) - { + private async Task RunAsync(CancellationToken token) { var reconnect = new Utils.ServiceReconnectState(TimeSpan.FromSeconds(2), 100, TimeSpan.FromMinutes(5)); - while (!token.IsCancellationRequested) - { - try - { + while (!token.IsCancellationRequested) { + try { _socket?.Dispose(); _socket = new ClientWebSocket(); await _socket.ConnectAsync(BuildUri(), token); @@ -121,17 +120,14 @@ private async Task RunAsync(CancellationToken token) await ListenAsync(_socket, token); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { break; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning("[PallyGG] Connection error: {Message}", ex.Message); } - if (Connected) - { + if (Connected) { Connected = false; BroadcastStatus(false); } @@ -139,8 +135,7 @@ private async Task RunAsync(CancellationToken token) if (token.IsCancellationRequested) break; reconnect.Retries++; - if (reconnect.Retries >= reconnect.MaxRetries) - { + if (reconnect.Retries >= reconnect.MaxRetries) { _logger?.LogError("[PallyGG] Max reconnect attempts reached. Please reconnect manually."); ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.PallyGG), "PallyGG.gg reconnect failed after maximum retries.", DateTime.Now); @@ -148,8 +143,13 @@ private async Task RunAsync(CancellationToken token) } _logger?.LogDebug("[PallyGG] Reconnecting in {Delay}s", reconnect.Backoff.TotalSeconds); - try { await Task.Delay(reconnect.Backoff, token); } - catch (OperationCanceledException) { break; } + try { + await Task.Delay(reconnect.Backoff, token); + } + catch (OperationCanceledException) { + break; + } + reconnect.Backoff = TimeSpan.FromMilliseconds(Math.Min( reconnect.Backoff.TotalMilliseconds * 2, reconnect.MaxBackoff.TotalMilliseconds)); } @@ -159,121 +159,111 @@ private async Task RunAsync(CancellationToken token) } [ExcludeFromCodeCoverage] - private async Task ListenAsync(ClientWebSocket socket, CancellationToken token) - { + private async Task ListenAsync(ClientWebSocket socket, CancellationToken token) { using var pingCts = CancellationTokenSource.CreateLinkedTokenSource(token); - var pingTask = Task.Run(async () => - { + Task pingTask = Task.Run(async () => { // keepalive - while (!pingCts.Token.IsCancellationRequested && socket.State == WebSocketState.Open) - { + while (!pingCts.Token.IsCancellationRequested && socket.State == WebSocketState.Open) { await Task.Delay(PingInterval, pingCts.Token); if (socket.State != WebSocketState.Open) break; - var ping = Encoding.UTF8.GetBytes("ping"); + byte[] ping = Encoding.UTF8.GetBytes("ping"); await socket.SendAsync(ping, WebSocketMessageType.Text, true, pingCts.Token); } }, pingCts.Token); var buffer = new byte[16 * 1024]; var messageBuilder = new StringBuilder(); - try - { - while (socket.State == WebSocketState.Open && !token.IsCancellationRequested) - { - var result = await socket.ReceiveAsync(buffer, token); - if (result.MessageType == WebSocketMessageType.Close) - { - await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Server closed", CancellationToken.None); + try { + while (socket.State == WebSocketState.Open && !token.IsCancellationRequested) { + WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, token); + if (result.MessageType == WebSocketMessageType.Close) { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Server closed", + CancellationToken.None); break; } messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); if (!result.EndOfMessage) continue; - string message = messageBuilder.ToString(); + var message = messageBuilder.ToString(); messageBuilder.Clear(); ProcessMessage(message); } } - finally - { + finally { await pingCts.CancelAsync(); - try { await pingTask; } catch { /**/ } + try { + await pingTask; + } + catch { + /**/ + } } } - public bool ProcessMessage(string message, bool simulated = false) - { + public bool ProcessMessage(string message, bool simulated = false) { if (string.IsNullOrWhiteSpace(message) || message == "pong") return false; - try - { - using var json = JsonDocument.Parse(message); - var root = json.RootElement; - if (!root.TryGetProperty("type", out var typeElem) + try { + using JsonDocument json = JsonDocument.Parse(message); + JsonElement root = json.RootElement; + if (!root.TryGetProperty("type", out JsonElement typeElem) || typeElem.GetString() != "campaigntip.notify") return false; - if (!root.TryGetProperty("payload", out var payload) - || !payload.TryGetProperty("campaignTip", out var tip)) return false; + if (!root.TryGetProperty("payload", out JsonElement payload) + || !payload.TryGetProperty("campaignTip", out JsonElement tip)) return false; - if (!tip.TryGetProperty("grossAmountInCents", out var grossElem) + if (!tip.TryGetProperty("grossAmountInCents", out JsonElement grossElem) || grossElem.ValueKind != JsonValueKind.Number) return false; double amount = grossElem.GetInt64() / 100.0; if (amount <= 0) return false; - string user = tip.TryGetProperty("displayName", out var nameElem) + string user = tip.TryGetProperty("displayName", out JsonElement nameElem) && !string.IsNullOrWhiteSpace(nameElem.GetString()) ? nameElem.GetString()! : "Anonymous"; - var subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { User = simulated ? "SYSTEM" : user, Currency = "USD", // USD only - Value = amount.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture), + Value = amount.ToString("0.00", CultureInfo.InvariantCulture), Source = simulated ? SubathonEventSource.Simulated : SubathonEventSource.PallyGG, EventType = SubathonEventType.PallyGGDonation }; - if (payload.TryGetProperty("page", out var page) - && page.TryGetProperty("slug", out var slugElem)) + if (payload.TryGetProperty("page", out JsonElement page) + && page.TryGetProperty("slug", out JsonElement slugElem)) subathonEvent.EventTypeMeta = slugElem.GetString(); - if (!simulated && tip.TryGetProperty("id", out var idElem) + if (!simulated && tip.TryGetProperty("id", out JsonElement idElem) && !string.IsNullOrWhiteSpace(idElem.GetString())) subathonEvent.Id = GuidFromString($"pallygg|{idElem.GetString()}"); SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return true; } - catch (JsonException) - { + catch (JsonException) { _logger?.LogDebug("[PallyGG] Ignored non-json message"); return false; } } - internal static Guid GuidFromString(string input) - { - var hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); + internal static Guid GuidFromString(string input) { + byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); return new Guid(hash); } - public static void SimulateTip(string value = "10.00") - { - if (!double.TryParse(value, out var val) || val <= 0) return; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + public static void SimulateTip(string value = "10.00") { + if (!double.TryParse(value, out double val) || val <= 0) return; + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { User = "SYSTEM", Currency = "USD", - Value = val.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture), + Value = val.ToString("0.00", CultureInfo.InvariantCulture), Source = SubathonEventSource.Simulated, EventType = SubathonEventType.PallyGGDonation }); } - private void BroadcastStatus(bool status) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastStatus(bool status) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.PallyGG, Service = "Socket", Name = Room, @@ -281,12 +271,4 @@ private void BroadcastStatus(bool status) Configured = Enabled && !string.IsNullOrWhiteSpace(ApiKey) }); } - - [ExcludeFromCodeCoverage] - public void Dispose() - { - _cts?.Cancel(); - _socket?.Dispose(); - GC.SuppressFinalize(this); - } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/PicartoService.cs b/SubathonManager.Integration/PicartoService.cs index 067bb6ea..4959b4f9 100644 --- a/SubathonManager.Integration/PicartoService.cs +++ b/SubathonManager.Integration/PicartoService.cs @@ -1,80 +1,95 @@ -using PicartoEventsLib.Clients; -using PicartoEventsLib.Internal; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; -using PicartoEventsLib.Options; using PicartoEventsLib.Abstractions.Models; -using SubathonManager.Core.Models; +using PicartoEventsLib.Clients; +using PicartoEventsLib.Internal; +using PicartoEventsLib.Options; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; -using SubathonManager.Services; -using System.Diagnostics.CodeAnalysis; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; +using SubathonManager.Services; namespace SubathonManager.Integration; -public class PicartoService : IDisposable, IAppService -{ - internal PicartoEventsClient? _eventClient; - internal PicartoChatClient? _chatClient; - - internal bool _disposed = false; - private string? _picartoUsername; - public bool Running; +public class PicartoService : IDisposable, IAppService { + private readonly ILogger? _chatLogger; private readonly Utils.ServiceReconnectState _chatReconnect = new(); - private readonly Utils.ServiceReconnectState _eventsReconnect = new(); - - private bool _eventsConnected; - private bool _chatConnected; - - private readonly ILogger? _logger; private readonly ILogger? _clientLogger; - private readonly ILogger? _chatLogger; private readonly IConfig _config; - + private readonly Utils.ServiceReconnectState _eventsReconnect = new(); + + private readonly ILogger? _logger; + internal PicartoChatClient? _chatClient; + private bool _chatConnected; + + internal bool _disposed; + internal PicartoEventsClient? _eventClient; + + private bool _eventsConnected; + private string? _picartoUsername; + internal PicartoClientOptions Opts = new(); - + public bool Running; + public PicartoService(ILogger? logger, IConfig config, ILogger? clientLogger, - ILogger? chatLogger) - { + ILogger? chatLogger) { _logger = logger; _config = config; _clientLogger = clientLogger; _chatLogger = chatLogger; } - public async Task StartAsync(CancellationToken ct = default) - { + public async Task StartAsync(CancellationToken ct = default) { await StartupServiceAsync(ct); } - - public Task StopAsync(CancellationToken ct = default) - { + + public Task StopAsync(CancellationToken ct = default) { Running = false; Dispose(); return Task.CompletedTask; } - - public async Task StartupServiceAsync(CancellationToken ct = default) - { + + public void Dispose() { + if (_disposed) return; + + _chatReconnect.Dispose(); + _eventsReconnect.Dispose(); + if (_eventClient != null) { + _eventClient.Disconnected -= OnDisconnect; + _eventClient.Connected -= OnConnect; + _eventClient.AlertReceived -= OnAlert; + _ = Task.Run(_eventClient.DisconnectAsync); + } + + if (_chatClient != null) { + _chatClient.Disconnected -= OnDisconnect; + _chatClient.Connected -= OnConnect; + _chatClient.MessageReceived -= OnChatMessage; + _ = Task.Run(_chatClient.DisconnectAsync); + } + + _disposed = true; + } + + public async Task StartupServiceAsync(CancellationToken ct = default) { Running = false; Opts.Channel = string.Empty; - + _picartoUsername = _config.Get("Picarto", "Username", string.Empty)!; bool configured = !string.IsNullOrWhiteSpace(_picartoUsername); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Chat", Name = _picartoUsername, Status = false, Configured = configured }); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Alerts", Name = _picartoUsername, @@ -105,118 +120,103 @@ public async Task StartupServiceAsync(CancellationToken ct = default) } [ExcludeFromCodeCoverage] - private void OnAlert(object? sender, PicartoAlert alert) - { + private void OnAlert(object? sender, PicartoAlert alert) { ProcessAlert(alert); } - public static void ProcessAlert(PicartoAlert alert) - { - SubathonEvent ev = new SubathonEvent - { + public static void ProcessAlert(PicartoAlert alert) { + var ev = new SubathonEvent { Source = alert.Username == "SYSTEM" ? SubathonEventSource.Simulated : SubathonEventSource.Picarto, EventTimestamp = alert.Timestamp.ToLocalTime(), User = alert.Username }; - if (alert is PicartoFollow follow) - { + if (alert is PicartoFollow follow) { ev.EventType = SubathonEventType.PicartoFollow; } - else if (alert is PicartoSubscription sub) - { + else if (alert is PicartoSubscription sub) { ev.EventType = sub.IsGift ? SubathonEventType.PicartoGiftSub : SubathonEventType.PicartoSub; ev.Currency = "sub"; ev.Value = $"T{sub.Tier}"; ev.EventTypeMeta = $"T{sub.Tier}"; ev.Amount = sub.GetMonths() * sub.Quantity; } - else if (alert is PicartoTip tip) - { + else if (alert is PicartoTip tip) { ev.Currency = "kudos"; ev.Value = $"{tip.Amount}"; ev.EventType = SubathonEventType.PicartoTip; } - else + else { return; + } + SubathonEvents.RaiseSubathonEventCreated(ev); } [ExcludeFromCodeCoverage] - private void OnChatMessage(object? sender, PicartoChatMessage chatMessage) - { + private void OnChatMessage(object? sender, PicartoChatMessage chatMessage) { ProcessChatMessage(chatMessage); } - internal static void ProcessChatMessage(PicartoChatMessage chatMessage) - { + internal static void ProcessChatMessage(PicartoChatMessage chatMessage) { if (string.IsNullOrWhiteSpace(chatMessage.Message) || !chatMessage.Message.StartsWith('!')) return; CommandService.ChatCommandRequest(SubathonEventSource.Picarto, chatMessage.Message, chatMessage.User.Username, chatMessage.User.IsBroadcaster, - false, false, + false, false, chatMessage.Timestamp.ToLocalTime(), chatMessage.MsgId); } - - public async Task UpdateChannel() - { + + public async Task UpdateChannel() { // call for connect/reconnect button - if (_eventClient == null && _chatClient == null) - { + if (_eventClient == null && _chatClient == null) { await StartupServiceAsync(); return; } - - var picartoUsername = _config.Get("Picarto", "Username")!; + + string picartoUsername = _config.Get("Picarto", "Username")!; // if (string.Equals(picartoUsername, _picartoUsername)) // return; - + _picartoUsername = picartoUsername; - if (string.IsNullOrWhiteSpace(_picartoUsername)) - { + if (string.IsNullOrWhiteSpace(_picartoUsername)) { if (_eventClient != null) await _eventClient.DisconnectAsync(); if (_chatClient != null) await _chatClient.DisconnectAsync(); return; } - + if (_eventClient != null) await _eventClient.ChangeChannel(_picartoUsername); if (_chatClient != null) await _chatClient.ChangeChannel(_picartoUsername); } - private void OnConnect(object? sender, PicartoWebSocketConnectedEventArgs args) - { - if (sender is PicartoChatClient) - { + private void OnConnect(object? sender, PicartoWebSocketConnectedEventArgs args) { + if (sender is PicartoChatClient) { _chatConnected = true; _chatReconnect.Reset(); _chatReconnect.Cts?.Cancel(); - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Chat", Name = _picartoUsername ?? "", Status = _chatConnected }); - } - else if (sender is PicartoEventsClient) - { + else if (sender is PicartoEventsClient) { _eventsConnected = true; _eventsReconnect.Reset(); _eventsReconnect.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Alerts", Name = _picartoUsername ?? "", - Status = _eventsConnected + Status = _eventsConnected }); } } @@ -225,37 +225,31 @@ private void OnConnect(object? sender, PicartoWebSocketConnectedEventArgs args) private async Task ReconnectWithBackoffAsync( PicartoWebSocketConnection client, Func isConnected, - Utils.ServiceReconnectState state) - { + Utils.ServiceReconnectState state) { if (!await state.Lock.WaitAsync(0)) return; - - try - { + + try { _logger?.LogInformation("Attempting to reconnect {Client}", client.GetType().Name); state.Cts?.Cancel(); state.Cts = new CancellationTokenSource(); - var token = state.Cts.Token; + CancellationToken token = state.Cts.Token; - while (!isConnected() && !token.IsCancellationRequested && !string.IsNullOrWhiteSpace(_picartoUsername)) - { - var delay = state.Backoff; + while (!isConnected() && !token.IsCancellationRequested && !string.IsNullOrWhiteSpace(_picartoUsername)) { + TimeSpan delay = state.Backoff; _logger?.LogDebug( "{Client} reconnect attempt in {Delay}s", client.GetType().Name, delay); - try - { + try { await Task.Delay(delay, token); await client.ReconnectAsync(); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, $"{client.GetType().Name} reconnection failed"); } @@ -264,10 +258,9 @@ private async Task ReconnectWithBackoffAsync( state.Backoff.TotalMilliseconds * 2, state.MaxBackoff.TotalMilliseconds)); state.Retries += 1; - if (state.Retries >= state.MaxRetries) - { + if (state.Retries >= state.MaxRetries) { _logger?.LogError("Max retries exceeded for Picarto connection. Please reconnect"); - + ErrorMessageEvents.RaiseErrorEvent( "ERROR", nameof(SubathonEventSource.Picarto), @@ -278,31 +271,27 @@ private async Task ReconnectWithBackoffAsync( } } } - finally - { + finally { state.Lock.Release(); } } - private void OnDisconnect(object? sender, PicartoWebSocketDisconnectedEventArgs args) - { + private void OnDisconnect(object? sender, PicartoWebSocketDisconnectedEventArgs args) { _logger?.LogWarning( "Picarto disconnected: {Sender} {Status} - {Description}", sender?.GetType().Name, args.CloseStatus, args.CloseDescription); - - var description = args.CloseDescription ?? string.Empty; + + string description = args.CloseDescription ?? string.Empty; bool shouldReconnect = args.Exception != null || (description != "Disconnect" && description != "Closing"); - if (sender is PicartoChatClient) - { + if (sender is PicartoChatClient) { _chatConnected = false; - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Chat", Name = _picartoUsername ?? "", @@ -310,19 +299,15 @@ private void OnDisconnect(object? sender, PicartoWebSocketDisconnectedEventArgs }); if (shouldReconnect && sender is PicartoWebSocketConnection ws) - { _ = Task.Run(() => ReconnectWithBackoffAsync( ws, () => _chatConnected, _chatReconnect)); - } } - else if (sender is PicartoEventsClient) - { + else if (sender is PicartoEventsClient) { _eventsConnected = false; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Picarto, Service = "Alerts", Name = _picartoUsername ?? "", @@ -330,38 +315,11 @@ private void OnDisconnect(object? sender, PicartoWebSocketDisconnectedEventArgs }); if (shouldReconnect && sender is PicartoWebSocketConnection ws) - { _ = Task.Run(() => ReconnectWithBackoffAsync( ws, () => _eventsConnected, _eventsReconnect)); - } } } - - public void Dispose() - { - if (_disposed) return; - - _chatReconnect.Dispose(); - _eventsReconnect.Dispose(); - if (_eventClient != null) - { - _eventClient.Disconnected -= OnDisconnect; - _eventClient.Connected -= OnConnect; - _eventClient.AlertReceived -= OnAlert; - _ = Task.Run(_eventClient.DisconnectAsync); - } - - if (_chatClient != null) - { - _chatClient.Disconnected -= OnDisconnect; - _chatClient.Connected -= OnConnect; - _chatClient.MessageReceived -= OnChatMessage; - _ = Task.Run(_chatClient.DisconnectAsync); - } - - _disposed = true; - } } \ No newline at end of file diff --git a/SubathonManager.Integration/StreamElementsService.cs b/SubathonManager.Integration/StreamElementsService.cs index 9dc9b7d9..47e4b63a 100644 --- a/SubathonManager.Integration/StreamElementsService.cs +++ b/SubathonManager.Integration/StreamElementsService.cs @@ -1,8 +1,8 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; using StreamElements.WebSocket; -using StreamElements.WebSocket.Models.Tip; using StreamElements.WebSocket.Models.Internal; +using StreamElements.WebSocket.Models.Tip; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -15,28 +15,25 @@ namespace SubathonManager.Integration; public class StreamElementsService(ILogger? logger, ISecureStorage secureStorage) - : IAppService -{ + : IAppService { + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(2), 50, TimeSpan.FromMinutes(5)); - private Func ClientFactory { get; set; } - = () => new StreamElementsClient(); - private StreamElementsClient? _client; - public bool Connected { get; private set; } = false; + private bool _hasAuthError; + + private Func ClientFactory { get; } + = () => new StreamElementsClient(); + + public bool Connected { get; private set; } private string? JwtToken => secureStorage.GetOrDefault(StorageKeys.StreamElementsJwt, string.Empty); - private bool _hasAuthError = false; - private readonly Utils.ServiceReconnectState _reconnectState = - new(TimeSpan.FromSeconds(2), maxRetries: 50, maxBackoff: TimeSpan.FromMinutes(5)); - - public Task StartAsync(CancellationToken cancellationToken = default) - { - try - { + + public Task StartAsync(CancellationToken cancellationToken = default) { + try { InitClient(); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogError(ex, "StreamElements could not start"); Disconnect(); } @@ -44,26 +41,21 @@ public Task StartAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } - public Task StopAsync(CancellationToken cancellationToken = default) - { + public Task StopAsync(CancellationToken cancellationToken = default) { Disconnect(); return Task.CompletedTask; } - - public bool InitClient() - { + + public bool InitClient() { _client = ClientFactory(); _hasAuthError = false; Connected = false; if (string.IsNullOrWhiteSpace(JwtToken)) return false; - - if (_client != null) - { - Disconnect(); - } + + if (_client != null) Disconnect(); _client = new StreamElementsClient(); - + _reconnectState.Reset(); _client.OnConnected += _OnConnected; _client.OnAuthenticated += _OnAuthenticated; @@ -74,59 +66,51 @@ public bool InitClient() return true; } - public bool IsTokenEmpty() - { + public bool IsTokenEmpty() { return !secureStorage.Exists(StorageKeys.StreamElementsJwt) || string.IsNullOrWhiteSpace(JwtToken); } - public bool SetJwtToken(string token) - { + public bool SetJwtToken(string token) { return secureStorage.Set(StorageKeys.StreamElementsJwt, token); } - - private void _OnConnected(object? sender, EventArgs e) - { + + private void _OnConnected(object? sender, EventArgs e) { logger?.LogInformation("[StreamElementsService] Connected"); _reconnectState.Reset(); _reconnectState.Cts?.Cancel(); } - private void _OnDisconnected(object? sender, EventArgs e) - { + + private void _OnDisconnected(object? sender, EventArgs e) { logger?.LogWarning("[StreamElementsService] Disconnected"); Connected = false; - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamElements, Service = "Socket", Name = "User", Status = Connected }); if (_hasAuthError) return; - + _ = Task.Run(ReconnectWithBackoffAsync); } [ExcludeFromCodeCoverage] - private async Task ReconnectWithBackoffAsync() - { + private async Task ReconnectWithBackoffAsync() { if (_client == null) return; if (!await _reconnectState.Lock.WaitAsync(0)) return; // already reconnecting - try - { + try { _reconnectState.Cts?.Cancel(); _reconnectState.Cts = new CancellationTokenSource(); - var token = _reconnectState.Cts.Token; + CancellationToken token = _reconnectState.Cts.Token; - while (!token.IsCancellationRequested && !Connected && !_hasAuthError) - { - if (_reconnectState.Retries >= _reconnectState.MaxRetries) - { - string message = + while (!token.IsCancellationRequested && !Connected && !_hasAuthError) { + if (_reconnectState.Retries >= _reconnectState.MaxRetries) { + var message = "StreamElements disconnected and max reconnect retries were reached."; ErrorMessageEvents.RaiseErrorEvent( @@ -141,7 +125,7 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.Retries++; - var delay = _reconnectState.Backoff; + TimeSpan delay = _reconnectState.Backoff; logger?.LogWarning( "[StreamElementsService] Reconnect attempt {Attempt}/{Max} in {Delay}s", @@ -149,25 +133,19 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.MaxRetries, delay.TotalSeconds); - try - { + try { await Task.Delay(delay, token); - if (!Connected && !_hasAuthError && _client != null) - { - _client.Connect(JwtToken); - } + if (!Connected && !_hasAuthError && _client != null) _client.Connect(JwtToken); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { return; } - catch (Exception ex) - { - string message = $"StreamElements Disconnected with an error. Could not auto-reconnect. {ex.Message}"; + catch (Exception ex) { + var message = $"StreamElements Disconnected with an error. Could not auto-reconnect. {ex.Message}"; logger?.LogWarning(ex, message); - ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.StreamElements), - message, DateTime.Now.ToLocalTime()); + ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.StreamElements), + message, DateTime.Now.ToLocalTime()); } // exponential backoff @@ -177,22 +155,19 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _reconnectState.Lock.Release(); } } - private void _OnAuthenticated(object? sender, Authenticated e) - { - logger?.LogDebug($"[StreamElementsService] Authenticated"); + private void _OnAuthenticated(object? sender, Authenticated e) { + logger?.LogDebug("[StreamElementsService] Authenticated"); Connected = true; _hasAuthError = false; _reconnectState.Cts?.Cancel(); - _reconnectState.Reset(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + _reconnectState.Reset(); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamElements, Service = "Socket", Name = "User", @@ -200,45 +175,39 @@ private void _OnAuthenticated(object? sender, Authenticated e) }); } - private void _OnAuthenticateError(object? sender, EventArgs e) - { + private void _OnAuthenticateError(object? sender, EventArgs e) { logger?.LogError("[StreamElementsService] Authentication Error"); Connected = false; _hasAuthError = true; - _reconnectState.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + _reconnectState.Cts?.Cancel(); + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamElements, Service = "Socket", Name = "User", Status = Connected }); - ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.StreamElements), + ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.StreamElements), "StreamElements Token could not be validated", DateTime.Now.ToLocalTime()); } - private void _OnTip(object? sender, Tip e) - { - SubathonEvent subathonEvent = new() - { + private void _OnTip(object? sender, Tip e) { + SubathonEvent subathonEvent = new() { User = e.Username, Currency = e.Currency, Value = $"{e.Amount}", Source = SubathonEventSource.StreamElements, EventType = SubathonEventType.StreamElementsDonation }; - if (Guid.TryParse(e.TipId, out var tipGuid)) + if (Guid.TryParse(e.TipId, out Guid tipGuid)) subathonEvent.Id = tipGuid; - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public void Disconnect() - { + public void Disconnect() { _reconnectState.Cts?.Cancel(); if (_client == null) return; - try - { + try { _client.Disconnect(); _client.OnConnected -= _OnConnected; _client.OnAuthenticated -= _OnAuthenticated; @@ -247,14 +216,11 @@ public void Disconnect() _client.OnAuthenticationFailure -= _OnAuthenticateError; _client = null; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[StreamElementsService] Disconnection Error"); } - finally - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + finally { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamElements, Service = "Socket", Name = "User", @@ -264,13 +230,11 @@ public void Disconnect() } } - public static void SimulateTip(string value = "10.00", string currency = "USD") - { - if (!double.TryParse(value, out var val)) + public static void SimulateTip(string value = "10.00", string currency = "USD") { + if (!double.TryParse(value, out double val)) return; - SubathonEvent subathonEvent = new() - { + SubathonEvent subathonEvent = new() { User = "SYSTEM", Currency = currency, Value = value, diff --git a/SubathonManager.Integration/StreamLabsService.cs b/SubathonManager.Integration/StreamLabsService.cs index c2c82900..13841b9e 100644 --- a/SubathonManager.Integration/StreamLabsService.cs +++ b/SubathonManager.Integration/StreamLabsService.cs @@ -1,8 +1,8 @@ -using Streamlabs.SocketClient; -using Streamlabs.SocketClient.Messages; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Streamlabs.SocketClient; +using Streamlabs.SocketClient.Messages; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -14,78 +14,68 @@ namespace SubathonManager.Integration; -public class StreamLabsService : IAppService -{ - internal Func ClientFactory; - private IStreamlabsClient? _client; - public bool Connected { get; private set; } = false; - +public class StreamLabsService : IAppService { private readonly ILogger? _logger; private readonly ISecureStorage _secureStorage; + private IStreamlabsClient? _client; internal string BaseUrl = "https://sockets.streamlabs.com"; - private string? SecretToken => _secureStorage.GetOrDefault(StorageKeys.StreamLabsSocketToken, string.Empty); + internal Func ClientFactory; - public StreamLabsService(ILogger? logger, ISecureStorage secureStorage) - { + public StreamLabsService(ILogger? logger, ISecureStorage secureStorage) { _logger = logger; _secureStorage = secureStorage; - ClientFactory = token => - { + ClientFactory = token => { var options = new OptionsWrapper( new StreamlabsOptions { Token = token, Url = BaseUrl }); return new StreamlabsClient( AppServices.Provider.GetRequiredService>(), options); }; } - - public async Task StartAsync(CancellationToken cancellationToken = default) - { + + public bool Connected { get; private set; } + private string? SecretToken => _secureStorage.GetOrDefault(StorageKeys.StreamLabsSocketToken, string.Empty); + + public async Task StartAsync(CancellationToken cancellationToken = default) { await InitClientAsync(); } - public async Task StopAsync(CancellationToken cancellationToken = default) - { + public async Task StopAsync(CancellationToken cancellationToken = default) { await DisconnectAsync(); } - + // todo cleanup all names in test for inits - public async Task InitClientAsync() - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + public async Task InitClientAsync() { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamLabs, Service = "Socket", Name = "User", Status = false, Configured = !IsTokenEmpty() }); - - if (string.IsNullOrWhiteSpace(SecretToken)) - { - Connected = false; + + if (string.IsNullOrWhiteSpace(SecretToken)) { + Connected = false; return false; } if (_client != null) await DisconnectAsync(); - + _client = ClientFactory(SecretToken); _client.OnDonation += OnDonation; - try - { + try { await _client.ConnectAsync(); Connected = true; } - catch (Exception ex) - { - string message = $"StreamLabs Service failed to connect: {ex.Message}"; + catch (Exception ex) { + var message = $"StreamLabs Service failed to connect: {ex.Message}"; _logger?.LogError(message); - ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), + ErrorMessageEvents.RaiseErrorEvent("ERROR", nameof(SubathonEventSource.Twitch), message, DateTime.Now); } - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamLabs, Service = "Socket", Name = "User", @@ -94,60 +84,52 @@ public async Task InitClientAsync() _logger?.LogInformation("StreamLabs Service Connected"); return Connected; } - - public bool IsTokenEmpty() - { + + public bool IsTokenEmpty() { return !_secureStorage.Exists(StorageKeys.StreamLabsSocketToken) || string.IsNullOrWhiteSpace(SecretToken); } - public bool SetSocketToken(string token) - { - return _secureStorage.Set(StorageKeys.StreamLabsSocketToken, token); + public bool SetSocketToken(string token) { + return _secureStorage.Set(StorageKeys.StreamLabsSocketToken, token); } - - private void OnDonation(object? o, DonationMessage message) - { - SubathonEvent subathonEvent = new() - { + + private void OnDonation(object? o, DonationMessage message) { + SubathonEvent subathonEvent = new() { User = message.Name, Currency = $"{message.Currency}".ToUpper(), Value = $"{message.Amount}", Source = SubathonEventSource.StreamLabs, EventType = SubathonEventType.StreamLabsDonation }; - if (Guid.TryParse(message.MessageId, out var tipGuid)) + if (Guid.TryParse(message.MessageId, out Guid tipGuid)) subathonEvent.Id = tipGuid; - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateTip(string value = "10.00", string currency = "USD") - { - if (!double.TryParse(value, out var val)) + + public static void SimulateTip(string value = "10.00", string currency = "USD") { + if (!double.TryParse(value, out double val)) return; - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { User = "SYSTEM", Currency = currency, Value = value, Source = SubathonEventSource.Simulated, EventType = SubathonEventType.StreamLabsDonation }; - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public async Task DisconnectAsync() - { + + public async Task DisconnectAsync() { if (!Connected) return; Connected = false; if (_client == null) return; - + _client.OnDonation -= OnDonation; await _client.DisconnectAsync(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.StreamLabs, Service = "Socket", Name = "User", diff --git a/SubathonManager.Integration/TangiaService.cs b/SubathonManager.Integration/TangiaService.cs index 11e178fd..06915ce9 100644 --- a/SubathonManager.Integration/TangiaService.cs +++ b/SubathonManager.Integration/TangiaService.cs @@ -7,32 +7,32 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; -using SubathonManager.Core.Objects; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; namespace SubathonManager.Integration; -public class TangiaService(ILogger? logger, IHttpClientFactory httpClientFactory, ISecureStorage secureStorage) : IAppService -{ +public class TangiaService( + ILogger? logger, + IHttpClientFactory httpClientFactory, + ISecureStorage secureStorage) : IAppService { private const string PollBase = "https://api.tangia.co/ack-hook/poll"; private const string PingBase = "https://api.tangia.co/ack-hook/ping"; private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(10); - private string? EventKey => secureStorage.GetOrDefault(StorageKeys.TangiaEventKey, string.Empty); - private CancellationTokenSource? _cts; private int _instanceId; - public Task StartAsync(CancellationToken ct = default) - { + private string? EventKey => secureStorage.GetOrDefault(StorageKeys.TangiaEventKey, string.Empty); + + public Task StartAsync(CancellationToken ct = default) { StopPolling(); - var key = EventKey; - if (string.IsNullOrWhiteSpace(key)) - { + string? key = EventKey; + if (string.IsNullOrWhiteSpace(key)) { logger?.LogInformation("[Tangia] Not configured. Integration disabled."); BroadcastStatus(false); return Task.CompletedTask; @@ -42,64 +42,61 @@ public Task StartAsync(CancellationToken ct = default) BroadcastStatus(true); _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - var token = _cts.Token; + CancellationToken token = _cts.Token; _ = Task.Run(() => PollLoopAsync(key, token), token); return Task.CompletedTask; } - public Task StopAsync(CancellationToken ct = default) - { + public Task StopAsync(CancellationToken ct = default) { StopPolling(); BroadcastStatus(false); return Task.CompletedTask; } - private void StopPolling() - { + private void StopPolling() { if (_cts is { IsCancellationRequested: false }) _cts.Cancel(); _cts = null; } [ExcludeFromCodeCoverage] - private async Task PollLoopAsync(string key, CancellationToken ct) - { + private async Task PollLoopAsync(string key, CancellationToken ct) { logger?.LogInformation("[Tangia] Polling started"); - while (!ct.IsCancellationRequested) - { - try - { + while (!ct.IsCancellationRequested) { + try { await PollOnceAsync(key, ct); } - catch (OperationCanceledException) { break; } - catch (Exception ex) - { + catch (OperationCanceledException) { + break; + } + catch (Exception ex) { logger?.LogWarning(ex, "[Tangia] Unexpected error during poll"); } - try { await Task.Delay(PollInterval, ct); } - catch (OperationCanceledException) { break; } + try { + await Task.Delay(PollInterval, ct); + } + catch (OperationCanceledException) { + break; + } } logger?.LogInformation("[Tangia] Polling stopped"); } - internal async Task PollOnceAsync(string key, CancellationToken ct) - { + internal async Task PollOnceAsync(string key, CancellationToken ct) { double cacheBust = Random.Shared.NextDouble(); - string url = $"{PollBase}?key={Uri.EscapeDataString(key)}&cacheBust={cacheBust:F16}&instance={_instanceId}"; + var url = $"{PollBase}?key={Uri.EscapeDataString(key)}&cacheBust={cacheBust:F16}&instance={_instanceId}"; - using var client = httpClientFactory.CreateClient(nameof(TangiaService)); + using HttpClient client = httpClientFactory.CreateClient(nameof(TangiaService)); client.Timeout = HttpTimeout; HttpResponseMessage response; - try - { + try { response = await client.GetAsync(url, ct); } - catch (TaskCanceledException) when (!ct.IsCancellationRequested) - { + catch (TaskCanceledException) when (!ct.IsCancellationRequested) { logger?.LogDebug("[Tangia] Poll request timed out"); return; } @@ -110,23 +107,20 @@ internal async Task PollOnceAsync(string key, CancellationToken ct) if (response.StatusCode == HttpStatusCode.NoContent) return; - if (!response.IsSuccessStatusCode) - { + if (!response.IsSuccessStatusCode) { logger?.LogWarning("[Tangia] Unexpected poll response: {Status}", response.StatusCode); return; } - var json = await response.Content.ReadAsStringAsync(ct); + string json = await response.Content.ReadAsStringAsync(ct); if (string.IsNullOrWhiteSpace(json)) return; TangiaResponse? payload; - try - { + try { payload = JsonSerializer.Deserialize(json); } - catch (JsonException ex) - { + catch (JsonException ex) { logger?.LogWarning(ex, "[Tangia] Failed to deserialize poll response"); return; } @@ -134,23 +128,21 @@ internal async Task PollOnceAsync(string key, CancellationToken ct) if (payload?.Events == null || payload.Events.Count == 0) return; - foreach (var ev in payload.Events) - { + foreach (TangiaEvent ev in payload.Events) { logger?.LogDebug("[Tangia] Event received: {Event}", JsonSerializer.Serialize(ev)); if (string.IsNullOrWhiteSpace(ev.EventId)) continue; if (ev.EventId.Contains("_cp_")) continue; if (ev.Data?.OverlayParams?.TriggerData?.Price <= 0) continue; if (ev.EventId.Contains("_test_")) continue; - + // var time = TimeSpan.FromMilliseconds(ev.UnixMs); - SubathonEvent sev = new SubathonEvent() - { + var sev = new SubathonEvent { User = ev.Data?.OverlayParams?.BuyerInfo?.Name ?? ev.Data?.OverlayParams?.Name ?? "Tangia User", Id = Utils.CreateGuidFromUniqueString(ev.EventId), Value = $"{ev.Data?.OverlayParams?.TriggerData?.Price}", Currency = "tokens", Source = SubathonEventSource.Tangia, - EventType = SubathonEventType.TangiaTokens, + EventType = SubathonEventType.TangiaTokens // EventTimestamp = new DateTime(time.Ticks).ToLocalTime(), // so apparently this isnt real unix, it's like, UTC+2 or something }; SubathonEvents.RaiseSubathonEventCreated(sev); @@ -159,21 +151,19 @@ internal async Task PollOnceAsync(string key, CancellationToken ct) [ExcludeFromCodeCoverage] [Obsolete("Unused and may not be required for functionality")] - private async Task PingAsync(string key, CancellationToken ct) - { - try - { - string url = $"{PingBase}?key={Uri.EscapeDataString(key)}&instance={_instanceId}¤tEvent=undefined"; - using var client = httpClientFactory.CreateClient(nameof(TangiaService)); - await client.PostAsync(url, content: null, ct); + private async Task PingAsync(string key, CancellationToken ct) { + try { + var url = $"{PingBase}?key={Uri.EscapeDataString(key)}&instance={_instanceId}¤tEvent=undefined"; + using HttpClient client = httpClientFactory.CreateClient(nameof(TangiaService)); + await client.PostAsync(url, null, ct); + } + catch { + /**/ } - catch { /**/ } } - public static void SimulateTangiaTokens(long amount) - { - var ev = new SubathonEvent - { + public static void SimulateTangiaTokens(long amount) { + var ev = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TangiaTokens, User = "SYSTEM", @@ -184,32 +174,27 @@ public static void SimulateTangiaTokens(long amount) SubathonEvents.RaiseSubathonEventCreated(ev); } - public static bool TryParseEventKey(string overlayUrl, out string eventKey) - { + public static bool TryParseEventKey(string overlayUrl, out string eventKey) { eventKey = string.Empty; if (string.IsNullOrWhiteSpace(overlayUrl)) return false; - try - { + try { var uri = new Uri(overlayUrl); - var path = uri.AbsolutePath; - var lastSegment = path.Split('/').LastOrDefault(s => !string.IsNullOrWhiteSpace(s)); + string path = uri.AbsolutePath; + string? lastSegment = path.Split('/').LastOrDefault(s => !string.IsNullOrWhiteSpace(s)); if (string.IsNullOrWhiteSpace(lastSegment) || !lastSegment.StartsWith("evt_")) return false; eventKey = lastSegment; return true; } - catch - { + catch { return false; } } - private void BroadcastStatus(bool connected) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastStatus(bool connected) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.Tangia, Service = nameof(SubathonEventSource.Tangia), Name = "", @@ -218,61 +203,45 @@ private void BroadcastStatus(bool connected) }); } - private class TangiaResponse - { - [JsonPropertyName("Events")] - public List? Events { get; set; } + private class TangiaResponse { + [JsonPropertyName("Events")] public List? Events { get; set; } } - internal class TangiaEvent - { - [JsonPropertyName("UnixMS")] - public long UnixMs { get; set; } + internal class TangiaEvent { + [JsonPropertyName("UnixMS")] public long UnixMs { get; set; } - [JsonPropertyName("Data")] - public TangiaEventData? Data { get; set; } + [JsonPropertyName("Data")] public TangiaEventData? Data { get; set; } - [JsonPropertyName("EventID")] - public string? EventId { get; set; } + [JsonPropertyName("EventID")] public string? EventId { get; set; } } - internal class TangiaEventData - { - [JsonPropertyName("type")] - public string? Type { get; set; } + internal class TangiaEventData { + [JsonPropertyName("type")] public string? Type { get; set; } - [JsonPropertyName("URL")] - public string? Url { get; set; } + [JsonPropertyName("URL")] public string? Url { get; set; } - [JsonPropertyName("OverlayParams")] - public TangiaOverlayParams? OverlayParams { get; set; } + [JsonPropertyName("OverlayParams")] public TangiaOverlayParams? OverlayParams { get; set; } } - internal class TangiaOverlayParams - { - - [JsonPropertyName("InteractionTitle")] - public string? InteractionTitle { get; set; } - [JsonPropertyName("TriggerData")] - public TangiaTriggerData? TriggerData { get; set; } - [JsonPropertyName("BuyerInfo")] - public TangiaBuyerInfo? BuyerInfo { get; set; } - [JsonPropertyName("duration")] - public long? Duration { get; set; } - [JsonPropertyName("name")] - public string? Name { get; set; } + internal class TangiaOverlayParams { + [JsonPropertyName("InteractionTitle")] public string? InteractionTitle { get; set; } + + [JsonPropertyName("TriggerData")] public TangiaTriggerData? TriggerData { get; set; } + + [JsonPropertyName("BuyerInfo")] public TangiaBuyerInfo? BuyerInfo { get; set; } + + [JsonPropertyName("duration")] public long? Duration { get; set; } + + [JsonPropertyName("name")] public string? Name { get; set; } } - internal class TangiaTriggerData - { - [JsonPropertyName("price")] - public int? Price { get; set; } + internal class TangiaTriggerData { + [JsonPropertyName("price")] public int? Price { get; set; } } - internal class TangiaBuyerInfo - { - [JsonPropertyName("has-plus")] - public bool? HasPlus { get; set; } - [JsonPropertyName("name")] - public string? Name { get; set; } + + internal class TangiaBuyerInfo { + [JsonPropertyName("has-plus")] public bool? HasPlus { get; set; } + + [JsonPropertyName("name")] public string? Name { get; set; } } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/ThroneService.cs b/SubathonManager.Integration/ThroneService.cs index d85e3bf2..716fd507 100644 --- a/SubathonManager.Integration/ThroneService.cs +++ b/SubathonManager.Integration/ThroneService.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -15,30 +16,26 @@ namespace SubathonManager.Integration; public class ThroneService(ILogger? logger, IConfig config, DevTunnelsService devTunnels) - : IWebhookIntegration -{ - private bool Enabled => config.GetBool("Throne", "Enabled", false); - public string WebhookPath => "/api/webhooks/throne"; - + : IWebhookIntegration { private const string ThronePublicKeyPem = """ -----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAPXbUfxh7XL4SYUVcfhmYMIbxvtR9E9LDd8gPJ1PwSD8= -----END PUBLIC KEY----- """; + private static readonly TimeSpan TimestampTolerance = TimeSpan.FromMinutes(10); + private bool Enabled => config.GetBool("Throne", "Enabled"); + public string WebhookPath => "/api/webhooks/throne"; - - public async Task StartAsync(CancellationToken ct = default) - { + + public async Task StartAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated += OnTunnelUpdated; - if (Enabled) - { + if (Enabled) { logger?.LogInformation("[Throne] Webhook listener ready at {Path}", WebhookPath); _ = devTunnels.StartTunnelAsync(ct); } - else - { + else { logger?.LogInformation("[Throne] Has not been enabled. Shutting down..."); BroadcastStatus(false, null); } @@ -46,55 +43,82 @@ public async Task StartAsync(CancellationToken ct = default) await Task.CompletedTask; } - public Task StopAsync(CancellationToken ct = default) - { + public Task StopAsync(CancellationToken ct = default) { IntegrationEvents.ConnectionUpdated -= OnTunnelUpdated; BroadcastStatus(false, null); - + return Task.CompletedTask; } - - - [ExcludeFromCodeCoverage] - public async Task Initialize(CancellationToken ct = default) - { - var tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); - if (!tunnelConn.Status) { - return; + + public Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, + CancellationToken ct = default) { + if (!Enabled) return Task.CompletedTask; + if (!headers.TryGetValue("X-Signature-Timestamp", out string? timestamp) + || string.IsNullOrWhiteSpace(timestamp) + || !long.TryParse(timestamp, out long timestampUnix)) { + logger?.LogWarning("[Throne] Rejected - missing or non-numeric X-Signature-Timestamp"); + return Task.CompletedTask; + } + + if (!headers.TryGetValue("X-Signature-Ed25519", out string? signatureHex) + || string.IsNullOrWhiteSpace(signatureHex)) { + logger?.LogWarning("[Throne] Rejected - missing X-Signature-Ed25519"); + return Task.CompletedTask; + } + + TimeSpan age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestampUnix); + if (age.Duration() > TimestampTolerance) { + logger?.LogWarning("[Throne] Rejected - timestamp age {Age:F0}s exceeds tolerance", age.TotalSeconds); + return Task.CompletedTask; + } + + string stringData = Encoding.UTF8.GetString(rawBody); + if (!SignatureVerification.VerifyEd25519Signature(stringData, timestamp, signatureHex, ThronePublicKeyPem)) { + logger?.LogWarning("[Throne] Rejected - invalid signature"); + return Task.CompletedTask; } - - if (!Enabled) - { + + logger?.LogDebug("[Throne] Signature verified OK"); + + ProcessData(stringData); + + return Task.CompletedTask; + } + + + [ExcludeFromCodeCoverage] + public async Task Initialize(CancellationToken ct = default) { + IntegrationConnection tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); + if (!tunnelConn.Status) return; + + if (!Enabled) { BroadcastStatus(false, null); return; } - + tunnelConn = Utils.GetConnection(SubathonEventSource.DevTunnels, "Tunnel"); BroadcastStatus(Enabled, tunnelConn.Status ? tunnelConn.Name : null); await Task.CompletedTask; } - + [ExcludeFromCodeCoverage] - private void OnTunnelUpdated(IntegrationConnection connection) - { + private void OnTunnelUpdated(IntegrationConnection connection) { if (connection is not { Source: SubathonEventSource.DevTunnels, Service: "Tunnel" }) return; - if (Enabled && connection.Status) - { + if (Enabled && connection.Status) { Task.Run(() => Initialize()); return; } + BroadcastStatus(Enabled, connection.Status ? connection.Name : null); } - - private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) - { + + private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) { string? fullUrl = !string.IsNullOrWhiteSpace(tunnelBaseUrl) && tunnelBaseUrl != "(starting...)" ? tunnelBaseUrl.TrimEnd('/') + WebhookPath : null; - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Name = fullUrl ?? "", Status = enabled && fullUrl != null, Source = SubathonEventSource.Throne, @@ -105,114 +129,77 @@ private void BroadcastStatus(bool enabled, string? tunnelBaseUrl) if (fullUrl != null) logger?.LogInformation("[Throne] Public webhook URL: {Url}", fullUrl); } - public Task HandleWebhookAsync(byte[] rawBody, IReadOnlyDictionary headers, CancellationToken ct = default) - { - if (!Enabled) return Task.CompletedTask; - if (!headers.TryGetValue("X-Signature-Timestamp", out var timestamp) - || string.IsNullOrWhiteSpace(timestamp) - || !long.TryParse(timestamp, out var timestampUnix)) - { - logger?.LogWarning("[Throne] Rejected - missing or non-numeric X-Signature-Timestamp"); - return Task.CompletedTask; - } - - if (!headers.TryGetValue("X-Signature-Ed25519", out var signatureHex) - || string.IsNullOrWhiteSpace(signatureHex)) - { - logger?.LogWarning("[Throne] Rejected - missing X-Signature-Ed25519"); - return Task.CompletedTask; - } - - var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestampUnix); - if (age.Duration() > TimestampTolerance) - { - logger?.LogWarning("[Throne] Rejected - timestamp age {Age:F0}s exceeds tolerance", age.TotalSeconds); - return Task.CompletedTask; - } - - var stringData = Encoding.UTF8.GetString(rawBody); - if (!SignatureVerification.VerifyEd25519Signature( stringData, timestamp, signatureHex, ThronePublicKeyPem)) - { - logger?.LogWarning("[Throne] Rejected - invalid signature"); - return Task.CompletedTask; - } - - logger?.LogDebug("[Throne] Signature verified OK"); - - ProcessData(stringData); - - return Task.CompletedTask; - } - public void ProcessData(string stringData, bool isSim = false) - { + public void ProcessData(string stringData, bool isSim = false) { var throneEvent = JsonConvert.DeserializeObject>(stringData); if (throneEvent == null) return; - throneEvent.TryGetValue("data", out var value); + throneEvent.TryGetValue("data", out object? value); if (value == null || string.IsNullOrWhiteSpace(value.ToString())) return; var data = JsonConvert.DeserializeObject>(value.ToString() ?? string.Empty)!; - throneEvent.TryGetValue("event_id", out var uuid); - data.TryGetValue("item_name", out var itemName); - data.TryGetValue("gifter_username", out var gifterName); - data.TryGetValue("currency", out var currency); + throneEvent.TryGetValue("event_id", out object? uuid); + data.TryGetValue("item_name", out object? itemName); + data.TryGetValue("gifter_username", out object? gifterName); + data.TryGetValue("currency", out object? currency); // data.TryGetValue("creator_username", out var creatorUsername); // bool.TryParse(isSurpriseRaw?.ToString(), out var isSurprise); // var username = creatorUsername?.ToString(); - - data.TryGetValue("is_surprise_gift", out var isSurpriseRaw); + + data.TryGetValue("is_surprise_gift", out object? isSurpriseRaw); if ((bool)(isSurpriseRaw ?? false)) itemName = "Surprise Gift"; - - SubathonEvent subathonEvent = new SubathonEvent - { + + var subathonEvent = new SubathonEvent { Id = Utils.TryParseGuid(uuid?.ToString()), SecondaryValue = itemName?.ToString() ?? "Item", User = gifterName?.ToString() ?? "Crowdfunding Complete!", Source = isSim ? SubathonEventSource.Simulated : SubathonEventSource.Throne }; - throneEvent.TryGetValue("event_type", out var eventType); + throneEvent.TryGetValue("event_type", out object? eventType); subathonEvent.TertiaryValue = itemName?.ToString() ?? "New Gift"; - switch (eventType?.ToString()) - { + switch (eventType?.ToString()) { case "gift_purchased": - var mode = config.GetOrderTypeMode("Throne", $"{SubathonEventType.ThroneGiftPurchase}", + OrderTypeModes mode = config.GetOrderTypeMode("Throne", $"{SubathonEventType.ThroneGiftPurchase}", OrderTypeModes.Dollar); - - data.TryGetValue("price", out var price); - double.TryParse(price?.ToString() ?? "0.00", out var priceInt); + + data.TryGetValue("price", out object? price); + double.TryParse(price?.ToString() ?? "0.00", out double priceInt); subathonEvent.EventType = SubathonEventType.ThroneGiftPurchase; - subathonEvent.Currency = mode == OrderTypeModes.Dollar && !string.IsNullOrWhiteSpace(currency!.ToString()) ? currency.ToString() : "item"; + subathonEvent.Currency = + mode == OrderTypeModes.Dollar && !string.IsNullOrWhiteSpace(currency!.ToString()) + ? currency.ToString() + : "item"; subathonEvent.Amount = 1; - subathonEvent.Value = mode != OrderTypeModes.Dollar ? itemName?.ToString() ?? "1" : - (priceInt / 100).ToString("F2", System.Globalization.CultureInfo.InvariantCulture); + subathonEvent.Value = mode != OrderTypeModes.Dollar + ? itemName?.ToString() ?? "1" + : (priceInt / 100).ToString("F2", CultureInfo.InvariantCulture); break; case "contribution_purchased": subathonEvent.EventType = SubathonEventType.ThroneGiftContribution; subathonEvent.Currency = currency?.ToString() ?? ""; - data.TryGetValue("amount", out var amount); + data.TryGetValue("amount", out object? amount); subathonEvent.TertiaryValue = itemName?.ToString() ?? "New Contribution"; - double.TryParse(amount?.ToString(), out var amountInt); + double.TryParse(amount?.ToString(), out double amountInt); subathonEvent.Value = - (amountInt / 100).ToString("F2", System.Globalization.CultureInfo.InvariantCulture); + (amountInt / 100).ToString("F2", CultureInfo.InvariantCulture); break; case "gift_crowdfunded": - data.TryGetValue("price", out var price2); - double.TryParse(price2?.ToString() ?? "0.00", out var priceInt2); + data.TryGetValue("price", out object? price2); + double.TryParse(price2?.ToString() ?? "0.00", out double priceInt2); subathonEvent.EventType = SubathonEventType.ThroneCrowdGiftComplete; subathonEvent.User = itemName?.ToString() ?? "Crowdfunding Complete!"; subathonEvent.Currency = currency?.ToString() ?? "item"; subathonEvent.Value = - (priceInt2 / 100).ToString("F2", System.Globalization.CultureInfo.InvariantCulture); + (priceInt2 / 100).ToString("F2", CultureInfo.InvariantCulture); break; default: return; } - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } } \ No newline at end of file diff --git a/SubathonManager.Integration/TipeeeStreamService.cs b/SubathonManager.Integration/TipeeeStreamService.cs index 6545eff7..38fa84d3 100644 --- a/SubathonManager.Integration/TipeeeStreamService.cs +++ b/SubathonManager.Integration/TipeeeStreamService.cs @@ -1,11 +1,13 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; -using SocketIOClient; using SocketIO.Core; -using SocketIOType = SocketIOClient.SocketIO; +using SocketIOClient; +using SocketIOClient.Transport; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -14,16 +16,31 @@ using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; +using SocketIOType = SocketIOClient.SocketIO; namespace SubathonManager.Integration; -public class TipeeeStreamService(ILogger? logger, IHttpClientFactory httpClientFactory, - ISecureStorage secureStorage, ITimerService timerService): IAppService -{ +public class TipeeeStreamService( + ILogger? logger, + IHttpClientFactory httpClientFactory, + ISecureStorage secureStorage, + ITimerService timerService) : IAppService { private const string ApiBase = "https://api.tipeeestream.com"; internal readonly string _oAuthUrl = "https://oauth.subathonmanager.app/auth/tipeeestream/login"; + + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(3), 50, TimeSpan.FromMinutes(5)); + internal readonly string _refreshUrl = "https://oauth.subathonmanager.app/auth/tipeeestream/refresh"; + private bool _apiKeyRetried; + private bool _connected; + private bool _hasAuthError; + private IDisposable? _refreshTimerHandle; + + private SocketIOType? _socket; + private CancellationTokenSource? _socketCts; + private string? _username = string.Empty; internal Action OpenBrowser = url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); @@ -31,24 +48,11 @@ public class TipeeeStreamService(ILogger? logger, IHttpClie private string? AccessToken => secureStorage.GetOrDefault(StorageKeys.TipeeeStreamAccessToken, string.Empty); private string? RefreshToken => secureStorage.GetOrDefault(StorageKeys.TipeeeStreamRefreshToken, string.Empty); private string? ApiKey => secureStorage.GetOrDefault(StorageKeys.TipeeeStreamApiKey, string.Empty); - private string? _username = string.Empty; - private SocketIOType? _socket; - private bool _connected; - private bool _hasAuthError; - private bool _apiKeyRetried; - private CancellationTokenSource? _socketCts; - private IDisposable? _refreshTimerHandle; - - private readonly Utils.ServiceReconnectState _reconnectState = - new(TimeSpan.FromSeconds(3), maxRetries: 50, maxBackoff: TimeSpan.FromMinutes(5)); - - public async Task StartAsync(CancellationToken ct = default) - { + public async Task StartAsync(CancellationToken ct = default) { Utils.PendingOAuthCallback = null; - if (!HasTokens()) - { + if (!HasTokens()) { logger?.LogInformation("[TipeeeStream] Not configured. Integration disabled."); BroadcastStatus(false); return; @@ -57,45 +61,46 @@ public async Task StartAsync(CancellationToken ct = default) await InitializeAsync(ct); } + public async Task StopAsync(CancellationToken ct = default) { + _refreshTimerHandle?.Dispose(); + _refreshTimerHandle = null; + await DisconnectAsync(); + BroadcastStatus(false); + } + [ExcludeFromCodeCoverage] - private async Task InitializeAsync(CancellationToken ct = default) - { - if (CheckExpiry()) - { - var refreshed = await RefreshAccessTokenAsync(ct); - if (!refreshed) - { + private async Task InitializeAsync(CancellationToken ct = default) { + if (CheckExpiry()) { + bool refreshed = await RefreshAccessTokenAsync(ct); + if (!refreshed) { RevokeTokens(); await StartOAuthFlowAsync(ct); - if (!HasTokens()) - { + if (!HasTokens()) { BroadcastStatus(false); return; } } } - if (string.IsNullOrWhiteSpace(_username)) - { - var username = await FetchUsernameAsync(ct); - if (string.IsNullOrWhiteSpace(username)) - { + if (string.IsNullOrWhiteSpace(_username)) { + string? username = await FetchUsernameAsync(ct); + if (string.IsNullOrWhiteSpace(username)) { logger?.LogWarning("[TipeeeStream] Could not fetch username"); BroadcastStatus(false); return; } + _username = username; } - if (string.IsNullOrWhiteSpace(ApiKey)) - { - var apiKey = await FetchApiKeyAsync(ct); - if (string.IsNullOrWhiteSpace(apiKey)) - { + if (string.IsNullOrWhiteSpace(ApiKey)) { + string? apiKey = await FetchApiKeyAsync(ct); + if (string.IsNullOrWhiteSpace(apiKey)) { logger?.LogWarning("[TipeeeStream] Could not fetch API key"); BroadcastStatus(false); return; } + secureStorage.Set(StorageKeys.TipeeeStreamApiKey, apiKey); } @@ -106,12 +111,10 @@ private async Task InitializeAsync(CancellationToken ct = default) _refreshTimerHandle = timerService.Register( $"{nameof(TipeeeStreamService)}.TokenRefresh", TimeSpan.FromMinutes(30), - async (token) => - { + async token => { if (!CheckExpiry()) return; - var ok = await RefreshAccessTokenAsync(token); - if (!ok) - { + bool ok = await RefreshAccessTokenAsync(token); + if (!ok) { logger?.LogWarning("[TipeeeStream] Periodic token refresh failed - disconnecting"); BroadcastStatus(false); await DisconnectAsync(); @@ -121,20 +124,10 @@ private async Task InitializeAsync(CancellationToken ct = default) await ConnectSocketAsync(ct); } - public async Task StopAsync(CancellationToken ct = default) - { - _refreshTimerHandle?.Dispose(); - _refreshTimerHandle = null; - await DisconnectAsync(); - BroadcastStatus(false); - } - [ExcludeFromCodeCoverage] - private async Task ConnectSocketAsync(CancellationToken ct = default) - { + private async Task ConnectSocketAsync(CancellationToken ct = default) { string? socketUrl = await GetSocketUrlAsync(ct); - if (string.IsNullOrWhiteSpace(socketUrl)) - { + if (string.IsNullOrWhiteSpace(socketUrl)) { logger?.LogWarning("[TipeeeStream] Could not retrieve socket URL"); BroadcastStatus(false); return; @@ -147,47 +140,40 @@ private async Task ConnectSocketAsync(CancellationToken ct = default) _hasAuthError = false; _connected = false; - _socket = new SocketIOType(socketUrl, new SocketIOOptions - { - Query = new List> - { + _socket = new SocketIOType(socketUrl, new SocketIOOptions { + Query = new List> { new("access_token", ApiKey!) }, EIO = EngineIO.V3, - Transport = SocketIOClient.Transport.TransportProtocol.WebSocket + Transport = TransportProtocol.WebSocket }); _socket.OnConnected += OnSocketConnected; _socket.OnDisconnected += OnSocketDisconnected; _socket.On("new-event", OnNewEvent); - try - { + try { await _socket.ConnectAsync(ct); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Initial socket connection failed"); _ = Task.Run(() => ReconnectWithBackoffAsync(_socketCts.Token), ct); } } [ExcludeFromCodeCoverage] - private async void OnSocketConnected(object? sender, EventArgs e) - { + private async void OnSocketConnected(object? sender, EventArgs e) { logger?.LogInformation("[TipeeeStream] Socket connected"); _connected = true; _hasAuthError = false; _reconnectState.Reset(); _reconnectState.Cts?.Cancel(); - try - { + try { await _socket!.EmitAsync("join-room", ApiKey, _username); logger?.LogDebug("[TipeeeStream] Joined room for {User}", _username); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to emit join-room"); } @@ -195,16 +181,14 @@ private async void OnSocketConnected(object? sender, EventArgs e) } [ExcludeFromCodeCoverage] - private void OnSocketDisconnected(object? sender, string reason) - { + private void OnSocketDisconnected(object? sender, string reason) { logger?.LogWarning("[TipeeeStream] Socket disconnected: {Reason}", reason); _connected = false; BroadcastStatus(false); if (_hasAuthError || _socketCts?.IsCancellationRequested == true) return; - if (string.Equals(reason, "io server disconnect", StringComparison.OrdinalIgnoreCase)) - { + if (string.Equals(reason, "io server disconnect", StringComparison.OrdinalIgnoreCase)) { _ = Task.Run(() => HandleApiKeyFailureAsync(_socketCts?.Token ?? CancellationToken.None)); return; } @@ -213,51 +197,44 @@ private void OnSocketDisconnected(object? sender, string reason) } [ExcludeFromCodeCoverage] - private void OnNewEvent(SocketIOResponse response) - { - try - { - HandleParsedEvent(response.GetValue(0)?.Event); - } - catch (Exception ex) - { + private void OnNewEvent(SocketIOResponse response) { + try { + HandleParsedEvent(response.GetValue()?.Event); + } + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to parse new-event"); } } - internal void ProcessEventJson(string json) - { - try - { + internal void ProcessEventJson(string json) { + try { var wrappers = JsonSerializer.Deserialize>(json); HandleParsedEvent(wrappers?.Count > 0 ? wrappers[0].Event : null); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to parse event JSON"); } } - private void HandleParsedEvent(TipeeeEvent? ev) - { + private void HandleParsedEvent(TipeeeEvent? ev) { if (ev?.Type is not ("donation" or "tipeee")) return; - var p = ev.Parameters; + TipeeeEventParameters? p = ev.Parameters; if (p == null) return; - var refStr = ev.Ref.ValueKind == JsonValueKind.String + string refStr = ev.Ref.ValueKind == JsonValueKind.String ? ev.Ref.GetString() ?? "" : ev.Ref.ValueKind == JsonValueKind.Number - ? ev.Ref.GetDouble().ToString(System.Globalization.CultureInfo.InvariantCulture) + ? ev.Ref.GetDouble().ToString(CultureInfo.InvariantCulture) : ""; if (refStr.StartsWith("TWITCH_", StringComparison.OrdinalIgnoreCase) || refStr.StartsWith("YOUTUBE_", StringComparison.OrdinalIgnoreCase)) return; - + // docs mention this but in practice it doesn't so, :/ - var isSimulation = refStr.StartsWith("simulation", StringComparison.OrdinalIgnoreCase); + bool isSimulation = refStr.StartsWith("simulation", StringComparison.OrdinalIgnoreCase); - var username = isSimulation + string? username = isSimulation ? "SYSTEM" : !string.IsNullOrWhiteSpace(p.Username) ? p.Username @@ -266,23 +243,24 @@ private void HandleParsedEvent(TipeeeEvent? ev) ?.Username ?? "TipeeeStream"; - var amount = p.Amount.ValueKind == JsonValueKind.String - ? double.TryParse(p.Amount.GetString(), System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var parsed) ? parsed : 0.0 + double amount = p.Amount.ValueKind == JsonValueKind.String + ? double.TryParse(p.Amount.GetString(), NumberStyles.Any, + CultureInfo.InvariantCulture, out double parsed) + ? parsed + : 0.0 : p.Amount.ValueKind == JsonValueKind.Number ? p.Amount.GetDouble() : 0.0; - var subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = Utils.CreateGuidFromUniqueString(refStr), Source = SubathonEventSource.TipeeeStream, EventType = SubathonEventType.TipeeeStreamDonation, User = username, - Value = amount.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + Value = amount.ToString("F2", CultureInfo.InvariantCulture), Currency = !string.IsNullOrWhiteSpace(p.Currency) ? p.Currency : "EUR", - EventTimestamp = DateTimeOffset.TryParse(ev.CreatedAt, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, out var ts) + EventTimestamp = DateTimeOffset.TryParse(ev.CreatedAt, CultureInfo.InvariantCulture, + DateTimeStyles.None, out DateTimeOffset ts) ? ts.LocalDateTime : DateTime.Now.ToLocalTime() }; @@ -291,21 +269,17 @@ private void HandleParsedEvent(TipeeeEvent? ev) } [ExcludeFromCodeCoverage] - private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) - { + private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) { if (!await _reconnectState.Lock.WaitAsync(0, ct)) return; - try - { + try { _reconnectState.Cts?.Cancel(); _reconnectState.Cts = new CancellationTokenSource(); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _reconnectState.Cts.Token); - var token = linked.Token; + CancellationToken token = linked.Token; - while (!token.IsCancellationRequested && !_connected && !_hasAuthError) - { - if (_reconnectState.Retries >= _reconnectState.MaxRetries) - { + while (!token.IsCancellationRequested && !_connected && !_hasAuthError) { + if (_reconnectState.Retries >= _reconnectState.MaxRetries) { var msg = "TipeeeStream disconnected and max reconnect retries were reached."; ErrorMessageEvents.RaiseErrorEvent( "ERROR", nameof(SubathonEventSource.TipeeeStream), msg, DateTime.Now.ToLocalTime()); @@ -314,20 +288,20 @@ private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) } _reconnectState.Retries++; - var delay = _reconnectState.Backoff; + TimeSpan delay = _reconnectState.Backoff; logger?.LogWarning("[TipeeeStream] Reconnect attempt {Attempt}/{Max} in {Delay}s", _reconnectState.Retries, _reconnectState.MaxRetries, delay.TotalSeconds); - try - { + try { await Task.Delay(delay, token); if (!_connected && _socket != null) await _socket.ConnectAsync(token); } - catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Reconnect attempt failed"); } @@ -337,63 +311,55 @@ private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) _reconnectState.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _reconnectState.Lock.Release(); } } [ExcludeFromCodeCoverage] - private async Task DisconnectAsync() - { + private async Task DisconnectAsync() { _socketCts?.Cancel(); _connected = false; _reconnectState.Cts?.Cancel(); - var s = _socket; + SocketIOType? s = _socket; _socket = null; if (s == null) return; - try - { + try { s.OnConnected -= OnSocketConnected; s.OnDisconnected -= OnSocketDisconnected; s.Off("new-event"); await s.DisconnectAsync(); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Disconnect error"); } - finally - { + finally { s.Dispose(); } } [ExcludeFromCodeCoverage] - public async Task ConnectAsync(CancellationToken ct = default) - { + public async Task ConnectAsync(CancellationToken ct = default) { await StopAsync(ct); await StartOAuthFlowAsync(ct); - if (!HasTokens()) - { + if (!HasTokens()) { BroadcastStatus(false); return; } + await InitializeAsync(ct); } - public static void SimulateDonation(string amount, string currency) - { - if (!double.TryParse(amount, System.Globalization.NumberStyles.Any, - System.Globalization.CultureInfo.InvariantCulture, out var amt)) return; - var ev = new SubathonEvent - { + public static void SimulateDonation(string amount, string currency) { + if (!double.TryParse(amount, NumberStyles.Any, + CultureInfo.InvariantCulture, out double amt)) return; + var ev = new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TipeeeStreamDonation, User = "SYSTEM", - Value = amt.ToString("F2", System.Globalization.CultureInfo.InvariantCulture), + Value = amt.ToString("F2", CultureInfo.InvariantCulture), Currency = !string.IsNullOrWhiteSpace(currency) ? currency : "EUR", EventTimestamp = DateTime.Now.ToLocalTime() }; @@ -401,39 +367,35 @@ public static void SimulateDonation(string amount, string currency) } [ExcludeFromCodeCoverage] - private bool CheckExpiry() - { + private bool CheckExpiry() { if (string.IsNullOrWhiteSpace(AccessToken)) return false; - var expires = Utils.GetAccessTokenExpiry(AccessToken); + DateTime? expires = Utils.GetAccessTokenExpiry(AccessToken); if (expires == null) return false; return DateTime.UtcNow >= expires.Value.AddSeconds(-60); } [ExcludeFromCodeCoverage] - private async Task RefreshAccessTokenAsync(CancellationToken ct = default) - { + private async Task RefreshAccessTokenAsync(CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(RefreshToken)) return false; logger?.LogDebug("[TipeeeStream] Refreshing access token..."); - try - { - using var client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); using var body = new FormUrlEncodedContent( [ new KeyValuePair("refresh_token", RefreshToken) ]); - var response = await client.PostAsync(_refreshUrl, body, ct); - if (!response.IsSuccessStatusCode) - { + HttpResponseMessage response = await client.PostAsync(_refreshUrl, body, ct); + if (!response.IsSuccessStatusCode) { logger?.LogWarning("[TipeeeStream] Token refresh failed ({Status})", response.StatusCode); return false; } - var json = await response.Content.ReadAsStringAsync(ct); + string json = await response.Content.ReadAsStringAsync(ct); var tokens = JsonSerializer.Deserialize>(json); - var newAccess = tokens?.GetValueOrDefault("access_token").GetString(); - var newRefresh = tokens?.GetValueOrDefault("refresh_token").GetString() ?? RefreshToken; + string? newAccess = tokens?.GetValueOrDefault("access_token").GetString(); + string? newRefresh = tokens?.GetValueOrDefault("refresh_token").GetString() ?? RefreshToken; if (string.IsNullOrWhiteSpace(newAccess)) return false; @@ -442,176 +404,159 @@ private async Task RefreshAccessTokenAsync(CancellationToken ct = default) logger?.LogDebug("[TipeeeStream] Tokens refreshed successfully"); return true; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Token refresh error"); return false; } } [ExcludeFromCodeCoverage] - private async Task StartOAuthFlowAsync(CancellationToken ct = default) - { + private async Task StartOAuthFlowAsync(CancellationToken ct = default) { RevokeTokens(); Utils.PendingOAuthCallback = null; logger?.LogDebug("[TipeeeStream] Opening OAuth..."); OpenBrowser(_oAuthUrl); - var (newAccess, newRefresh) = await WaitForProtocolCallbackAsync(ct); - if (!string.IsNullOrEmpty(newAccess) && !string.IsNullOrEmpty(newRefresh)) - { + (string? newAccess, string? newRefresh) = await WaitForProtocolCallbackAsync(ct); + if (!string.IsNullOrEmpty(newAccess) && !string.IsNullOrEmpty(newRefresh)) { secureStorage.Set(StorageKeys.TipeeeStreamAccessToken, newAccess); secureStorage.Set(StorageKeys.TipeeeStreamRefreshToken, newRefresh); logger?.LogInformation("[TipeeeStream] OAuth tokens stored"); } - else - { + else { logger?.LogWarning("[TipeeeStream] OAuth flow did not produce tokens"); } } - private async Task<(string?, string?)> WaitForProtocolCallbackAsync(CancellationToken ct = default) - { - var timeout = DateTime.Now.AddMinutes(15); - while (DateTime.Now < timeout && !ct.IsCancellationRequested) - { - var cb = Utils.PendingOAuthCallback; - if (cb?.Provider == "tipeeestream") - { + private async Task<(string?, string?)> WaitForProtocolCallbackAsync(CancellationToken ct = default) { + DateTime timeout = DateTime.Now.AddMinutes(15); + while (DateTime.Now < timeout && !ct.IsCancellationRequested) { + OAuthCallback? cb = Utils.PendingOAuthCallback; + if (cb?.Provider == "tipeeestream") { Utils.PendingOAuthCallback = null; - if (!string.IsNullOrEmpty(cb.Error)) - { + if (!string.IsNullOrEmpty(cb.Error)) { logger?.LogWarning("[TipeeeStream] OAuth error from callback: {Error}", cb.Error); return (null, null); } - if (!string.IsNullOrEmpty(cb.AccessToken) && !string.IsNullOrEmpty(cb.RefreshToken)) - { + + if (!string.IsNullOrEmpty(cb.AccessToken) && !string.IsNullOrEmpty(cb.RefreshToken)) { logger?.LogInformation("[TipeeeStream] OAuth callback received"); return (cb.AccessToken, cb.RefreshToken); } + logger?.LogWarning("[TipeeeStream] OAuth callback had no tokens and no error"); return (null, null); } + await Task.Delay(250, ct); } + logger?.LogWarning("[TipeeeStream] OAuth callback timed out"); return (null, null); } - private bool HasTokens() => - secureStorage.Exists(StorageKeys.TipeeeStreamAccessToken) - && secureStorage.Exists(StorageKeys.TipeeeStreamRefreshToken) - && !string.IsNullOrWhiteSpace(AccessToken) - && !string.IsNullOrWhiteSpace(RefreshToken); + private bool HasTokens() { + return secureStorage.Exists(StorageKeys.TipeeeStreamAccessToken) + && secureStorage.Exists(StorageKeys.TipeeeStreamRefreshToken) + && !string.IsNullOrWhiteSpace(AccessToken) + && !string.IsNullOrWhiteSpace(RefreshToken); + } - public void RevokeTokens() - { + public void RevokeTokens() { secureStorage.Delete(StorageKeys.TipeeeStreamAccessToken); secureStorage.Delete(StorageKeys.TipeeeStreamRefreshToken); secureStorage.Delete(StorageKeys.TipeeeStreamApiKey); } [ExcludeFromCodeCoverage] - private async Task FetchUsernameAsync(CancellationToken ct = default) - { - try - { - using var client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); + private async Task FetchUsernameAsync(CancellationToken ct = default) { + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", AccessToken); - var resp = await client.GetAsync($"{ApiBase}/v1.0/me", ct); - if (!resp.IsSuccessStatusCode) - { + new AuthenticationHeaderValue("Bearer", AccessToken); + HttpResponseMessage resp = await client.GetAsync($"{ApiBase}/v1.0/me", ct); + if (!resp.IsSuccessStatusCode) { logger?.LogWarning("[TipeeeStream] FetchUsername HTTP {Status}", resp.StatusCode); return null; } - var json = await resp.Content.ReadAsStringAsync(ct); + + string json = await resp.Content.ReadAsStringAsync(ct); logger?.LogDebug("[TipeeeStream] FetchUsername response: {Body}", json); - using var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("datas", out var datas) - && datas.TryGetProperty("username", out var un)) + using JsonDocument doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("datas", out JsonElement datas) + && datas.TryGetProperty("username", out JsonElement un)) return un.GetString(); - if (doc.RootElement.TryGetProperty("username", out var rootUn)) + if (doc.RootElement.TryGetProperty("username", out JsonElement rootUn)) return rootUn.GetString(); return null; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to fetch username"); return null; } } [ExcludeFromCodeCoverage] - private async Task FetchApiKeyAsync(CancellationToken ct = default) - { - try - { - using var client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); + private async Task FetchApiKeyAsync(CancellationToken ct = default) { + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", AccessToken); - var resp = await client.GetAsync($"{ApiBase}/v1.0/me/api", ct); - if (!resp.IsSuccessStatusCode) - { + new AuthenticationHeaderValue("Bearer", AccessToken); + HttpResponseMessage resp = await client.GetAsync($"{ApiBase}/v1.0/me/api", ct); + if (!resp.IsSuccessStatusCode) { logger?.LogWarning("[TipeeeStream] FetchApiKey HTTP {Status}", resp.StatusCode); return null; } - var json = await resp.Content.ReadAsStringAsync(ct); + + string json = await resp.Content.ReadAsStringAsync(ct); logger?.LogDebug("[TipeeeStream] FetchApiKey response: {Body}", json); - using var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("datas", out var datas) - && datas.TryGetProperty("apiKey", out var key)) + using JsonDocument doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("datas", out JsonElement datas) + && datas.TryGetProperty("apiKey", out JsonElement key)) return key.GetString(); - if (doc.RootElement.TryGetProperty("apiKey", out var rootKey)) + if (doc.RootElement.TryGetProperty("apiKey", out JsonElement rootKey)) return rootKey.GetString(); return null; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to fetch API key"); return null; } } [ExcludeFromCodeCoverage] - private async Task GetSocketUrlAsync(CancellationToken ct = default) - { - try - { - using var client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); - var resp = await client.GetAsync($"{ApiBase}/v2.0/site/socket", ct); - if (!resp.IsSuccessStatusCode) - { + private async Task GetSocketUrlAsync(CancellationToken ct = default) { + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TipeeeStreamService)); + HttpResponseMessage resp = await client.GetAsync($"{ApiBase}/v2.0/site/socket", ct); + if (!resp.IsSuccessStatusCode) { logger?.LogWarning("[TipeeeStream] GetSocketUrl HTTP {Status}", resp.StatusCode); return null; } - var json = await resp.Content.ReadAsStringAsync(ct); + + string json = await resp.Content.ReadAsStringAsync(ct); logger?.LogDebug("[TipeeeStream] GetSocketUrl response: {Body}", json); - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - var dataEl = root.TryGetProperty("datas", out var d) ? d : root; + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + JsonElement dataEl = root.TryGetProperty("datas", out JsonElement d) ? d : root; { - var host = dataEl.TryGetProperty("host", out var h) ? h.GetString() : null; - var port = dataEl.TryGetProperty("port", out var p) ? p.GetString() : null; - if (!string.IsNullOrWhiteSpace(host)) - { + string? host = dataEl.TryGetProperty("host", out JsonElement h) ? h.GetString() : null; + string? port = dataEl.TryGetProperty("port", out JsonElement p) ? p.GetString() : null; + if (!string.IsNullOrWhiteSpace(host)) { host = host.TrimEnd('/', ':'); return string.IsNullOrWhiteSpace(port) ? host : $"{host}:{port}"; } } return null; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TipeeeStream] Failed to get socket URL"); return null; } } [ExcludeFromCodeCoverage] - private async Task HandleApiKeyFailureAsync(CancellationToken ct = default) - { - if (_apiKeyRetried) - { + private async Task HandleApiKeyFailureAsync(CancellationToken ct = default) { + if (_apiKeyRetried) { logger?.LogError("[TipeeeStream] API key retry already attempted - giving up"); _hasAuthError = true; BroadcastStatus(false); @@ -625,9 +570,8 @@ private async Task HandleApiKeyFailureAsync(CancellationToken ct = default) logger?.LogWarning("[TipeeeStream] API key may be stale - re-fetching..."); secureStorage.Delete(StorageKeys.TipeeeStreamApiKey); - var newKey = await FetchApiKeyAsync(ct); - if (string.IsNullOrWhiteSpace(newKey)) - { + string? newKey = await FetchApiKeyAsync(ct); + if (string.IsNullOrWhiteSpace(newKey)) { logger?.LogWarning("[TipeeeStream] Re-fetch of API key failed"); _hasAuthError = true; BroadcastStatus(false); @@ -639,10 +583,8 @@ private async Task HandleApiKeyFailureAsync(CancellationToken ct = default) await ConnectSocketAsync(ct); } - private void BroadcastStatus(bool connected) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastStatus(bool connected) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.TipeeeStream, Service = nameof(SubathonEventSource.TipeeeStream), Name = connected ? _username ?? "User" : "", @@ -652,61 +594,42 @@ private void BroadcastStatus(bool connected) } - private class TipeeeWrapper - { - [JsonPropertyName("event")] - public TipeeeEvent? Event { get; set; } + private class TipeeeWrapper { + [JsonPropertyName("event")] public TipeeeEvent? Event { get; set; } } - private class TipeeeEvent - { - [JsonPropertyName("type")] - public string? Type { get; set; } + private class TipeeeEvent { + [JsonPropertyName("type")] public string? Type { get; set; } // "ref" can be a string or a float depending on the event source - [JsonPropertyName("ref")] - public JsonElement Ref { get; set; } + [JsonPropertyName("ref")] public JsonElement Ref { get; set; } // why is it diff between v1/v2 events... - [JsonPropertyName("created_at")] - public string? CreatedAt { get; set; } + [JsonPropertyName("created_at")] public string? CreatedAt { get; set; } - [JsonPropertyName("parameters")] - public TipeeeEventParameters? Parameters { get; set; } + [JsonPropertyName("parameters")] public TipeeeEventParameters? Parameters { get; set; } - [JsonPropertyName("user")] - public TipeeeEventUser? User { get; set; } + [JsonPropertyName("user")] public TipeeeEventUser? User { get; set; } } - private class TipeeeEventParameters - { - [JsonPropertyName("username")] - public string? Username { get; set; } + private class TipeeeEventParameters { + [JsonPropertyName("username")] public string? Username { get; set; } // "amount" can be a string or number depending on the event source - [JsonPropertyName("amount")] - public JsonElement Amount { get; set; } - - [JsonPropertyName("currency")] - public string? Currency { get; set; } + [JsonPropertyName("amount")] public JsonElement Amount { get; set; } + [JsonPropertyName("currency")] public string? Currency { get; set; } } - private class TipeeeEventUser - { - [JsonPropertyName("username")] - public string? Username { get; set; } + private class TipeeeEventUser { + [JsonPropertyName("username")] public string? Username { get; set; } - [JsonPropertyName("providers")] - public List? Providers { get; set; } + [JsonPropertyName("providers")] public List? Providers { get; set; } } - private class TipeeeEventProvider - { - [JsonPropertyName("code")] - public string? Code { get; set; } + private class TipeeeEventProvider { + [JsonPropertyName("code")] public string? Code { get; set; } - [JsonPropertyName("username")] - public string? Username { get; set; } + [JsonPropertyName("username")] public string? Username { get; set; } } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/TreatStreamService.cs b/SubathonManager.Integration/TreatStreamService.cs index e8800cb9..addd85b0 100644 --- a/SubathonManager.Integration/TreatStreamService.cs +++ b/SubathonManager.Integration/TreatStreamService.cs @@ -3,9 +3,9 @@ using System.Globalization; using System.Text.Json; using Microsoft.Extensions.Logging; -using SocketIOClient; using SocketIO.Core; -using SocketIOType = SocketIOClient.SocketIO; +using SocketIOClient; +using SocketIOClient.Transport; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -14,40 +14,53 @@ using SubathonManager.Core.Objects; using SubathonManager.Core.Security; using SubathonManager.Core.Security.Interfaces; +using SocketIOType = SocketIOClient.SocketIO; namespace SubathonManager.Integration; -public class TreatStreamService(ILogger? logger, IHttpClientFactory httpClientFactory, - ISecureStorage secureStorage, ITimerService timerService) : IAppService -{ - internal string SocketUrl = "https://nodeapi.treatstream.com/"; +public class TreatStreamService( + ILogger? logger, + IHttpClientFactory httpClientFactory, + ISecureStorage secureStorage, + ITimerService timerService) : IAppService { internal readonly string _oAuthUrl = "https://oauth.subathonmanager.app/auth/treatstream/login"; + + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(3), 50, TimeSpan.FromMinutes(5)); + internal readonly string _refreshUrl = "https://oauth.subathonmanager.app/auth/treatstream/refresh"; internal readonly string _socketTokenUrl = "https://treatstream.com/Oauth2/Authorize/socketToken"; + private bool _connected; + private bool _hasAuthError; + private IDisposable? _refreshTimerHandle; + + private SocketIOType? _socket; + private CancellationTokenSource? _socketCts; internal Action OpenBrowser = url => Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + internal string SocketUrl = "https://nodeapi.treatstream.com/"; + private string? AccessToken => secureStorage.GetOrDefault(StorageKeys.TreatStreamAccessToken, string.Empty); private string? RefreshToken => secureStorage.GetOrDefault(StorageKeys.TreatStreamRefreshToken, string.Empty); private string? ClientId => secureStorage.GetOrDefault(StorageKeys.TreatStreamClientId, string.Empty); - private SocketIOType? _socket; - private bool _connected; - private bool _hasAuthError; - private CancellationTokenSource? _socketCts; - private IDisposable? _refreshTimerHandle; - - private readonly Utils.ServiceReconnectState _reconnectState = - new(TimeSpan.FromSeconds(3), maxRetries: 50, maxBackoff: TimeSpan.FromMinutes(5)); + private DateTime? TokenExpiry { + get { + string? raw = secureStorage.GetOrDefault(StorageKeys.TreatStreamTokenExpiry, string.Empty); + return DateTime.TryParse(raw, CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime dt) + ? dt + : null; + } + } - public async Task StartAsync(CancellationToken ct = default) - { + public async Task StartAsync(CancellationToken ct = default) { Utils.PendingOAuthCallback = null; - if (!HasTokens()) - { + if (!HasTokens()) { logger?.LogInformation("[TreatStream] Not configured. Integration disabled."); BroadcastStatus(false); return; @@ -56,8 +69,7 @@ public async Task StartAsync(CancellationToken ct = default) await InitializeAsync(ct); } - public async Task StopAsync(CancellationToken ct = default) - { + public async Task StopAsync(CancellationToken ct = default) { _refreshTimerHandle?.Dispose(); _refreshTimerHandle = null; await DisconnectAsync(); @@ -65,17 +77,13 @@ public async Task StopAsync(CancellationToken ct = default) } [ExcludeFromCodeCoverage] - private async Task InitializeAsync(CancellationToken ct = default) - { - if (CheckExpiry()) - { - var refreshed = await RefreshAccessTokenAsync(ct); - if (!refreshed) - { + private async Task InitializeAsync(CancellationToken ct = default) { + if (CheckExpiry()) { + bool refreshed = await RefreshAccessTokenAsync(ct); + if (!refreshed) { RevokeTokens(); await StartOAuthFlowAsync(ct); - if (!HasTokens()) - { + if (!HasTokens()) { BroadcastStatus(false); return; } @@ -87,66 +95,50 @@ private async Task InitializeAsync(CancellationToken ct = default) } [ExcludeFromCodeCoverage] - public async Task ConnectAsync(CancellationToken ct = default) - { + public async Task ConnectAsync(CancellationToken ct = default) { await StopAsync(ct); await StartOAuthFlowAsync(ct); - if (!HasTokens()) - { + if (!HasTokens()) { BroadcastStatus(false); return; } - await InitializeAsync(ct); - } - private DateTime? TokenExpiry - { - get - { - var raw = secureStorage.GetOrDefault(StorageKeys.TreatStreamTokenExpiry, string.Empty); - return DateTime.TryParse(raw, CultureInfo.InvariantCulture, - DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var dt) ? dt : null; - } + await InitializeAsync(ct); } - internal void StoreExpiry(string? expiresIn) - { - double seconds = double.TryParse(expiresIn, NumberStyles.Any, CultureInfo.InvariantCulture, out var s) && s > 0 + internal void StoreExpiry(string? expiresIn) { + double seconds = double.TryParse(expiresIn, NumberStyles.Any, CultureInfo.InvariantCulture, out double s) && + s > 0 ? s : TimeSpan.FromDays(15).TotalSeconds; // docs say 30, but if it is unknown, be safe with 15 secureStorage.Set(StorageKeys.TreatStreamTokenExpiry, DateTime.UtcNow.AddSeconds(seconds).ToString("O", CultureInfo.InvariantCulture)); } - private bool CheckExpiry() - { + private bool CheckExpiry() { if (string.IsNullOrWhiteSpace(AccessToken)) return false; - var expires = TokenExpiry; + DateTime? expires = TokenExpiry; if (expires == null) return false; return DateTime.UtcNow >= expires.Value.AddHours(-1); } [ExcludeFromCodeCoverage] - private void RegisterRefreshTimer() - { + private void RegisterRefreshTimer() { _refreshTimerHandle?.Dispose(); - var untilRefresh = (TokenExpiry ?? DateTime.UtcNow.AddDays(30)) - DateTime.UtcNow - TimeSpan.FromHours(1); - var interval = TimeSpan.FromTicks(Math.Clamp(untilRefresh.Ticks, + TimeSpan untilRefresh = (TokenExpiry ?? DateTime.UtcNow.AddDays(30)) - DateTime.UtcNow - TimeSpan.FromHours(1); + TimeSpan interval = TimeSpan.FromTicks(Math.Clamp(untilRefresh.Ticks, TimeSpan.FromMinutes(5).Ticks, TimeSpan.FromHours(24).Ticks)); _refreshTimerHandle = timerService.Register( $"{nameof(TreatStreamService)}.TokenRefresh", interval, - async (token) => - { + async token => { if (!CheckExpiry()) return; - var ok = await RefreshAccessTokenAsync(token); - if (ok) - { + bool ok = await RefreshAccessTokenAsync(token); + if (ok) { RegisterRefreshTimer(); } - else - { + else { logger?.LogWarning("[TreatStream] Scheduled token refresh failed - disconnecting"); BroadcastStatus(false); await DisconnectAsync(); @@ -155,35 +147,35 @@ private void RegisterRefreshTimer() } [ExcludeFromCodeCoverage] - internal async Task RefreshAccessTokenAsync(CancellationToken ct = default) - { + internal async Task RefreshAccessTokenAsync(CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(RefreshToken)) return false; logger?.LogDebug("[TreatStream] Refreshing access token..."); - try - { - using var client = httpClientFactory.CreateClient(nameof(TreatStreamService)); + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TreatStreamService)); using var body = new FormUrlEncodedContent( [ new KeyValuePair("refresh_token", RefreshToken) ]); - var response = await client.PostAsync(_refreshUrl, body, ct); - if (!response.IsSuccessStatusCode) - { + HttpResponseMessage response = await client.PostAsync(_refreshUrl, body, ct); + if (!response.IsSuccessStatusCode) { logger?.LogWarning("[TreatStream] Token refresh failed ({Status})", response.StatusCode); return false; } - var json = await response.Content.ReadAsStringAsync(ct); + string json = await response.Content.ReadAsStringAsync(ct); var tokens = JsonSerializer.Deserialize>(json); var newAccess = tokens?.GetValueOrDefault("access_token").ToString(); - var newRefresh = tokens != null && tokens.TryGetValue("refresh_token", out var r) - ? r.ToString() : RefreshToken; - var expiresIn = tokens != null && tokens.TryGetValue("expires_in", out var e) - ? e.ToString() : null; - var clientId = tokens != null && tokens.TryGetValue("client_id", out var c) - ? c.ToString() : null; + string? newRefresh = tokens != null && tokens.TryGetValue("refresh_token", out JsonElement r) + ? r.ToString() + : RefreshToken; + string? expiresIn = tokens != null && tokens.TryGetValue("expires_in", out JsonElement e) + ? e.ToString() + : null; + string? clientId = tokens != null && tokens.TryGetValue("client_id", out JsonElement c) + ? c.ToString() + : null; if (string.IsNullOrWhiteSpace(newAccess)) return false; @@ -195,23 +187,20 @@ internal async Task RefreshAccessTokenAsync(CancellationToken ct = default logger?.LogDebug("[TreatStream] Tokens refreshed successfully"); return true; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Token refresh error"); return false; } } [ExcludeFromCodeCoverage] - private async Task StartOAuthFlowAsync(CancellationToken ct = default) - { + private async Task StartOAuthFlowAsync(CancellationToken ct = default) { RevokeTokens(); Utils.PendingOAuthCallback = null; logger?.LogDebug("[TreatStream] Opening OAuth..."); OpenBrowser(_oAuthUrl); - var cb = await WaitForProtocolCallbackAsync(ct); - if (cb != null && !string.IsNullOrEmpty(cb.AccessToken) && !string.IsNullOrEmpty(cb.RefreshToken)) - { + OAuthCallback? cb = await WaitForProtocolCallbackAsync(ct); + if (cb != null && !string.IsNullOrEmpty(cb.AccessToken) && !string.IsNullOrEmpty(cb.RefreshToken)) { secureStorage.Set(StorageKeys.TreatStreamAccessToken, cb.AccessToken); secureStorage.Set(StorageKeys.TreatStreamRefreshToken, cb.RefreshToken); if (!string.IsNullOrWhiteSpace(cb.ClientId)) @@ -219,44 +208,42 @@ private async Task StartOAuthFlowAsync(CancellationToken ct = default) StoreExpiry(cb.ExpiresIn); logger?.LogInformation("[TreatStream] OAuth tokens stored"); } - else - { + else { logger?.LogWarning("[TreatStream] OAuth flow did not produce tokens"); } } [ExcludeFromCodeCoverage] - private async Task WaitForProtocolCallbackAsync(CancellationToken ct = default) - { - var timeout = DateTime.Now.AddMinutes(15); - while (DateTime.Now < timeout && !ct.IsCancellationRequested) - { - var cb = Utils.PendingOAuthCallback; - if (cb?.Provider == "treatstream") - { + private async Task WaitForProtocolCallbackAsync(CancellationToken ct = default) { + DateTime timeout = DateTime.Now.AddMinutes(15); + while (DateTime.Now < timeout && !ct.IsCancellationRequested) { + OAuthCallback? cb = Utils.PendingOAuthCallback; + if (cb?.Provider == "treatstream") { Utils.PendingOAuthCallback = null; - if (!string.IsNullOrEmpty(cb.Error)) - { + if (!string.IsNullOrEmpty(cb.Error)) { logger?.LogWarning("[TreatStream] OAuth error from callback: {Error}", cb.Error); return null; } + return cb; } + await Task.Delay(250, ct); } + logger?.LogWarning("[TreatStream] OAuth callback timed out"); return null; } - public bool HasTokens() => - secureStorage.Exists(StorageKeys.TreatStreamAccessToken) - && secureStorage.Exists(StorageKeys.TreatStreamRefreshToken) - && !string.IsNullOrWhiteSpace(AccessToken) - && !string.IsNullOrWhiteSpace(RefreshToken); + public bool HasTokens() { + return secureStorage.Exists(StorageKeys.TreatStreamAccessToken) + && secureStorage.Exists(StorageKeys.TreatStreamRefreshToken) + && !string.IsNullOrWhiteSpace(AccessToken) + && !string.IsNullOrWhiteSpace(RefreshToken); + } [ExcludeFromCodeCoverage] - public void RevokeTokens() - { + public void RevokeTokens() { secureStorage.Delete(StorageKeys.TreatStreamAccessToken); secureStorage.Delete(StorageKeys.TreatStreamRefreshToken); secureStorage.Delete(StorageKeys.TreatStreamTokenExpiry); @@ -264,47 +251,41 @@ public void RevokeTokens() } [ExcludeFromCodeCoverage] - internal async Task FetchSocketTokenAsync(CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(ClientId)) - { + internal async Task FetchSocketTokenAsync(CancellationToken ct = default) { + if (string.IsNullOrWhiteSpace(ClientId)) { logger?.LogWarning("[TreatStream] No stored client_id - reconnect via OAuth to fetch it"); return null; } - try - { - using var client = httpClientFactory.CreateClient(nameof(TreatStreamService)); + try { + using HttpClient client = httpClientFactory.CreateClient(nameof(TreatStreamService)); using var body = new FormUrlEncodedContent( [ new KeyValuePair("client_id", ClientId!), new KeyValuePair("access_token", AccessToken ?? "") ]); - var resp = await client.PostAsync(_socketTokenUrl, body, ct); - if (!resp.IsSuccessStatusCode) - { + HttpResponseMessage resp = await client.PostAsync(_socketTokenUrl, body, ct); + if (!resp.IsSuccessStatusCode) { logger?.LogWarning("[TreatStream] FetchSocketToken HTTP {Status}", resp.StatusCode); return null; } - var json = await resp.Content.ReadAsStringAsync(ct); - using var doc = JsonDocument.Parse(json); - return doc.RootElement.TryGetProperty("socket_token", out var tok) + + string json = await resp.Content.ReadAsStringAsync(ct); + using JsonDocument doc = JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty("socket_token", out JsonElement tok) ? tok.ToString() : null; } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Failed to fetch socket token"); return null; } } [ExcludeFromCodeCoverage] - private async Task ConnectSocketAsync(CancellationToken ct = default) - { - var socketToken = await FetchSocketTokenAsync(ct); - if (string.IsNullOrWhiteSpace(socketToken)) - { + private async Task ConnectSocketAsync(CancellationToken ct = default) { + string? socketToken = await FetchSocketTokenAsync(ct); + if (string.IsNullOrWhiteSpace(socketToken)) { logger?.LogWarning("[TreatStream] Could not retrieve socket token"); BroadcastStatus(false); _ = Task.Run(() => ReconnectWithBackoffAsync(_socketCts?.Token ?? ct), ct); @@ -317,34 +298,29 @@ private async Task ConnectSocketAsync(CancellationToken ct = default) _hasAuthError = false; _connected = false; - _socket = new SocketIOType(SocketUrl, new SocketIOOptions - { - Query = new List> - { + _socket = new SocketIOType(SocketUrl, new SocketIOOptions { + Query = new List> { new("token", socketToken) }, EIO = EngineIO.V3, - Transport = SocketIOClient.Transport.TransportProtocol.WebSocket + Transport = TransportProtocol.WebSocket }); _socket.OnConnected += OnSocketConnected; _socket.OnDisconnected += OnSocketDisconnected; _socket.On("realTimeTreat", OnRealTimeTreat); - try - { + try { await _socket.ConnectAsync(ct); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Initial socket connection failed"); _ = Task.Run(() => ReconnectWithBackoffAsync(_socketCts.Token), ct); } } [ExcludeFromCodeCoverage] - private void OnSocketConnected(object? sender, EventArgs e) - { + private void OnSocketConnected(object? sender, EventArgs e) { logger?.LogInformation("[TreatStream] Socket connected"); _connected = true; _hasAuthError = false; @@ -354,8 +330,7 @@ private void OnSocketConnected(object? sender, EventArgs e) } [ExcludeFromCodeCoverage] - private void OnSocketDisconnected(object? sender, string reason) - { + private void OnSocketDisconnected(object? sender, string reason) { logger?.LogWarning("[TreatStream] Socket disconnected: {Reason}", reason); _connected = false; BroadcastStatus(false); @@ -365,21 +340,17 @@ private void OnSocketDisconnected(object? sender, string reason) } [ExcludeFromCodeCoverage] - private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) - { + private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) { if (!await _reconnectState.Lock.WaitAsync(0, ct)) return; - try - { + try { _reconnectState.Cts?.Cancel(); _reconnectState.Cts = new CancellationTokenSource(); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _reconnectState.Cts.Token); - var token = linked.Token; + CancellationToken token = linked.Token; - while (!token.IsCancellationRequested && !_connected && !_hasAuthError) - { - if (_reconnectState.Retries >= _reconnectState.MaxRetries) - { + while (!token.IsCancellationRequested && !_connected && !_hasAuthError) { + if (_reconnectState.Retries >= _reconnectState.MaxRetries) { var msg = "TreatStream disconnected and max reconnect retries were reached."; ErrorMessageEvents.RaiseErrorEvent( "ERROR", nameof(SubathonEventSource.TreatStream), msg, DateTime.Now.ToLocalTime()); @@ -388,24 +359,21 @@ private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) } _reconnectState.Retries++; - var delay = _reconnectState.Backoff; + TimeSpan delay = _reconnectState.Backoff; logger?.LogWarning("[TreatStream] Reconnect attempt {Attempt}/{Max} in {Delay}s", _reconnectState.Retries, _reconnectState.MaxRetries, delay.TotalSeconds); - try - { + try { await Task.Delay(delay, token); if (CheckExpiry()) await RefreshAccessTokenAsync(token); - var socketToken = await FetchSocketTokenAsync(token); - if (!string.IsNullOrWhiteSpace(socketToken) && !_connected && _socket != null) - { + string? socketToken = await FetchSocketTokenAsync(token); + if (!string.IsNullOrWhiteSpace(socketToken) && !_connected && _socket != null) { await DisconnectSocketOnlyAsync(); - _socket = new SocketIOType(SocketUrl, new SocketIOOptions - { + _socket = new SocketIOType(SocketUrl, new SocketIOOptions { Query = new List> { new("token", socketToken) }, EIO = EngineIO.V3, - Transport = SocketIOClient.Transport.TransportProtocol.WebSocket + Transport = TransportProtocol.WebSocket }); _socket.OnConnected += OnSocketConnected; _socket.OnDisconnected += OnSocketDisconnected; @@ -413,9 +381,10 @@ private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) await _socket.ConnectAsync(token); } } - catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Reconnect attempt failed"); } @@ -425,37 +394,31 @@ private async Task ReconnectWithBackoffAsync(CancellationToken ct = default) _reconnectState.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _reconnectState.Lock.Release(); } } [ExcludeFromCodeCoverage] - private async Task DisconnectSocketOnlyAsync() - { - var s = _socket; + private async Task DisconnectSocketOnlyAsync() { + SocketIOType? s = _socket; _socket = null; if (s == null) return; - try - { + try { s.OnConnected -= OnSocketConnected; s.OnDisconnected -= OnSocketDisconnected; s.Off("realTimeTreat"); await s.DisconnectAsync(); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Disconnect error"); } - finally - { + finally { s.Dispose(); } } - private async Task DisconnectAsync() - { + private async Task DisconnectAsync() { _socketCts?.Cancel(); _connected = false; _reconnectState.Cts?.Cancel(); @@ -463,41 +426,36 @@ private async Task DisconnectAsync() } [ExcludeFromCodeCoverage] - private void OnRealTimeTreat(SocketIOResponse response) - { - try - { - ProcessTreatJson(response.GetValue(0).GetRawText()); + private void OnRealTimeTreat(SocketIOResponse response) { + try { + ProcessTreatJson(response.GetValue().GetRawText()); } - catch (Exception ex) - { + catch (Exception ex) { logger?.LogWarning(ex, "[TreatStream] Failed to parse realTimeTreat"); } } - internal bool ProcessTreatJson(string json, bool simulated = false) - { - try - { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + internal bool ProcessTreatJson(string json, bool simulated = false) { + try { + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object) return false; - string? title = root.TryGetProperty("title", out var titleEl) ? titleEl.GetString() : null; + string? title = root.TryGetProperty("title", out JsonElement titleEl) ? titleEl.GetString() : null; if (string.IsNullOrWhiteSpace(title)) return false; - string sender = root.TryGetProperty("sender", out var senderEl) + string sender = root.TryGetProperty("sender", out JsonElement senderEl) && !string.IsNullOrWhiteSpace(senderEl.GetString()) ? senderEl.GetString()! : "TreatStream"; - string dateCreated = root.TryGetProperty("date_created", out var dateEl) - ? dateEl.GetString() ?? "" : ""; + string dateCreated = root.TryGetProperty("date_created", out JsonElement dateEl) + ? dateEl.GetString() ?? "" + : ""; bool isSystem = simulated || sender == "SYSTEM"; - var subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Id = Utils.CreateGuidFromUniqueString($"treatstream|{title}|{dateCreated}"), Source = isSystem ? SubathonEventSource.Simulated : SubathonEventSource.TreatStream, EventType = SubathonEventType.TreatStreamOrder, @@ -507,7 +465,7 @@ internal bool ProcessTreatJson(string json, bool simulated = false) Value = title, TertiaryValue = title, EventTimestamp = DateTimeOffset.TryParse(dateCreated, CultureInfo.InvariantCulture, - DateTimeStyles.None, out var ts) + DateTimeStyles.None, out DateTimeOffset ts) ? ts.LocalDateTime : DateTime.Now.ToLocalTime() }; @@ -515,18 +473,15 @@ internal bool ProcessTreatJson(string json, bool simulated = false) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return true; } - catch (JsonException ex) - { + catch (JsonException ex) { logger?.LogWarning(ex, "[TreatStream] Invalid treat JSON"); return false; } } - public static void SimulateTreat(string title = "Fancy Treat") - { + public static void SimulateTreat(string title = "Fancy Treat") { if (string.IsNullOrWhiteSpace(title)) title = "Fancy Treat"; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Source = SubathonEventSource.Simulated, EventType = SubathonEventType.TreatStreamOrder, User = "SYSTEM", @@ -538,10 +493,8 @@ public static void SimulateTreat(string title = "Fancy Treat") }); } - private void BroadcastStatus(bool connected) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + private void BroadcastStatus(bool connected) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.TreatStream, Service = "Socket", Name = connected ? "TreatStream" : "", @@ -549,4 +502,4 @@ private void BroadcastStatus(bool connected) Configured = HasTokens() }); } -} +} \ No newline at end of file diff --git a/SubathonManager.Integration/YouTubeService.cs b/SubathonManager.Integration/YouTubeService.cs index 169e2f11..4caf0569 100644 --- a/SubathonManager.Integration/YouTubeService.cs +++ b/SubathonManager.Integration/YouTubeService.cs @@ -1,9 +1,5 @@ -using YTLiveChat.Contracts; -using YTLiveChat.Contracts.Models; -using YTLiveChat.Contracts.Services; -using YTLiveChat.Services; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Logging; -using System.Diagnostics.CodeAnalysis; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; @@ -11,81 +7,86 @@ using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Services; +using YTLiveChat.Contracts; +using YTLiveChat.Contracts.Models; +using YTLiveChat.Contracts.Services; +using YTLiveChat.Services; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; -public class YouTubeService : IDisposable, IAppService -{ - private int _pollTime = 1500; +public class YouTubeService : IDisposable, IAppService { + private readonly IConfig _config; private readonly HttpClient _httpClient; + + private readonly ILogger? _logger; + + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(5), 100, TimeSpan.FromMinutes(2)); + private readonly IYTLiveChat _ytLiveChat; - private bool _disposed = false; + + private int _canonicalLinkErrorCount; + private bool _disposed; + private readonly int _pollTime = 1500; private string? _ytHandle; public bool Running; - private readonly Utils.ServiceReconnectState _reconnectState = - new(TimeSpan.FromSeconds(5), maxRetries: 100, maxBackoff: TimeSpan.FromMinutes(2)); - - private readonly ILogger? _logger; - private readonly IConfig _config; - - private int _canonicalLinkErrorCount = 0; - - public YouTubeService(ILogger? logger, IConfig config, ILogger? httpClientLogger, ILogger? chatLogger) - { + public YouTubeService(ILogger? logger, IConfig config, ILogger? httpClientLogger, + ILogger? chatLogger) { #pragma warning disable CS0618 - + _logger = logger; _config = config; - var options = new YTLiveChatOptions - { + var options = new YTLiveChatOptions { RequestFrequency = _pollTime, EnableContinuousLivestreamMonitor = true, // having upcoming streams will report as true LiveCheckFrequency = 10 * 1000 }; - - _httpClient ??= new HttpClient() - { + + _httpClient ??= new HttpClient { BaseAddress = new Uri(options.YoutubeBaseUrl) }; - + var ytHttpClient = new YTHttpClient(_httpClient, httpClientLogger); _ytLiveChat = new YTLiveChat.Services.YTLiveChat(options, ytHttpClient, chatLogger); - + _ytLiveChat.InitialPageLoaded += OnInitialPageLoaded; _ytLiveChat.ChatReceived += OnChatReceived; _ytLiveChat.ChatStopped += OnChatStopped; _ytLiveChat.ErrorOccurred += OnErrorOccurred; //_ytLiveChat.LivestreamStarted += OnStreamStart; _ytLiveChat.BannerAdded += OnBanner; - _ytLiveChat.LivestreamEnded += OnStreamEnd; - + _ytLiveChat.LivestreamEnded += OnStreamEnd; + #pragma warning restore CS0618 } - public Task StartAsync(CancellationToken cancellationToken = default) - { + public Task StartAsync(CancellationToken cancellationToken = default) { Start(null); return Task.CompletedTask; } - - public Task StopAsync(CancellationToken cancellationToken = default) - { + + public Task StopAsync(CancellationToken cancellationToken = default) { Running = false; Dispose(); return Task.CompletedTask; } - - public bool Start(string? handle) - { + + [ExcludeFromCodeCoverage] + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + public bool Start(string? handle) { Running = false; _reconnectState.Reset(); _ytHandle = handle ?? _config.Get("YouTube", "Handle")!; bool configured = !string.IsNullOrWhiteSpace(_ytHandle) && _ytHandle.Trim() != "@"; - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = "None", @@ -93,50 +94,44 @@ public bool Start(string? handle) Configured = configured }); - if (!configured) - { + if (!configured) { _logger?.LogInformation("YouTube Service not connected to any channel. Not running."); return Running; } - if (!_ytHandle.StartsWith("@")) _ytHandle = "@" + _ytHandle; + if (!_ytHandle.StartsWith("@")) _ytHandle = "@" + _ytHandle; _logger?.LogInformation("Youtube Service Starting for " + _ytHandle); - - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = _ytHandle!, Status = Running }); - _ytLiveChat.Start(handle: _ytHandle, overwrite: true); + _ytLiveChat.Start(_ytHandle, overwrite: true); return true; } - + // private void OnStreamStart(object? sender, LivestreamStartedEventArgs e) // { // Console.WriteLine("----------------------YT Live Stream Started"); // } - + [ExcludeFromCodeCoverage] - private async void OnStreamEnd(object? sender, LivestreamEndedEventArgs e) - { + private async void OnStreamEnd(object? sender, LivestreamEndedEventArgs e) { _logger?.LogDebug("YT Stream has ended"); await Task.Delay(2000); - if (await _reconnectState.IsReconnecting() && _reconnectState.Retries < _reconnectState.MaxRetries) - { + if (await _reconnectState.IsReconnecting() && _reconnectState.Retries < _reconnectState.MaxRetries) { _logger?.LogInformation("YT Cancelling reconnects. Stream verified as ended. Attempting last try."); _reconnectState.Retries = _reconnectState.MaxRetries - 1; } } - private void OnInitialPageLoaded(object? sender, InitialPageLoadedEventArgs e) - { + private void OnInitialPageLoaded(object? sender, InitialPageLoadedEventArgs e) { Running = true; _reconnectState.Reset(); _reconnectState.Cts?.Cancel(); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = _ytHandle!, @@ -144,12 +139,11 @@ private void OnInitialPageLoaded(object? sender, InitialPageLoadedEventArgs e) }); _logger?.LogInformation($"Successfully loaded YouTube Live ID: {e.LiveId}"); } - private void OnChatStopped(object? sender, ChatStoppedEventArgs e) - { + + private void OnChatStopped(object? sender, ChatStoppedEventArgs e) { Running = false; _logger?.LogWarning("YT Chat stopped"); - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = _ytHandle!, @@ -157,16 +151,15 @@ private void OnChatStopped(object? sender, ChatStoppedEventArgs e) }); TryReconnectLoop(); } - - private void OnBanner(object? sender, BannerAddedEventArgs e) - { - if (e.Banner.BannerType != BannerType.CrossChannelRedirect || e.Banner is not CrossChannelRedirectBannerItem banner) return; + + private void OnBanner(object? sender, BannerAddedEventArgs e) { + if (e.Banner.BannerType != BannerType.CrossChannelRedirect || + e.Banner is not CrossChannelRedirectBannerItem banner) return; if (banner.RedirectType != CrossChannelRedirectType.Raid) return; - var channel = banner.RedirectChannelHandle.Replace("@", ""); + string channel = banner.RedirectChannelHandle.Replace("@", ""); - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.YouTube, EventType = SubathonEventType.YouTubeRedirect, Value = "", @@ -176,52 +169,43 @@ private void OnBanner(object? sender, BannerAddedEventArgs e) SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - private void OnErrorOccurred(object? sender, ErrorOccurredEventArgs e) - { - var ex = e.GetException(); + private void OnErrorOccurred(object? sender, ErrorOccurredEventArgs e) { + Exception? ex = e.GetException(); Running = false; - - if (ex?.Message.Contains("canonical link not found", StringComparison.OrdinalIgnoreCase) == true) - { + + if (ex?.Message.Contains("canonical link not found", StringComparison.OrdinalIgnoreCase) == true) { if (_canonicalLinkErrorCount == 0) - { _logger?.LogWarning( "YouTube stream is offline? (canonical link not found)." ); - } _canonicalLinkErrorCount++; } - else - { + else { var msg = "YT Error Occured"; - if (ex != null && ex.Message.Contains("Initial Continuation token not found")) - { + if (ex != null && ex.Message.Contains("Initial Continuation token not found")) { msg += " - Initial Continuation token not found"; _logger?.LogWarning(msg); } - else + else { _logger?.LogWarning(ex, "YT Error Occurred"); + } } - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = _ytHandle!, Status = Running }); } - - private void OnChatReceived(object? sender, ChatReceivedEventArgs e) - { + + private void OnChatReceived(object? sender, ChatReceivedEventArgs e) { if (string.IsNullOrWhiteSpace(_ytHandle) || _ytHandle == "@") return; - - if (!Running) - { - IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection - { + + if (!Running) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { Source = SubathonEventSource.YouTube, Service = "Chat", Name = _ytHandle!, @@ -233,38 +217,33 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) Running = true; _canonicalLinkErrorCount = 0; - + ChatItem item = e.ChatItem; if (item.Timestamp.DateTime.ToLocalTime() < DateTime.Now - TimeSpan.FromMinutes(3)) - { // only if it's not older than 3 min, // to avoid reparsing old events that had new id's return; - } string user = item.Author.Name.Replace("@", ""); - if (item.Superchat != null) - { + if (item.Superchat != null) { if (item.IsTicker) return; string currency = item.Superchat.Currency.ToUpper().Trim(); string raw = item.Superchat.AmountString.Trim(); - + if (currency == "USD" && !raw.StartsWith('$')) // Parsed incorrectly currency = Utils.TryParseCurrency(raw); - if (currency is "" or "???") - { - string message = $"Unknown currency detected: From: {user}, {item.Superchat.AmountString}"; - ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventType.YouTubeSuperChat), + if (currency is "" or "???") { + var message = $"Unknown currency detected: From: {user}, {item.Superchat.AmountString}"; + ErrorMessageEvents.RaiseErrorEvent("WARN", nameof(SubathonEventType.YouTubeSuperChat), message, item.Timestamp.DateTime.ToLocalTime()); _logger?.LogWarning(message); } - - SubathonEvent subathonEvent = new() - { + + SubathonEvent subathonEvent = new() { User = user, Currency = $"{currency}".Trim().ToUpper(), Value = $"{item.Superchat.AmountValue}", @@ -277,29 +256,27 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) return; } - if (item.MembershipDetails != null) - { + if (item.MembershipDetails != null) { if (item.IsTicker) return; - SubathonEvent subathonEvent = new() - { + SubathonEvent subathonEvent = new() { Source = SubathonEventSource.YouTube }; subathonEvent.Id = Guid.TryParse(item.Id, out Guid gid) ? gid : Utils.CreateGuidFromUniqueString(item.Id); subathonEvent.EventTimestamp = item.Timestamp.DateTime.ToLocalTime(); - - var details = item.MembershipDetails; + + MembershipDetails? details = item.MembershipDetails; string tier = details.HeaderSubtext ?? details.LevelName; if (string.IsNullOrWhiteSpace(tier)) tier = details.LevelName; subathonEvent.Value = tier; subathonEvent.Currency = "member"; - - if (tier.Contains("new", StringComparison.CurrentCultureIgnoreCase) && details.EventType == MembershipEventType.Unknown) + + if (tier.Contains("new", StringComparison.CurrentCultureIgnoreCase) && + details.EventType == MembershipEventType.Unknown) details.EventType = MembershipEventType.New; - - switch (details.EventType) - { + + switch (details.EventType) { case MembershipEventType.Milestone: // essentially a resub subathonEvent.EventType = SubathonEventType.YouTubeMembership; tier = details.LevelName; @@ -324,20 +301,21 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) subathonEvent.EventType = SubathonEventType.YouTubeMembership; break; } + subathonEvent.User = user; - + if (tier.Equals("Member", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(tier)) tier = "DEFAULT"; if (tier.StartsWith("Upgraded membership to")) // upgrade type { // levelname might be correct here, but incredibly hard to test tier = tier.Replace("Upgraded membership to", ""); - tier = tier[..^1]; // remove end ! - + tier = tier[..^1]; // remove end ! } + subathonEvent.Value = tier.Trim(); subathonEvent.EventTypeMeta = tier.Trim(); - + SubathonEvents.RaiseSubathonEventCreated(subathonEvent); return; } @@ -349,42 +327,36 @@ private void OnChatReceived(object? sender, ChatReceivedEventArgs e) item.Timestamp.DateTime.ToLocalTime(), Utils.CreateGuidFromUniqueString(item.Id)); else if (user.Equals("blerp", StringComparison.OrdinalIgnoreCase) && _config.GetBool("Extensions", "Blerp.Enabled", true)) - { BlerpChatService.ParseMessage(messagePreview, SubathonEventSource.YouTube); - } } - - private void TryReconnectLoop() - { + + private void TryReconnectLoop() { if (string.IsNullOrWhiteSpace(_ytHandle) || _ytHandle.Trim() == "@") return; - + _ = Task.Run(ReconnectWithBackoffAsync); } - + [ExcludeFromCodeCoverage] - private async Task ReconnectWithBackoffAsync() - { + private async Task ReconnectWithBackoffAsync() { if (await _reconnectState.IsReconnecting()) return; // already reconnecting - + if (string.IsNullOrWhiteSpace(_ytHandle) || _ytHandle.Trim() == "@") return; - - try - { + + try { _reconnectState.Cts?.Cancel(); _reconnectState.Cts = new CancellationTokenSource(); - var token = _reconnectState.Cts.Token; + CancellationToken token = _reconnectState.Cts.Token; - while (!token.IsCancellationRequested && !Running && _ytHandle?.Trim() != "@" && !string.IsNullOrWhiteSpace(_ytHandle)) - { - if (_reconnectState.Retries >= _reconnectState.MaxRetries) - { + while (!token.IsCancellationRequested && !Running && _ytHandle?.Trim() != "@" && + !string.IsNullOrWhiteSpace(_ytHandle)) { + if (_reconnectState.Retries >= _reconnectState.MaxRetries) { _logger?.LogError( "[YT] Max reconnect retries ({Retries}) reached. Giving up.", _reconnectState.MaxRetries); - + ErrorMessageEvents.RaiseErrorEvent( "ERROR", nameof(SubathonEventSource.YouTube), @@ -395,7 +367,7 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.Retries++; - var delay = _reconnectState.Backoff; + TimeSpan delay = _reconnectState.Backoff; _logger?.LogWarning( "[YT] Reconnect attempt {Attempt}/{Max} in {Delay}s", @@ -403,21 +375,16 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.MaxRetries, delay.TotalSeconds); - try - { + try { await Task.Delay(delay, token); if (!Running && !string.IsNullOrWhiteSpace(_ytHandle)) - { - _ytLiveChat.Start(handle: _ytHandle, overwrite: true); - } + _ytLiveChat.Start(_ytHandle, overwrite: true); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { return; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "[YT] Reconnect attempt failed"); } @@ -428,25 +395,15 @@ private async Task ReconnectWithBackoffAsync() _reconnectState.MaxBackoff.TotalMilliseconds)); } } - finally - { + finally { _reconnectState.Lock.Release(); } } - - [ExcludeFromCodeCoverage] - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - + [ExcludeFromCodeCoverage] - protected virtual void Dispose(bool disposing) - { + protected virtual void Dispose(bool disposing) { if (_disposed) return; - if (disposing) - { + if (disposing) { _reconnectState.Dispose(); _ytLiveChat.Stop(); _ytLiveChat.InitialPageLoaded -= OnInitialPageLoaded; @@ -457,16 +414,15 @@ protected virtual void Dispose(bool disposing) _ytLiveChat.Dispose(); _httpClient.Dispose(); } + _disposed = true; } - - public static void SimulateSuperChat(string value = "10.00", string currency = "USD") - { - if (!double.TryParse(value, out var val)) + + public static void SimulateSuperChat(string value = "10.00", string currency = "USD") { + if (!double.TryParse(value, out double val)) return; - SubathonEvent subathonEvent = new SubathonEvent - { + var subathonEvent = new SubathonEvent { User = "SYSTEM", Currency = currency, Value = value, @@ -475,11 +431,9 @@ public static void SimulateSuperChat(string value = "10.00", string currency = " }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - - public static void SimulateMembership(string tier = "DEFAULT") - { - SubathonEvent subathonEvent = new SubathonEvent - { + + public static void SimulateMembership(string tier = "DEFAULT") { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, Currency = "member", EventType = SubathonEventType.YouTubeMembership, @@ -489,11 +443,9 @@ public static void SimulateMembership(string tier = "DEFAULT") SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - public static void SimulateGiftMemberships(int amount) - { - string tier = "DEFAULT"; - SubathonEvent subathonEvent = new SubathonEvent - { + public static void SimulateGiftMemberships(int amount) { + var tier = "DEFAULT"; + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, Currency = "member", EventType = SubathonEventType.YouTubeGiftMembership, @@ -503,12 +455,10 @@ public static void SimulateGiftMemberships(int amount) Amount = amount }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); - } - - public static void SimulateRaid() - { - SubathonEvent subathonEvent = new SubathonEvent - { + } + + public static void SimulateRaid() { + var subathonEvent = new SubathonEvent { Source = SubathonEventSource.Simulated, Currency = "", EventType = SubathonEventType.YouTubeRedirect, @@ -517,5 +467,5 @@ public static void SimulateRaid() Amount = 1 }; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); - } + } } \ No newline at end of file From 01a5c92abf1e05452f7a005d1c947f64a1a72783 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:12:00 -0300 Subject: [PATCH 09/39] formatting changes in webserver --- .../AspNetContextAdapter.cs | 44 +- .../Interfaces/IHttpContext.cs | 14 +- .../Interfaces/IWebSocketClient.cs | 23 +- SubathonManager.Server/WebServer.Api.cs | 313 +++++------- SubathonManager.Server/WebServer.Overlays.cs | 360 ++++++------- SubathonManager.Server/WebServer.WebSocket.cs | 475 ++++++++---------- SubathonManager.Server/WebServer.Webhooks.cs | 20 +- SubathonManager.Server/WebServer.cs | 243 +++++---- SubathonManager.Server/WebSocketClient.cs | 54 +- .../WebSocketOutboundQueue.cs | 99 ++-- 10 files changed, 737 insertions(+), 908 deletions(-) diff --git a/SubathonManager.Server/AspNetContextAdapter.cs b/SubathonManager.Server/AspNetContextAdapter.cs index 72f73d21..e48f2af3 100644 --- a/SubathonManager.Server/AspNetContextAdapter.cs +++ b/SubathonManager.Server/AspNetContextAdapter.cs @@ -3,13 +3,13 @@ using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Primitives; using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; [ExcludeFromCodeCoverage] -public sealed class AspNetContextAdapter(HttpContext ctx) : IHttpContext -{ +public sealed class AspNetContextAdapter(HttpContext ctx) : IHttpContext { public string Method => ctx.Request.Method; public string Path => ctx.Request.Path.Value ?? "/"; public string QueryString => ctx.Request.QueryString.Value?.TrimStart('?') ?? string.Empty; @@ -17,24 +17,22 @@ public sealed class AspNetContextAdapter(HttpContext ctx) : IHttpContext public Encoding Encoding => Encoding.UTF8; public bool IsWebSocket => ctx.WebSockets.IsWebSocketRequest; - public IReadOnlyDictionary Headers - { - get - { + public IReadOnlyDictionary Headers { + get { var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (key, value) in ctx.Request.Headers) + foreach ((string key, StringValues value) in ctx.Request.Headers) dict[key] = value.ToString(); return dict; } } - public Task? AcceptWebSocketAsync(string? subProtocol = null) - => ctx.WebSockets.IsWebSocketRequest + public Task? AcceptWebSocketAsync(string? subProtocol = null) { + return ctx.WebSockets.IsWebSocketRequest ? ctx.WebSockets.AcceptWebSocketAsync(subProtocol) : null; + } - public async Task WriteResponse(int code, string body, bool addCors = false, string? contentType = null) - { + public async Task WriteResponse(int code, string body, bool addCors = false, string? contentType = null) { ctx.Response.StatusCode = code; if (contentType != null) ctx.Response.ContentType = contentType; @@ -43,33 +41,33 @@ public async Task WriteResponse(int code, string body, bool addCors = false, str await ctx.Response.WriteAsync(body, Encoding.UTF8); } - public async Task ServeFile(string fullPath, string contentType) - { + public async Task ServeFile(string fullPath, string contentType) { ctx.Response.ContentType = contentType; AddCorsHeaders(ctx.Response); await ctx.Response.SendFileAsync(fullPath); } - public async Task ServeBytes(byte[] data, string contentType) - { + public async Task ServeBytes(byte[] data, string contentType) { ctx.Response.ContentType = contentType; ctx.Response.ContentLength = data.Length; AddCorsHeaders(ctx.Response); await ctx.Response.Body.WriteAsync(data); } - private static void AddCorsHeaders(HttpResponse response) - { + private static void AddCorsHeaders(HttpResponse response) { response.Headers["Access-Control-Allow-Origin"] = "*"; response.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; response.Headers["Access-Control-Allow-Headers"] = "Content-Type"; } } - [ExcludeFromCodeCoverage] -internal sealed class NoopHostLifetime : IHostLifetime -{ - public Task WaitForStartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} +internal sealed class NoopHostLifetime : IHostLifetime { + public Task WaitForStartAsync(CancellationToken cancellationToken) { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/SubathonManager.Server/Interfaces/IHttpContext.cs b/SubathonManager.Server/Interfaces/IHttpContext.cs index 9f375fe6..59bee7dc 100644 --- a/SubathonManager.Server/Interfaces/IHttpContext.cs +++ b/SubathonManager.Server/Interfaces/IHttpContext.cs @@ -1,12 +1,12 @@ -using System.Text; -using System.Net.WebSockets; +using System.Net.WebSockets; +using System.Text; + namespace SubathonManager.Server.Interfaces; -public interface IHttpContext -{ +public interface IHttpContext { string Method { get; } string Path { get; } - + string QueryString { get; } IReadOnlyDictionary Headers { get; } @@ -14,9 +14,9 @@ public interface IHttpContext Stream Body { get; } Encoding Encoding { get; } bool IsWebSocket { get; } - + Task? AcceptWebSocketAsync(string? subProtocol = null); - Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null); + Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null); Task ServeFile(string fullPath, string contentType); Task ServeBytes(byte[] data, string contentType); } \ No newline at end of file diff --git a/SubathonManager.Server/Interfaces/IWebSocketClient.cs b/SubathonManager.Server/Interfaces/IWebSocketClient.cs index 950cead3..feb2b8f6 100644 --- a/SubathonManager.Server/Interfaces/IWebSocketClient.cs +++ b/SubathonManager.Server/Interfaces/IWebSocketClient.cs @@ -1,25 +1,24 @@ -using SubathonManager.Core.Enums; -using System.Net.WebSockets; +using System.Net.WebSockets; +using SubathonManager.Core.Enums; -namespace SubathonManager.Server; +namespace SubathonManager.Server.Interfaces; -public interface IWebSocketClient -{ +public interface IWebSocketClient { List ClientTypes { get; set; } - + WebSocketState State { get; } - + Guid ClientId { get; } List IntegrationSources { get; } - - Task SendAsync(ArraySegment buffer, - WebSocketMessageType messageType, - bool endOfMessage, + + Task SendAsync(ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, CancellationToken cancellationToken); Task ReceiveAsync( - ArraySegment buffer, + ArraySegment buffer, CancellationToken cancellationToke); Task CloseAsync( diff --git a/SubathonManager.Server/WebServer.Api.cs b/SubathonManager.Server/WebServer.Api.cs index de6da18c..d9b309bc 100644 --- a/SubathonManager.Server/WebServer.Api.cs +++ b/SubathonManager.Server/WebServer.Api.cs @@ -1,66 +1,62 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SubathonManager.Core.Models; -using SubathonManager.Core.Events; using SubathonManager.Core.Enums; -using SubathonManager.Integration; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; using SubathonManager.Data; +using SubathonManager.Integration; using SubathonManager.Server.Interfaces; // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Server; -public partial class WebServer -{ - private void SetupApiRoutes() - { - _routes.Add((new RouteKey("POST", "/api/data/control"),HandleDataControlRequestAsync )); - _routes.Add((new RouteKey("PUT", "/api/data/control"),HandleDataControlRequestAsync )); - - _routes.Add((new RouteKey("GET", "/api/data/status"),HandleStatusRequestAsync)); - - _routes.Add((new RouteKey("GET", "/api/data/amounts"),HandleAmountsRequestAsync )); - - _routes.Add((new RouteKey("GET", "/api/data/values"),HandleValuesRequestAsync )); - - _routes.Add((new RouteKey("GET", "/api/data/commands"),HandleCommandsRequestAsync )); - - _routes.Add((new RouteKey("PUT", "/api/data/values"),HandleValuesPatchRequestAsync)); - _routes.Add((new RouteKey("POST", "/api/data/values"),HandleValuesPatchRequestAsync )); - _routes.Add((new RouteKey("PATCH", "/api/data/values"),HandleValuesPatchRequestAsync )); - - _routes.Add((new RouteKey("GET", "/api/select"),HandleSelectAsync)); - - _routes.Add((new RouteKey("POST", "/api/update-position/"),HandleWidgetUpdateAsync )); - _routes.Add((new RouteKey("POST", "/api/update-size/"),HandleWidgetUpdateAsync)); - _routes.Add((new RouteKey("POST", "/api/update-dimensions/"),HandleWidgetUpdateAsync)); - _routes.Add((new RouteKey("POST", "/api/widget-action/"),HandleWidgetActionAsync)); +public partial class WebServer { + private void SetupApiRoutes() { + _routes.Add((new RouteKey("POST", "/api/data/control"), HandleDataControlRequestAsync)); + _routes.Add((new RouteKey("PUT", "/api/data/control"), HandleDataControlRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/data/status"), HandleStatusRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/data/amounts"), HandleAmountsRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/data/values"), HandleValuesRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/data/commands"), HandleCommandsRequestAsync)); + + _routes.Add((new RouteKey("PUT", "/api/data/values"), HandleValuesPatchRequestAsync)); + _routes.Add((new RouteKey("POST", "/api/data/values"), HandleValuesPatchRequestAsync)); + _routes.Add((new RouteKey("PATCH", "/api/data/values"), HandleValuesPatchRequestAsync)); + + _routes.Add((new RouteKey("GET", "/api/select"), HandleSelectAsync)); + + _routes.Add((new RouteKey("POST", "/api/update-position/"), HandleWidgetUpdateAsync)); + _routes.Add((new RouteKey("POST", "/api/update-size/"), HandleWidgetUpdateAsync)); + _routes.Add((new RouteKey("POST", "/api/update-dimensions/"), HandleWidgetUpdateAsync)); + _routes.Add((new RouteKey("POST", "/api/widget-action/"), HandleWidgetActionAsync)); } - internal async Task HandleWidgetActionAsync(IHttpContext ctx) - { - var parts = ctx.Path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length < 3 || !Guid.TryParse(parts[2], out var widgetId)) - { + internal async Task HandleWidgetActionAsync(IHttpContext ctx) { + string[] parts = ctx.Path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3 || !Guid.TryParse(parts[2], out Guid widgetId)) { await ctx.WriteResponse(400, "Invalid Widget ID"); return; } string body; - using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) { body = await reader.ReadToEndAsync(); + } - try - { + try { var data = JsonSerializer.Deserialize>(body); - string name = data != null && data.TryGetValue("action", out var el) && el.ValueKind == JsonValueKind.String + string name = data != null && data.TryGetValue("action", out JsonElement el) && + el.ValueKind == JsonValueKind.String ? el.GetString() ?? string.Empty : string.Empty; - if (!Enum.TryParse(name, ignoreCase: true, out var action)) - { + if (!Enum.TryParse(name, true, out WidgetContextAction action)) { await ctx.WriteResponse(400, "Unknown action"); return; } @@ -68,52 +64,46 @@ internal async Task HandleWidgetActionAsync(IHttpContext ctx) WidgetEvents.RaiseWidgetAction(widgetId, action); await ctx.WriteResponse(200, "OK"); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to handle widget context action"); await ctx.WriteResponse(400, "Invalid action data"); } } - - internal async Task HandleSelectAsync(IHttpContext ctx) - { - var path = ctx.Path; - + + internal async Task HandleSelectAsync(IHttpContext ctx) { + string path = ctx.Path; + // Fast Close await ctx.WriteResponse(200, "OK"); - var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3) - { + string[] parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 3) { string widgetId = parts[2]; - if (Guid.TryParse(widgetId, out var widgetGuid)) - { - WidgetEvents.RaiseSelectEditorWidget(widgetGuid); - } + if (Guid.TryParse(widgetId, out Guid widgetGuid)) WidgetEvents.RaiseSelectEditorWidget(widgetGuid); } } - - internal async Task HandleWidgetUpdateAsync(IHttpContext ctx) - {; - var path = ctx.Path; - - var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 3) - { + + internal async Task HandleWidgetUpdateAsync(IHttpContext ctx) { + ; + string path = ctx.Path; + + string[] parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 3) { string widgetId = parts[2]; string body; - using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) { body = await reader.ReadToEndAsync(); + } + var data = JsonSerializer.Deserialize>(body); - if (data == null) - { + if (data == null) { await ctx.WriteResponse(400, "Invalid update data"); return; } var widgetHelper = new WidgetEntityHelper(_factory, null); - bool success = false; + var success = false; if (path.StartsWith("/api/update-size/", StringComparison.OrdinalIgnoreCase)) success = await widgetHelper.UpdateWidgetScale(widgetId, data); else if (path.StartsWith("/api/update-position/", StringComparison.OrdinalIgnoreCase)) @@ -121,100 +111,88 @@ internal async Task HandleWidgetUpdateAsync(IHttpContext ctx) else if (path.StartsWith("/api/update-dimensions/", StringComparison.OrdinalIgnoreCase)) success = await widgetHelper.UpdateWidgetDimensions(widgetId, data); - if (success) - { + if (success) { await ctx.WriteResponse(200, "OK"); return; } - + await ctx.WriteResponse(404, "Widget Not Found"); return; } + await ctx.WriteResponse(400, "Invalid Widget ID"); } - private async Task HandleDataControlRequestAsync(IHttpContext ctx) - { + private async Task HandleDataControlRequestAsync(IHttpContext ctx) { string body; - using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) { body = await reader.ReadToEndAsync(); + } var data = new Dictionary(); - try - { + try { data = JsonSerializer.Deserialize>($"{body}"); } - catch (JsonException ex) - { + catch (JsonException ex) { _logger?.LogError(ex, "Invalid control data"); await ctx.WriteResponse(400, "Invalid control data"); return; } - if (data == null || data.Count == 0) - { + if (data == null || data.Count == 0) { await ctx.WriteResponse(400, "Invalid control data"); return; } - SubathonEventType type = SubathonEventType.Unknown; + var type = SubathonEventType.Unknown; if (!data.ContainsKey("type") || !data.TryGetValue("type", out JsonElement elem) - || !Enum.TryParse(elem.GetString()!, ignoreCase: true, out type)) - { - + || !Enum.TryParse(elem.GetString()!, true, out type)) { await ctx.WriteResponse(400, "Invalid control data"); return; } - bool success = false; - if (type == SubathonEventType.Command) - { + var success = false; + if (type == SubathonEventType.Command) { success = ExternalEventService.ProcessExternalCommand(data); } - else if (((SubathonEventType?)type).IsCurrencyDonation() && ((SubathonEventType?)type).IsExternal()) - { + else if (((SubathonEventType?)type).IsCurrencyDonation() && ((SubathonEventType?)type).IsExternal()) { success = ExternalEventService.ProcessExternalDonation(data); } - else if (((SubathonEventType?)type).IsSubscription() && ((SubathonEventType?)type).IsExternal()) - { + else if (((SubathonEventType?)type).IsSubscription() && ((SubathonEventType?)type).IsExternal()) { success = ExternalEventService.ProcessExternalSub(data); } - else - { + else { await ctx.WriteResponse(400, "Invalid control data"); return; } - if (success) - { + if (success) { await ctx.WriteResponse(200, "OK"); return; } + await ctx.WriteResponse(400, "Invalid API Request"); } - internal async Task HandleStatusRequestAsync(IHttpContext ctx) - { - await using var db = await _factory.CreateDbContextAsync(); + internal async Task HandleStatusRequestAsync(IHttpContext ctx) { + await using AppDbContext db = await _factory.CreateDbContextAsync(); SubathonData? subathon = await db.SubathonDatas.Include(s => s.Multiplier) .AsNoTracking() .FirstOrDefaultAsync(s => s.IsActive); TimeSpan? multiplierRemaining = TimeSpan.Zero; - if (subathon == null) - { + if (subathon == null) { await ctx.WriteResponse(400, "Invalid status request"); return; } + if (subathon.Multiplier.Duration != null && subathon.Multiplier.Duration > TimeSpan.Zero - && subathon.Multiplier.Started != null) - { + && subathon.Multiplier.Started != null) { DateTime? multEndTime = subathon.Multiplier.Started + subathon.Multiplier.Duration; multiplierRemaining = multEndTime! - DateTime.Now; } - object response = new - { + object response = new { millis_cumulated = subathon.MillisecondsCumulative, millis_elapsed = subathon.MillisecondsElapsed, millis_remaining = subathon.MillisecondsRemaining(), @@ -227,91 +205,86 @@ internal async Task HandleStatusRequestAsync(IHttpContext ctx) is_paused = subathon.IsPaused, is_locked = subathon.IsLocked, is_reversed = subathon.IsSubathonReversed(), - multiplier = new - { + multiplier = new { running = subathon.Multiplier.IsRunning(), apply_points = subathon.Multiplier.ApplyToPoints, apply_time = subathon.Multiplier.ApplyToSeconds, is_from_hypetrain = subathon.Multiplier.FromHypeTrain, started_at = subathon.Multiplier.Started, duration_seconds = Math.Round(subathon.Multiplier.Duration?.TotalSeconds ?? 0), - duration_remaining_seconds = Math.Round(multiplierRemaining.Value.TotalSeconds), + duration_remaining_seconds = Math.Round(multiplierRemaining.Value.TotalSeconds) } }; - string json = JsonSerializer.Serialize(response, new JsonSerializerOptions - { + string json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); await ctx.WriteResponse(200, json); } - internal async Task HandleAmountsRequestAsync(IHttpContext ctx) - { - await using var db = await _factory.CreateDbContextAsync(); + internal async Task HandleAmountsRequestAsync(IHttpContext ctx) { + await using AppDbContext db = await _factory.CreateDbContextAsync(); SubathonData? subathon = await db.SubathonDatas.Include(s => s.Multiplier) .AsNoTracking() .FirstOrDefaultAsync(s => s.IsActive); - if (subathon == null) - { + if (subathon == null) { await ctx.WriteResponse(400, "Invalid status request"); return; } - var events = await db.SubathonEvents + + List events = await db.SubathonEvents .Where(e => e.SubathonId == subathon.Id && e.ProcessedToSubathon) .Where(e => e.EventType != SubathonEventType.Command && e.EventType != SubathonEventType.Unknown) .ToListAsync(); - - var simulated = events.Where(e => e.User != null && (e.User.StartsWith("SYSTEM") || e.User.StartsWith("SIMULATED"))).ToList(); - var real = events.Where(e => e.User != null && !e.User.StartsWith("SYSTEM") && !e.User.StartsWith("SIMULATED")).ToList(); - - object response = new - { + + List simulated = events + .Where(e => e.User != null && (e.User.StartsWith("SYSTEM") || e.User.StartsWith("SIMULATED"))).ToList(); + List real = events + .Where(e => e.User != null && !e.User.StartsWith("SYSTEM") && !e.User.StartsWith("SIMULATED")).ToList(); + + object response = new { simulated = BuildDataSummary(simulated), real = BuildDataSummary(real) }; - string json = JsonSerializer.Serialize(response, new JsonSerializerOptions - { + string json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); await ctx.WriteResponse(200, json); } - - internal static object[] BuildCommandCatalog() => - Enum.GetValues() + + internal static object[] BuildCommandCatalog() { + return Enum.GetValues() .Where(c => c is not (SubathonCommandType.None or SubathonCommandType.Unknown)) - .Select(object (c) => new - { + .Select(object (c) => new { command = c.ToString(), description = c.GetDescription(), requires_parameter = c.IsParametersRequired(), is_control = c.IsControlTypeCommand() }).ToArray(); + } - internal async Task HandleCommandsRequestAsync(IHttpContext ctx) - { + internal async Task HandleCommandsRequestAsync(IHttpContext ctx) { string json = JsonSerializer.Serialize(new { commands = BuildCommandCatalog() }); - await ctx.WriteResponse(200, json, addCors: true, contentType: "application/json"); + await ctx.WriteResponse(200, json, true, "application/json"); } - private async Task HandleValuesRequestAsync(IHttpContext ctx) - { - var json = await _valueHelper.GetAllAsJsonAsync(); + private async Task HandleValuesRequestAsync(IHttpContext ctx) { + string json = await _valueHelper.GetAllAsJsonAsync(); await ctx.WriteResponse(200, json); } - internal async Task HandleValuesPatchRequestAsync(IHttpContext ctx) - { + internal async Task HandleValuesPatchRequestAsync(IHttpContext ctx) { string body; - using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) + using (var reader = new StreamReader(ctx.Body, ctx.Encoding)) { body = await reader.ReadToEndAsync(); + } + int patched = await _valueHelper.PatchFromJsonAsync(body); int code; string msg; - switch (patched) - { + switch (patched) { case -1: code = 400; msg = "Error patching values"; @@ -325,17 +298,15 @@ internal async Task HandleValuesPatchRequestAsync(IHttpContext ctx) msg = $"Patched {patched} Values"; break; } + await ctx.WriteResponse(code, msg); } - private object BuildDataSummary(List events) - { + private object BuildDataSummary(List events) { var result = new Dictionary(); - - static string NormalizeTier(string meta) - { - return meta switch - { + + static string NormalizeTier(string meta) { + return meta switch { "1000" => "T1", "2000" => "T2", "3000" => "T3", @@ -343,24 +314,21 @@ static string NormalizeTier(string meta) }; } - var groups = events.GroupBy(e => e.EventType); + IEnumerable> groups = events.GroupBy(e => e.EventType); - foreach (var g in groups) - { - string? key = g.Key!.ToString(); + foreach (IGrouping g in groups) { + var key = g.Key!.ToString(); if (key == null) continue; - if (g.Key.IsCurrencyDonation()) - { + if (g.Key.IsCurrencyDonation()) { result[key] = g - .Where(e => !string.IsNullOrWhiteSpace(e.Currency)) + .Where(e => !string.IsNullOrWhiteSpace(e.Currency)) .GroupBy(e => e.Currency ?? "") .ToDictionary( t => t.Key, - t => - { + t => { double sum = t.Sum(e => - double.TryParse(e.Value, out var amount) + double.TryParse(e.Value, out double amount) ? amount : 0 ); @@ -368,61 +336,54 @@ static string NormalizeTier(string meta) } ); } - else if (g.Key.IsSubscription()) - { + else if (g.Key.IsSubscription()) { result[key] = g.GroupBy(e => NormalizeTier(e.Value)) .ToDictionary( t => t.Key, t => t.Sum(x => x.Amount) ); } - else if (g.Key.IsToken()) - { - result[key] = g.Sum(e => int.TryParse(e.Value, out var v) ? v : 0); + else if (g.Key.IsToken()) { + result[key] = g.Sum(e => int.TryParse(e.Value, out int v) ? v : 0); } - else if (g.Key.IsOrder()) - { - var breakdown = g - .Where(e => !string.IsNullOrWhiteSpace(e.Currency)) + else if (g.Key.IsOrder()) { + Dictionary breakdown = g + .Where(e => !string.IsNullOrWhiteSpace(e.Currency)) .GroupBy(e => e.Currency ?? "") .ToDictionary( t => t.Key, - t => - { + t => { double sum = t.Sum(e => - double.TryParse(string.Equals(e.Value, "new", StringComparison.OrdinalIgnoreCase) - ? "1" : e.Value, out var amount) + double.TryParse(string.Equals(e.Value, "new", StringComparison.OrdinalIgnoreCase) + ? "1" + : e.Value, out double amount) ? amount : 0 ); return Math.Round(sum, 2); } ); - result[key] = new Dictionary - { + result[key] = new Dictionary { ["count"] = g.Count(), ["breakdown"] = breakdown }; } - else - { - switch (g.Key) - { + else { + switch (g.Key) { case SubathonEventType.TwitchFollow: result[key] = g.Count(); break; case SubathonEventType.TwitchRaid: - result[key] = new - { + result[key] = new { count = g.Count(), - total_viewers = g.Sum(e => int.TryParse(e.Value, out var v) ? v : 0) + total_viewers = g.Sum(e => int.TryParse(e.Value, out int v) ? v : 0) }; break; } } } + return result; } - } \ No newline at end of file diff --git a/SubathonManager.Server/WebServer.Overlays.cs b/SubathonManager.Server/WebServer.Overlays.cs index ddba1da6..ee6df5eb 100644 --- a/SubathonManager.Server/WebServer.Overlays.cs +++ b/SubathonManager.Server/WebServer.Overlays.cs @@ -1,54 +1,45 @@ -using System.Globalization; +using System.Collections.Specialized; +using System.Globalization; using System.Net; using System.Text; using System.Text.RegularExpressions; +using System.Web; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Data; using SubathonManager.Server.Interfaces; namespace SubathonManager.Server; -public partial class WebServer -{ - - public void AddRoute(Core.Models.Route route) - { - foreach (var widget in route.Widgets) - { +public partial class WebServer { + public void AddRoute(Route route) { + foreach (Widget widget in route.Widgets) { string folder = Path.GetDirectoryName(widget.HtmlPath)!; - if (!_servedFolders.Add(folder)) - { - _logger?.LogInformation($"Registered static folder: {folder}"); - } + if (!_servedFolders.Add(folder)) _logger?.LogInformation($"Registered static folder: {folder}"); } } - internal async Task HandleWidgetRequest(IHttpContext ctx) - { - var path = ctx.Path; - var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + internal async Task HandleWidgetRequest(IHttpContext ctx) { + string path = ctx.Path; + string[] parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2) - { + if (parts.Length >= 2) { string widgetId = parts[1]; - if (Guid.TryParse(widgetId, out var widgetGuid)) - { - - await using var db = await _factory.CreateDbContextAsync(); - var widget = await db.Widgets + if (Guid.TryParse(widgetId, out Guid widgetGuid)) { + await using AppDbContext db = await _factory.CreateDbContextAsync(); + Widget? widget = await db.Widgets .Include(ww => ww.CssVariables) .Include(ww => ww.JsVariables) .FirstOrDefaultAsync(w => w.Id == widgetGuid); - if (widget != null) - { + if (widget != null) { string relativePath = string.Join('/', parts.Skip(2)); - if (widget.Type.IsAsset() && string.IsNullOrWhiteSpace(relativePath)) - { + if (widget.Type.IsAsset() && string.IsNullOrWhiteSpace(relativePath)) { string fileName = Path.GetFileName(widget.HtmlPath); string html = widget.Type == WidgetType.Video ? $"" @@ -62,32 +53,25 @@ internal async Task HandleWidgetRequest(IHttpContext ctx) ? widget.HtmlPath : Path.Combine(folder, relativePath.Replace('/', Path.DirectorySeparatorChar)); - if (WidgetFiles.Current.Exists(filePath)) - { - if (Path.GetExtension(filePath).Equals(".html", StringComparison.OrdinalIgnoreCase)) - { + if (WidgetFiles.Current.Exists(filePath)) { + if (Path.GetExtension(filePath).Equals(".html", StringComparison.OrdinalIgnoreCase)) { string html = WidgetFiles.Current.ReadAllText(filePath) ?? string.Empty; var cssOverrides = new StringBuilder(); cssOverrides.AppendLine(GetWebsocketInjectionScript()); cssOverrides.AppendLine(""); if (html.Contains("", StringComparison.OrdinalIgnoreCase)) - { - html = html.Replace("", cssOverrides + "\n", StringComparison.OrdinalIgnoreCase); - } + html = html.Replace("", cssOverrides + "\n", + StringComparison.OrdinalIgnoreCase); else - { html += cssOverrides; - } - + var jsOverrides = new StringBuilder(); jsOverrides.AppendLine("\n\n"); var fontLoader = """ @@ -113,7 +97,7 @@ function loadGoogleFont(fontName) { link.rel = 'stylesheet'; document.head.appendChild(link); } - + function loadCdnFont(fontName) { const link = document.createElement('link'); link.href = `https://fonts.cdnfonts.com/css/${fontName.replace(/ /g, '-').toLowerCase()}`; @@ -137,19 +121,17 @@ function loadBunnyFont(fontName) { } """; - if (html.Contains(" "; + } - private string GenerateMergedPage(Core.Models.Route route, bool isEditor = false) - { - StringBuilder sb = new StringBuilder(); + private string GenerateMergedPage(Route route, bool isEditor = false) { + var sb = new StringBuilder(); sb.AppendLine( $"overlay-{route.Id}"); sb.AppendLine($@" @@ -406,7 +383,6 @@ @keyframes pulse {{ "); if (isEditor) - { sb.AppendLine(@" "); - } string overlayClass = isEditor ? "overlay-edit" : ""; sb.AppendLine($@"
"); - foreach (var w in route.Widgets) - { + foreach (Widget w in route.Widgets) { if (!isEditor && !w.Visibility) continue; if (w.ScaleX == 0) w.ScaleX = 1; if (w.ScaleY == 0) w.ScaleY = 1; - string cssClass = "widget-wrapper"; + var cssClass = "widget-wrapper"; if (!w.Visibility) cssClass += " widget-hidden"; - string sx = w.ScaleX.ToString(CultureInfo.InvariantCulture); - string sy = w.ScaleY.ToString(CultureInfo.InvariantCulture); - string px = w.X.ToString(CultureInfo.InvariantCulture); - string py = w.Y.ToString(CultureInfo.InvariantCulture); + var sx = w.ScaleX.ToString(CultureInfo.InvariantCulture); + var sy = w.ScaleY.ToString(CultureInfo.InvariantCulture); + var px = w.X.ToString(CultureInfo.InvariantCulture); + var py = w.Y.ToString(CultureInfo.InvariantCulture); sb.AppendLine($@"
"); } - if (isEditor) - { + if (isEditor) { sb.AppendLine(@"
"); - foreach (var w in route.Widgets) - { - string chromeClass = "widget-chrome"; + foreach (Widget w in route.Widgets) { + var chromeClass = "widget-chrome"; if (!w.Visibility) chromeClass += " chrome-hidden"; sb.AppendLine($@"
"); } + sb.AppendLine("
"); } sb.AppendLine("
"); - sb.AppendLine(@$" + sb.AppendLine(@" "); sb.AppendLine(@$"{GetWebsocketInjectionScript(route.Id.ToString())}"); // moving/resizing/selecting in edit mode - if (isEditor) - { + if (isEditor) { sb.AppendLine(@" "); - + // resize as separate listeners - sb.AppendLine(@$" + sb.AppendLine(@" "); diff --git a/SubathonManager.Server/WebServer.WebSocket.cs b/SubathonManager.Server/WebServer.WebSocket.cs index a45f9c96..5a37e385 100644 --- a/SubathonManager.Server/WebServer.WebSocket.cs +++ b/SubathonManager.Server/WebServer.WebSocket.cs @@ -5,25 +5,24 @@ using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Core.Enums; -using SubathonManager.Core.Models; using SubathonManager.Core.Events; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.Integration; using SubathonManager.Server.Interfaces; using SubathonManager.Services; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Server; -public partial class WebServer -{ +public partial class WebServer { + private static readonly TimeSpan OutboundDrainTimeout = TimeSpan.FromSeconds(2); private readonly List _clients = new(); private readonly object _lock = new(); - private static readonly TimeSpan OutboundDrainTimeout = TimeSpan.FromSeconds(2); - private void SetupWebsocketListeners() - { + private void SetupWebsocketListeners() { SubathonEvents.SubathonDataUpdate += SendSubathonDataUpdate; SubathonEvents.SubathonEventProcessed += SendSubathonEventProcessed; SubathonEvents.SubathonGoalCompleted += SendGoalCompleted; @@ -36,7 +35,7 @@ private void SetupWebsocketListeners() SubathonEvents.PromptRunStarted += OnPromptStart; SubathonEvents.PromptRunUpdate += OnPromptRunUpdate; SubathonEvents.PromptRunProgressUpdated += OnPromptProgress; - + OverlayEvents.WidgetVarsUpdated += SendWidgetVarsUpdate; OverlayEvents.WidgetRefreshRequested += SendWidgetReload; @@ -46,8 +45,7 @@ private void SetupWebsocketListeners() WheelEvents.WheelDataChanged += SendWheelDataChanged; } - private void StopWebsocketServer() - { + private void StopWebsocketServer() { SubathonEvents.SubathonDataUpdate -= SendSubathonDataUpdate; SubathonEvents.SubathonEventProcessed -= SendSubathonEventProcessed; SubathonEvents.SubathonGoalCompleted -= SendGoalCompleted; @@ -56,11 +54,11 @@ private void StopWebsocketServer() SubathonEvents.SubathonValueConfigRequested -= SendSubathonValues; SubathonEvents.SubathonTotalsUpdated -= SendSubathonTotals; SubathonEvents.SubscriptionTotalsUpdated -= SendSubscriptionTotals; - + SubathonEvents.PromptRunStarted -= OnPromptStart; SubathonEvents.PromptRunUpdate -= OnPromptRunUpdate; SubathonEvents.PromptRunProgressUpdated -= OnPromptProgress; - + OverlayEvents.WidgetVarsUpdated -= SendWidgetVarsUpdate; OverlayEvents.WidgetRefreshRequested -= SendWidgetReload; @@ -70,54 +68,46 @@ private void StopWebsocketServer() WheelEvents.WheelDataChanged -= SendWheelDataChanged; List clientsCopy; - lock (_lock) - { + lock (_lock) { clientsCopy = _clients.ToList(); _clients.Clear(); } - foreach (var client in clientsCopy) + foreach (IWebSocketClient client in clientsCopy) client.CompleteOutbound(); } - private void OnPromptStart(SubathonPromptRun subathonPromptRun, SubathonPrompt? subathonPrompt) - { - SendPromptData(subathonPromptRun, 0); + private void OnPromptStart(SubathonPromptRun subathonPromptRun, SubathonPrompt? subathonPrompt) { + SendPromptData(subathonPromptRun); } - private void OnPromptRunUpdate(SubathonPromptRun subathonPromptRun, SubathonPrompt? subathonPrompt) - { - Task.Run(async () => - { - await using var db = await _factory.CreateDbContextAsync(); + private void OnPromptRunUpdate(SubathonPromptRun subathonPromptRun, SubathonPrompt? subathonPrompt) { + Task.Run(async () => { + await using AppDbContext db = await _factory.CreateDbContextAsync(); long current = await PromptOrchestratorService.GetCurrentCountAsync(db, subathonPromptRun.LinkedPrompt!); long progress = current - subathonPromptRun.BaselineCount; SendPromptData(subathonPromptRun, progress); }); } - private void OnPromptProgress(SubathonPromptRun subathonPromptRun, long progress) - { + private void OnPromptProgress(SubathonPromptRun subathonPromptRun, long progress) { SendPromptData(subathonPromptRun, progress); } - - internal void SendWidgetReload(Guid widgetId, float x, float y, int width, int height, float scaleX, float scaleY) - { - var data = new { - type = "widget_reload", + + internal void SendWidgetReload(Guid widgetId, float x, float y, int width, int height, float scaleX, float scaleY) { + var data = new { + type = "widget_reload", widgetId = widgetId.ToString(), x, y, width, height, scaleX, scaleY }; BroadcastObject(data, WebsocketClientMessageType.Overlay); } - internal void SendPromptData(SubathonPromptRun? run, long progress = 0) - { - object data = new - { + internal void SendPromptData(SubathonPromptRun? run, long progress = 0) { + object data = new { type = "prompt_update", status = run == null ? "None" : run.Status.ToString(), - progress = progress, + progress, target = run?.LinkedPrompt?.Value ?? 0, seconds_remaining = run?.TimeRemaining().TotalSeconds ?? 0, start_time = run?.StartedAt, @@ -127,35 +117,33 @@ internal void SendPromptData(SubathonPromptRun? run, long progress = 0) prompt_type = $"{run?.LinkedPrompt?.Type}", prompt_subtype = $"{run?.LinkedPrompt?.SubType}", prompt_eventtype = $"{run?.LinkedPrompt?.FilterEventType}", - prompt_eventtype_metafilter = run?.LinkedPrompt?.FilterMeta + prompt_eventtype_metafilter = run?.LinkedPrompt?.FilterMeta }; BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - - internal void SendWidgetVarsUpdate(Guid widgetId, - IEnumerable cssVars, IEnumerable jsVars) - { - var data = new - { + + internal void SendWidgetVarsUpdate(Guid widgetId, + IEnumerable cssVars, IEnumerable jsVars) { + var data = new { type = "widget_vars_update", widgetId = widgetId.ToString(), cssVars = cssVars.Select(v => new { name = v.Name, value = v.Value }), - jsVars = jsVars.Select(v => new { name = v.Name, value = v.Value, - injectLine = v.GetInjectLine() }) + jsVars = jsVars.Select(v => new { + name = v.Name, value = v.Value, + injectLine = v.GetInjectLine() + }) }; BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - internal void SendSubathonValues(string jsonData) - { - var newData = $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {jsonData} }}"; + internal void SendSubathonValues(string jsonData) { + var newData = + $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {jsonData} }}"; Broadcast(newData, OutboundCoalesceKey.None, WebsocketClientTypeHelper.ConfigConsumersList); } - internal void SendGoalsUpdated(List goals, long currentPoints, GoalsType type) - { - object data = new - { + internal void SendGoalsUpdated(List goals, long currentPoints, GoalsType type) { + object data = new { type = "goals_list", points = currentPoints, goals = goals.Select(goal => GoalToObject(goal, currentPoints)).ToArray(), @@ -164,50 +152,43 @@ internal void SendGoalsUpdated(List goals, long currentPoints, Goa BroadcastObject(data, OutboundCoalesceKey.GoalsList, WebsocketClientTypeHelper.ConsumersList); } - private object GoalToObject(SubathonGoal goal, long currentPoints) - { - return new - { + private object GoalToObject(SubathonGoal goal, long currentPoints) { + return new { text = goal.Text, points = goal.Points, completed = goal.Points <= currentPoints }; } - internal void SendGoalCompleted(SubathonGoal goal, long currentPoints) - { - object data = new - { + internal void SendGoalCompleted(SubathonGoal goal, long currentPoints) { + object data = new { type = "goal_completed", goal_text = goal.Text, - goal_points = goal.Points, + goal_points = goal.Points, points = currentPoints }; BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private async Task InitConnection(IWebSocketClient socket) - { - await using var db = await _factory.CreateDbContextAsync(); + private async Task InitConnection(IWebSocketClient socket) { + await using AppDbContext db = await _factory.CreateDbContextAsync(); SubathonData? subathon = await db.SubathonDatas.Include(s => s.Multiplier) .FirstOrDefaultAsync(s => s.IsActive); if (subathon is null) return; string configValues = await _valueHelper.GetAllAsJsonAsync(); SendSubathonValues(configValues); - + await SelectSendAsync(socket, SubathonDataToObject(subathon)); - - SubathonGoalSet? goalSet = await db.SubathonGoalSets.AsNoTracking(). - Include(g=> g.Goals).FirstOrDefaultAsync(g => g.IsActive); - if (goalSet != null) - { + + SubathonGoalSet? goalSet = await db.SubathonGoalSets.AsNoTracking().Include(g => g.Goals) + .FirstOrDefaultAsync(g => g.IsActive); + if (goalSet != null) { long val = subathon.Points; if (goalSet.Type == GoalsType.Money) - val = (long) Math.Floor(subathon.MoneySum ?? 0); + val = (long)Math.Floor(subathon.MoneySum ?? 0); - object data = new - { + object data = new { type = "goals_list", points = val, goals = goalSet.Goals.Select(goal => GoalToObject(goal, val)).ToArray(), @@ -219,37 +200,35 @@ private async Task InitConnection(IWebSocketClient socket) SubathonPromptRun? promptRun = await db.SubathonPromptRuns.AsNoTracking() .Include(p => p.LinkedPrompt) .FirstOrDefaultAsync(p => p.Status == SubathonPromptRunStatus.Active && p.ExpiresAt > DateTime.Now); - if (promptRun is { LinkedPrompt: not null }) - { + if (promptRun is { LinkedPrompt: not null }) { long current = await PromptOrchestratorService.GetCurrentCountAsync(db, promptRun.LinkedPrompt); long progress = current - promptRun.BaselineCount; SendPromptData(promptRun, progress); } - else - SendPromptData(null, 0); - - var totals = await EventService.GetSubathonTotalsAsync(db); + else { + SendPromptData(null); + } + + SubathonTotals? totals = await EventService.GetSubathonTotalsAsync(db); if (totals != null) await SelectSendAsync(socket, SubathonTotalsToObject(totals)); - var subTotals = await EventService.GetSubscriptionTotalsAsync(db); + SubscriptionTotals? subTotals = await EventService.GetSubscriptionTotalsAsync(db); if (subTotals != null) await SelectSendAsync(socket, SubscriptionTotalsToObject(subTotals)); - var activeWheel = await db.WheelSets + WheelSet? activeWheel = await db.WheelSets .Include(w => w.WheelItems) .ThenInclude(i => i.Action) .AsNoTracking() .FirstOrDefaultAsync(w => w.IsActive); - if (activeWheel != null) - { - int spinsOwed = StateValueHelper.Get(db, StateKeys.WheelSpinsOwed); - var items = activeWheel.WheelItems.Select(WheelItemToObject).ToList(); - var wheelData = new - { + if (activeWheel != null) { + var spinsOwed = StateValueHelper.Get(db, StateKeys.WheelSpinsOwed); + List items = activeWheel.WheelItems.Select(WheelItemToObject).ToList(); + var wheelData = new { type = "wheel_data", wheel = new { id = activeWheel.Id, name = activeWheel.Name, spin_count = activeWheel.SpinCount, items }, spins_owed = spinsOwed @@ -258,10 +237,8 @@ private async Task InitConnection(IWebSocketClient socket) } } - private object SubathonTotalsToObject(SubathonTotals totals) - { - return new - { + private object SubathonTotalsToObject(SubathonTotals totals) { + return new { type = "subathon_totals", currency = totals.Currency, money_sum = totals.MoneySum, @@ -291,54 +268,49 @@ private object SubathonTotalsToObject(SubathonTotals totals) .ToDictionary(k => k.Key.ToString(), k => k.Value), follow_count = totals.Simulated.FollowLikeTotal, follow_count_by_type = totals.Simulated.FollowLikeByEvent - .ToDictionary(k => k.Key.ToString(), k => k.Value), + .ToDictionary(k => k.Key.ToString(), k => k.Value) } }; } - private object SubscriptionTotalsToObject(SubscriptionTotals totals) - { - return new - { + private object SubscriptionTotalsToObject(SubscriptionTotals totals) { + return new { type = "subscription_totals", sub_total = totals.SubTotal, sub_total_by_type = totals.SubTotalByEvent .ToDictionary(k => k.Key.ToString(), k => k.Value), sub_total_by_type_tier = totals.SubTotalByEventTier .ToDictionary(k => k.Key.ToString(), k => k.Value), - simulated = new - { + simulated = new { sub_total = totals.Simulated.SubTotal, sub_total_by_type = totals.Simulated.SubTotalByEvent .ToDictionary(k => k.Key.ToString(), k => k.Value), sub_total_by_type_tier = totals.Simulated.SubTotalByEventTier - .ToDictionary(k => k.Key.ToString(), k => k.Value), + .ToDictionary(k => k.Key.ToString(), k => k.Value) } }; } - private object SubathonEventToObject(SubathonEvent subathonEvent) - { - var trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); + private object SubathonEventToObject(SubathonEvent subathonEvent) { + string? trueSource = subathonEvent.EventType.GetTypeTrueSource(subathonEvent.EventTypeMeta); var eventType = subathonEvent.EventType.ToString(); if (subathonEvent.EventType == SubathonEventType.GoAffProOrder - && GoAffProOrderHelper.TryGetStore(subathonEvent.EventTypeMeta, out var store)) - { + && GoAffProOrderHelper.TryGetStore(subathonEvent.EventTypeMeta, out GoAffProStore? store)) { trueSource = store.InternalName; eventType = store.InternalEventName; } - object data = new - { + + object data = new { type = "event", event_type = eventType, - source = subathonEvent.Source.ToString(), + source = subathonEvent.Source.ToString(), seconds_added = subathonEvent.GetFinalSecondsValueRaw() < 0.5 ? 0 : subathonEvent.GetFinalSecondsValue(), points_added = subathonEvent.GetFinalPointsValue(), - user = subathonEvent.User, + user = subathonEvent.User, value = subathonEvent.Value, // sometimes useful amount = subathonEvent.Amount, // sometimes useful currency = subathonEvent.Currency, // sometimes useful - command = subathonEvent.Command.ToString(), // only useful if eventType is command + command = subathonEvent.Command.ToString(), // only useful if eventType is command event_timestamp = subathonEvent.EventTimestamp, reversed = subathonEvent.WasReversed, sub_type = subathonEvent.EventType.GetSubType().ToString(), @@ -349,49 +321,41 @@ private object SubathonEventToObject(SubathonEvent subathonEvent) return data; } - internal void SendSubathonEventProcessed(SubathonEvent subathonEvent, bool effective) - { - bool showOverride = _config.GetBool("App", "ShowLockedEvents", false); + internal void SendSubathonEventProcessed(SubathonEvent subathonEvent, bool effective) { + bool showOverride = _config.GetBool("App", "ShowLockedEvents"); if (!showOverride && !subathonEvent.ProcessedToSubathon) return; BroadcastObject(SubathonEventToObject(subathonEvent), WebsocketClientTypeHelper.ConsumersList); } - internal void SendSubathonTotals(SubathonTotals totals) - { + internal void SendSubathonTotals(SubathonTotals totals) { BroadcastObject(SubathonTotalsToObject(totals), OutboundCoalesceKey.SubathonTotals, WebsocketClientTypeHelper.ConsumersList); } - internal void SendSubscriptionTotals(SubscriptionTotals totals) - { + internal void SendSubscriptionTotals(SubscriptionTotals totals) { BroadcastObject(SubscriptionTotalsToObject(totals), OutboundCoalesceKey.SubscriptionTotals, WebsocketClientTypeHelper.ConsumersList); } - internal void SendRefreshRequest(Guid id) - { - BroadcastObject(new - { + internal void SendRefreshRequest(Guid id) { + BroadcastObject(new { type = "refresh_request", id = id.ToString() }, WebsocketClientMessageType.Overlay); } - private object SubathonDataToObject(SubathonData subathon) - { + private object SubathonDataToObject(SubathonData subathon) { TimeSpan? multiplierRemaining = TimeSpan.Zero; if (subathon.Multiplier.Duration != null && subathon.Multiplier.Duration > TimeSpan.Zero - && subathon.Multiplier.Started != null) - { + && subathon.Multiplier.Started != null) { DateTime? multEndTime = subathon.Multiplier.Started + subathon.Multiplier.Duration; multiplierRemaining = multEndTime - DateTime.Now; } long roundedMoney = subathon.GetRoundedMoneySum(); double fractionalMoney = subathon.GetRoundedMoneySumWithCents(); - - object data = new - { + + object data = new { type = "subathon_timer", total_seconds = subathon.TimeRemainingRounded().TotalSeconds, days = subathon.TimeRemainingRounded().Days, @@ -403,47 +367,42 @@ private object SubathonDataToObject(SubathonData subathon) fractional_money = fractionalMoney, currency = subathon.Currency, is_paused = subathon.IsPaused, - is_locked = subathon.IsLocked, + is_locked = subathon.IsLocked, is_reversed = subathon.IsSubathonReversed(), multiplier_points = subathon.Multiplier.ApplyToPoints ? subathon.Multiplier.Multiplier : 1, multiplier_time = subathon.Multiplier.ApplyToSeconds ? subathon.Multiplier.Multiplier : 1, multiplier_start_time = subathon.Multiplier.Started, multiplier_seconds_total = Math.Round(subathon.Multiplier.Duration?.TotalSeconds ?? 0), - multiplier_seconds_remaining = Math.Round(multiplierRemaining.Value.TotalSeconds), - total_seconds_elapsed = (int) (subathon.MillisecondsElapsed / 1000), - total_seconds_added = (int) (subathon.MillisecondsCumulative / 1000) + multiplier_seconds_remaining = Math.Round(multiplierRemaining.Value.TotalSeconds), + total_seconds_elapsed = (int)(subathon.MillisecondsElapsed / 1000), + total_seconds_added = (int)(subathon.MillisecondsCumulative / 1000) }; return data; } - internal void SendSubathonDataUpdate(SubathonData subathon, DateTime time) - { + internal void SendSubathonDataUpdate(SubathonData subathon, DateTime time) { BroadcastObject(SubathonDataToObject(subathon), OutboundCoalesceKey.SubathonTimer, WebsocketClientTypeHelper.ConsumersList); } - internal void AddSocketClient(IWebSocketClient socket) - { - lock (_lock) - { + internal void AddSocketClient(IWebSocketClient socket) { + lock (_lock) { _clients.Add(socket); _logger?.LogDebug("{ClientsCount} websocket clients connected", _clients.Count); } + socket.StartOutbound(); } - - public async Task HandleWebSocketRequestAsync(IHttpContext ctx) - { - if (!ctx.IsWebSocket) - { + + public async Task HandleWebSocketRequestAsync(IHttpContext ctx) { + if (!ctx.IsWebSocket) { await ctx.WriteResponse(400, "Invalid Websocket Request"); return; } - var accept = ctx.AcceptWebSocketAsync(); + Task? accept = ctx.AcceptWebSocketAsync(); - if (accept is null) - { + if (accept is null) { await ctx.WriteResponse(400, "Not a WebSocket request"); return; } @@ -451,25 +410,22 @@ public async Task HandleWebSocketRequestAsync(IHttpContext ctx) using WebSocket socket = await accept; IWebSocketClient client = new WebSocketClient(socket); AddSocketClient(client); - + _logger?.LogDebug("New WebSocket Client Connected [{ClientClientId}].", client.ClientId); - - try - { + + try { await Listen(client); } - finally - { + finally { await client.CompleteOutboundAsync(OutboundDrainTimeout); - foreach (var clientIntegrationSource in client.IntegrationSources) - { + foreach (SubathonEventSource clientIntegrationSource in client.IntegrationSources) { WebServerEvents.RaiseWebSocketIntegrationSourceChange(clientIntegrationSource.ToString(), false); - _logger?.LogDebug("WebSocket Client disconnected for Integration: {ClientIntegrationSource}", clientIntegrationSource); + _logger?.LogDebug("WebSocket Client disconnected for Integration: {ClientIntegrationSource}", + clientIntegrationSource); } - lock (_lock) - { + lock (_lock) { _clients.Remove(client); _logger?.LogDebug("{ClientsCount} websocket clients connected", _clients.Count); } @@ -477,76 +433,66 @@ public async Task HandleWebSocketRequestAsync(IHttpContext ctx) _logger?.LogDebug("WebSocket Client Disconnected [{ClientClientId}]", client.ClientId); } } - - private async Task Listen(IWebSocketClient socket) - { + + private async Task Listen(IWebSocketClient socket) { var buffer = new byte[1024 * 8]; - while (socket.State == WebSocketState.Open) - { - var result = await socket.ReceiveAsync(buffer, CancellationToken.None); - if (result.MessageType == WebSocketMessageType.Close) - { + while (socket.State == WebSocketState.Open) { + WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) { await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client closed", CancellationToken.None); break; } string msg = Encoding.UTF8.GetString(buffer, 0, result.Count); - WebsocketClientMessageType clientMessageType = WebsocketClientMessageType.None; - try - { - var json = JsonDocument.Parse(msg); - if (json.RootElement.TryGetProperty("ws_type", out var type)) - { - switch (type.GetString()) - { + var clientMessageType = WebsocketClientMessageType.None; + try { + JsonDocument json = JsonDocument.Parse(msg); + if (json.RootElement.TryGetProperty("ws_type", out JsonElement type)) { + switch (type.GetString()) { case "ping": socket.TryEnqueue(Encoding.UTF8.GetBytes("{\"ws_type\":\"pong\"}")); break; case "hello": - _logger?.LogDebug($"[WebSocket] [{socket.ClientId}] Hello from {json.RootElement.GetProperty("origin").GetString()}"); + _logger?.LogDebug( + $"[WebSocket] [{socket.ClientId}] Hello from {json.RootElement.GetProperty("origin").GetString()}"); break; } if (Enum.TryParse(type.GetString(), out clientMessageType)) - { - if (socket.ClientTypes.Count >= 1 && !socket.ClientTypes.Contains(clientMessageType)) - { - _logger?.LogDebug($"WebSocket ClientType [{clientMessageType}] identified for client [{socket.ClientId}]"); + if (socket.ClientTypes.Count >= 1 && !socket.ClientTypes.Contains(clientMessageType)) { + _logger?.LogDebug( + $"WebSocket ClientType [{clientMessageType}] identified for client [{socket.ClientId}]"); socket.ClientTypes.Add(clientMessageType); - if (clientMessageType.IsConsumer() && socket.ClientTypes.Contains(WebsocketClientMessageType.Generic)) - { + if (clientMessageType.IsConsumer() && + socket.ClientTypes.Contains(WebsocketClientMessageType.Generic)) { socket.ClientTypes.Remove(WebsocketClientMessageType.Generic); await InitConnection(socket); } else if (clientMessageType == WebsocketClientMessageType.ValueConfig && - socket.ClientTypes.Contains(WebsocketClientMessageType.Generic)) - { + socket.ClientTypes.Contains(WebsocketClientMessageType.Generic)) { socket.ClientTypes.Remove(WebsocketClientMessageType.Generic); string configValues = await _valueHelper.GetAllAsJsonAsync(); - var newData = $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {configValues} }}"; + var newData = + $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {configValues} }}"; await SelectSendStringAsync(socket, newData); } } - } } - switch (clientMessageType) - { - case WebsocketClientMessageType.Command: - { + switch (clientMessageType) { + case WebsocketClientMessageType.Command: { if (json.RootElement.TryGetProperty("request", out JsonElement reqElem) - && string.Equals(reqElem.GetString(), "commands", StringComparison.OrdinalIgnoreCase)) - { - await SelectSendAsync(socket, new - { + && string.Equals(reqElem.GetString(), "commands", StringComparison.OrdinalIgnoreCase)) { + await SelectSendAsync(socket, new { type = "command_list", ws_type = nameof(WebsocketClientMessageType.Command), commands = BuildCommandCatalog() }); break; } + if (!json.RootElement.TryGetProperty("type", out JsonElement elem) - || !Enum.TryParse(elem.GetString()!, ignoreCase: true, out SubathonEventType seType) + || !Enum.TryParse(elem.GetString()!, true, out SubathonEventType seType) || seType == SubathonEventType.Unknown) continue; if (seType != SubathonEventType.Command) continue; @@ -559,8 +505,7 @@ private async Task Listen(IWebSocketClient socket) bool success = ExternalEventService.ProcessExternalCommand(data); data.TryGetValue("command", out JsonElement cmdElem); data.TryGetValue("context", out JsonElement ctxElem); - await SelectSendAsync(socket, new - { + await SelectSendAsync(socket, new { type = "command_ack", ws_type = nameof(WebsocketClientMessageType.Command), command = cmdElem.ValueKind == JsonValueKind.String ? cmdElem.GetString() : null, @@ -569,35 +514,33 @@ private async Task Listen(IWebSocketClient socket) }); break; } - case WebsocketClientMessageType.IntegrationSource: - { + case WebsocketClientMessageType.IntegrationSource: { if (json.RootElement.TryGetProperty("source", out JsonElement src) && - Enum.TryParse(src.GetString()!, ignoreCase: true, out SubathonEventSource source) - && !socket.IntegrationSources.Contains(source)) - { + Enum.TryParse(src.GetString()!, true, out SubathonEventSource source) + && !socket.IntegrationSources.Contains(source)) { _logger?.LogDebug($"WebSocket Client [{socket.ClientId}] added Integration: {source}"); socket.IntegrationSources.Add(source); WebServerEvents.RaiseWebSocketIntegrationSourceChange(source.ToString(), true); } - + if (!json.RootElement.TryGetProperty("type", out JsonElement elem) - || !Enum.TryParse(elem.GetString()!, ignoreCase: true, out SubathonEventType seType) + || !Enum.TryParse(elem.GetString()!, true, out SubathonEventType seType) || seType == SubathonEventType.Unknown) continue; - + Dictionary data = json.RootElement .EnumerateObject() .ToDictionary(p => p.Name, p => p.Value); - - if (((SubathonEventType?)seType).IsCurrencyDonation() && ((SubathonEventType?)seType).IsExternal()) - { + + if (((SubathonEventType?)seType).IsCurrencyDonation() && + ((SubathonEventType?)seType).IsExternal()) { if (!socket.IntegrationSources.Contains(((SubathonEventType?)seType).GetSource())) socket.IntegrationSources.Add(((SubathonEventType?)seType).GetSource()); ExternalEventService.ProcessExternalDonation(data); } - else if (((SubathonEventType?)seType).IsSubscription() && ((SubathonEventType?)seType).IsExternal()) - { + else if (((SubathonEventType?)seType).IsSubscription() && + ((SubathonEventType?)seType).IsExternal()) { if (!socket.IntegrationSources.Contains(((SubathonEventType?)seType).GetSource())) socket.IntegrationSources.Add(((SubathonEventType?)seType).GetSource()); ExternalEventService.ProcessExternalSub(data); @@ -605,40 +548,38 @@ private async Task Listen(IWebSocketClient socket) break; } - case WebsocketClientMessageType.ValueConfig when json.RootElement.TryGetProperty("data", out var data): - { + case WebsocketClientMessageType.ValueConfig + when json.RootElement.TryGetProperty("data", out JsonElement data): { int patched = await _valueHelper.PatchFromJsonDataAsync(data); var resMsg = ""; if (patched == -1) resMsg = "Error Patching"; else if (patched == 0) resMsg = "No patches needed"; else resMsg = $"Patched {patched} Values"; - object resp = new - { + object resp = new { ws_type = WebsocketClientMessageType.ValueConfig, response = resMsg }; await SelectSendAsync(socket, resp); break; } - case WebsocketClientMessageType.ValueConfig: - { + case WebsocketClientMessageType.ValueConfig: { string configValues = await _valueHelper.GetAllAsJsonAsync(); - var newData = $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {configValues} }}"; + var newData = + $"{{ \"type\": \"value_config\", \"ws_type\": \"{WebsocketClientMessageType.ValueConfig}\", \"data\": {configValues} }}"; await SelectSendStringAsync(socket, newData); break; } - case WebsocketClientMessageType.WheelControl: - { - if (!json.RootElement.TryGetProperty("id", out var idProp) - || !Guid.TryParse(idProp.GetString(), out var histId)) + case WebsocketClientMessageType.WheelControl: { + if (!json.RootElement.TryGetProperty("id", out JsonElement idProp) + || !Guid.TryParse(idProp.GetString(), out Guid histId)) break; - if (!json.RootElement.TryGetProperty("status", out var statusProp) - || !Enum.TryParse(statusProp.GetString(), ignoreCase: true, out WheelSpinHistoryStatus newStatus)) + if (!json.RootElement.TryGetProperty("status", out JsonElement statusProp) + || !Enum.TryParse(statusProp.GetString(), true, out WheelSpinHistoryStatus newStatus)) break; - await using var db = await _factory.CreateDbContextAsync(); - var history = await db.WheelSpinHistories + await using AppDbContext db = await _factory.CreateDbContextAsync(); + WheelSpinHistory? history = await db.WheelSpinHistories .Include(h => h.LinkedItem).ThenInclude(i => i!.Action) .Include(h => h.LinkedWheel) .FirstOrDefaultAsync(h => h.Id == histId); @@ -649,24 +590,21 @@ private async Task Listen(IWebSocketClient socket) history.UpdatedAt = DateTime.Now; await db.SaveChangesAsync(); - int spinsOwed = StateValueHelper.Get(db, StateKeys.WheelSpinsOwed); + var spinsOwed = StateValueHelper.Get(db, StateKeys.WheelSpinsOwed); WheelEvents.RaiseWheelSpinStatusChanged(history, spinsOwed); break; } } } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, ex.Message); } } } - private static object? WheelItemToObject(WheelItem? item) - { + private static object? WheelItemToObject(WheelItem? item) { if (item == null) return null; - return new - { + return new { id = item.Id, text = item.Text, weight = item.Weight, @@ -674,18 +612,17 @@ private async Task Listen(IWebSocketClient socket) is_infinite = item.IsInfinite, enabled = item.Enabled, index = item.Index, - action = item.Action == null ? null : (object?)new - { - type = item.Action.ActionType.ToString(), - parameter = item.Action.Parameter - } + action = item.Action == null + ? null + : (object?)new { + type = item.Action.ActionType.ToString(), + parameter = item.Action.Parameter + } }; } - private void SendWheelSpinStarted(WheelSet wheel, int delaySeconds) - { - var data = new - { + private void SendWheelSpinStarted(WheelSet wheel, int delaySeconds) { + var data = new { type = "wheel_spin_start", wheel_id = wheel.Id, wheel_name = wheel.Name, @@ -695,16 +632,13 @@ private void SendWheelSpinStarted(WheelSet wheel, int delaySeconds) BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private void SendWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int _) - { - var itemSnapshot = WheelItemToObject(item); - var data = new - { + private void SendWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHistory history, int _) { + object? itemSnapshot = WheelItemToObject(item); + var data = new { type = "wheel_spin_result", wheel = new { id = wheel.Id, name = wheel.Name }, item = itemSnapshot, - history = new - { + history = new { id = history.Id, status = history.Status.ToString(), created_at = history.CreatedAt, @@ -715,11 +649,9 @@ private void SendWheelSpinResult(WheelSet wheel, WheelItem? item, WheelSpinHisto BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private void SendWheelSpinStatusChanged(WheelSpinHistory history, int _) - { - var itemSnapshot = WheelItemToObject(history.LinkedItem); - var data = new - { + private void SendWheelSpinStatusChanged(WheelSpinHistory history, int _) { + object? itemSnapshot = WheelItemToObject(history.LinkedItem); + var data = new { type = "wheel_spin_status", history_id = history.Id, status = history.Status.ToString(), @@ -729,15 +661,13 @@ private void SendWheelSpinStatusChanged(WheelSpinHistory history, int _) BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private void SendWheelDataChanged(WheelSet wheel, int spinsOwed) - { + private void SendWheelDataChanged(WheelSet wheel, int spinsOwed) { // snapshot - var wheelId = wheel.Id; - var wheelName = wheel.Name; - var spinCount = wheel.SpinCount; - var items = wheel.WheelItems.Select(WheelItemToObject).ToList(); - var data = new - { + Guid wheelId = wheel.Id; + string wheelName = wheel.Name; + int spinCount = wheel.SpinCount; + List items = wheel.WheelItems.Select(WheelItemToObject).ToList(); + var data = new { type = "wheel_data", wheel = new { id = wheelId, name = wheelName, spin_count = spinCount, items }, spins_owed = spinsOwed @@ -745,40 +675,39 @@ private void SendWheelDataChanged(WheelSet wheel, int spinsOwed) BroadcastObject(data, WebsocketClientTypeHelper.ConsumersList); } - private void BroadcastObject(object data, OutboundCoalesceKey key, params WebsocketClientMessageType[] types) - => Broadcast(JsonSerializer.Serialize(data), key, types); + private void BroadcastObject(object data, OutboundCoalesceKey key, params WebsocketClientMessageType[] types) { + Broadcast(JsonSerializer.Serialize(data), key, types); + } - private void BroadcastObject(object data, params WebsocketClientMessageType[] types) - => Broadcast(JsonSerializer.Serialize(data), OutboundCoalesceKey.None, types); + private void BroadcastObject(object data, params WebsocketClientMessageType[] types) { + Broadcast(JsonSerializer.Serialize(data), OutboundCoalesceKey.None, types); + } - private void Broadcast(string json, OutboundCoalesceKey key, params WebsocketClientMessageType[] types) - { + private void Broadcast(string json, OutboundCoalesceKey key, params WebsocketClientMessageType[] types) { byte[] bytes = Encoding.UTF8.GetBytes(json); List clientsCopy; - lock (_lock) + lock (_lock) { clientsCopy = _clients.ToList(); + } - foreach (var ws in clientsCopy.Where(ws => + foreach (IWebSocketClient ws in clientsCopy.Where(ws => ws.State == WebSocketState.Open && ws.ClientTypes.Any(types.Contains))) - { ws.TryEnqueue(bytes, key); - } } - internal Task SelectSendAsync(IWebSocketClient client, object data) - => SelectSendStringAsync(client, JsonSerializer.Serialize(data)); + internal Task SelectSendAsync(IWebSocketClient client, object data) { + return SelectSendStringAsync(client, JsonSerializer.Serialize(data)); + } - internal Task SelectSendStringAsync(IWebSocketClient client, string data) - { + internal Task SelectSendStringAsync(IWebSocketClient client, string data) { if (client.State == WebSocketState.Open) client.TryEnqueue(Encoding.UTF8.GetBytes(data)); return Task.CompletedTask; } - public string GetWebsocketInjectionScript(string? routeId = "") - { - string script = $@" + public string GetWebsocketInjectionScript(string? routeId = "") { + var script = $@" """; - public MakeShipServiceTests() - { + public MakeShipServiceTests() { typeof(IntegrationEvents) .GetField("ConnectionUpdated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); @@ -36,49 +34,52 @@ public MakeShipServiceTests() MakeShipTrackingRegistry.Initialize([]); } - private static MakeShipService MakeService(RoutedHttpHandler handler) - { + private static MakeShipService MakeService(RoutedHttpHandler handler) { var logger = new Mock>(); var mock = new Mock(); mock.Setup(f => f.CreateClient(It.IsAny())) .Returns(() => new HttpClient(handler)); - return new MakeShipService(logger.Object, mock.Object, timerService: null); + return new MakeShipService(logger.Object, mock.Object, null); } - private static RoutedHttpHandler PetitionHandler(int sales, int pledges, string name = "Cool Plush!") => new() - { - [PetitionUrl] = (HttpStatusCode.OK, PetitionHtml), - [$"https://api.preproduct.io/api/preproducts/{PetitionProductId}.json"] = - (HttpStatusCode.OK, $$"""{"preproduct":{"name":"{{name}}","sales_actual":{{sales}} } }"""), - [$"https://storefront.makeship.com/orders/petitions/{PetitionProductId}/pledges/count"] = - (HttpStatusCode.OK, $"{pledges}") - }; - - private static RoutedHttpHandler CampaignHandler(int salesQuantity, int pledges) => new() - { - [CampaignUrl] = (HttpStatusCode.OK, CampaignHtml), - [$"https://storefront.makeship.com/products/{CampaignProductId}/sales-quantity"] = - (HttpStatusCode.OK, $$"""{"quantity":{{salesQuantity}}}"""), - [$"https://storefront.makeship.com/orders/petitions/{CampaignProductId}/pledges/count"] = - (HttpStatusCode.OK, $"{pledges}") - }; - - private static async Task> CaptureEventsFromPoll(MakeShipService service, MakeShipTracking tracking) - { + private static RoutedHttpHandler PetitionHandler(int sales, int pledges, string name = "Cool Plush!") { + return new RoutedHttpHandler { + [PetitionUrl] = (HttpStatusCode.OK, PetitionHtml), + [$"https://api.preproduct.io/api/preproducts/{PetitionProductId}.json"] = + (HttpStatusCode.OK, $$"""{"preproduct":{"name":"{{name}}","sales_actual":{{sales}} } }"""), + [$"https://storefront.makeship.com/orders/petitions/{PetitionProductId}/pledges/count"] = + (HttpStatusCode.OK, $"{pledges}") + }; + } + + private static RoutedHttpHandler CampaignHandler(int salesQuantity, int pledges) { + return new RoutedHttpHandler { + [CampaignUrl] = (HttpStatusCode.OK, CampaignHtml), + [$"https://storefront.makeship.com/products/{CampaignProductId}/sales-quantity"] = + (HttpStatusCode.OK, $$"""{"quantity":{{salesQuantity}}}"""), + [$"https://storefront.makeship.com/orders/petitions/{CampaignProductId}/pledges/count"] = + (HttpStatusCode.OK, $"{pledges}") + }; + } + + private static async Task> CaptureEventsFromPoll(MakeShipService service, + MakeShipTracking tracking) { typeof(SubathonEvents) .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); var captured = new List(); - void Handler(SubathonEvent e) => captured.Add(e); + + void Handler(SubathonEvent e) { + captured.Add(e); + } + SubathonEvents.SubathonEventCreated += Handler; - try - { + try { await service.PollTrackingAsync(tracking, CancellationToken.None); return captured; } - finally - { + finally { SubathonEvents.SubathonEventCreated -= Handler; } } @@ -91,8 +92,7 @@ private static async Task> CaptureEventsFromPoll(MakeShipSer [InlineData("https://www.makeship.com/petitions/", MakeShipProductType.Invalid)] [InlineData("", MakeShipProductType.Invalid)] [InlineData(null, MakeShipProductType.Invalid)] - public void ClassifyUrl_ReturnsExpectedType(string? url, MakeShipProductType expected) - { + public void ClassifyUrl_ReturnsExpectedType(string? url, MakeShipProductType expected) { Assert.Equal(expected, MakeShipTrackingRegistry.ClassifyUrl(url)); } @@ -101,26 +101,23 @@ public void ClassifyUrl_ReturnsExpectedType(string? url, MakeShipProductType exp [InlineData(PetitionUrl + "/", "cool-plush")] [InlineData(PetitionUrl + "?utm_source=x", "cool-plush")] [InlineData("", "")] - public void GetSlug_ExtractsLastSegment(string url, string expected) - { + public void GetSlug_ExtractsLastSegment(string url, string expected) { Assert.Equal(expected, MakeShipTrackingRegistry.GetSlug(url)); } [Fact] - public void GetDisplayNameFromSlug_TitleCasesSlug() - { + public void GetDisplayNameFromSlug_TitleCasesSlug() { Assert.Equal("Cool Jumbo Plush", MakeShipTrackingRegistry.GetDisplayNameFromSlug(CampaignUrl)); Assert.Equal("Cool Plush", MakeShipTrackingRegistry.GetDisplayNameFromSlug(PetitionUrl)); } [Fact] - public async Task Petition_FirstSync_ResolvesAndBaselinesWithoutEvents() - { + public async Task Petition_FirstSync_ResolvesAndBaselinesWithoutEvents() { var tracking = new MakeShipTracking { Url = PetitionUrl }; MakeShipTrackingRegistry.Upsert(tracking); - var service = MakeService(PetitionHandler(sales: 190, pledges: 190)); + MakeShipService service = MakeService(PetitionHandler(190, 190)); - var events = await CaptureEventsFromPoll(service, tracking); + List events = await CaptureEventsFromPoll(service, tracking); Assert.Empty(events); Assert.Equal(MakeShipProductType.Petition, tracking.ProductType); @@ -131,18 +128,17 @@ public async Task Petition_FirstSync_ResolvesAndBaselinesWithoutEvents() } [Fact] - public async Task Petition_ObservedPledgeIncrease_RaisesSingleEventWithDelta() - { + public async Task Petition_ObservedPledgeIncrease_RaisesSingleEventWithDelta() { var tracking = new MakeShipTracking { Url = PetitionUrl }; MakeShipTrackingRegistry.Upsert(tracking); - var service = MakeService(PetitionHandler(sales: 190, pledges: 190)); + MakeShipService service = MakeService(PetitionHandler(190, 190)); await service.PollTrackingAsync(tracking, CancellationToken.None); - var events = await CaptureEventsFromPoll( - MakeServiceReusingSync(service, PetitionHandler(sales: 191, pledges: 197)), tracking); + List events = await CaptureEventsFromPoll( + MakeServiceReusingSync(service, PetitionHandler(191, 197)), tracking); - var ev = Assert.Single(events); + SubathonEvent ev = Assert.Single(events); Assert.Equal(SubathonEventType.MakeShipPledge, ev.EventType); Assert.Equal(SubathonEventSource.MakeShip, ev.Source); Assert.Equal("7", ev.Value); @@ -154,23 +150,20 @@ public async Task Petition_ObservedPledgeIncrease_RaisesSingleEventWithDelta() } [Fact] - public async Task Petition_NoChange_RaisesNothing() - { + public async Task Petition_NoChange_RaisesNothing() { var tracking = new MakeShipTracking { Url = PetitionUrl }; MakeShipTrackingRegistry.Upsert(tracking); - var service = MakeService(PetitionHandler(sales: 190, pledges: 190)); + MakeShipService service = MakeService(PetitionHandler(190, 190)); await service.PollTrackingAsync(tracking, CancellationToken.None); - var events = await CaptureEventsFromPoll(service, tracking); + List events = await CaptureEventsFromPoll(service, tracking); Assert.Empty(events); } [Fact] - public async Task Petition_AlreadyResolvedFromDb_FirstSyncIsStillBaseline() - { - var tracking = new MakeShipTracking - { + public async Task Petition_AlreadyResolvedFromDb_FirstSyncIsStillBaseline() { + var tracking = new MakeShipTracking { Url = PetitionUrl, Name = "Cool Plush!", ShopifyProductId = PetitionProductId, @@ -179,48 +172,46 @@ public async Task Petition_AlreadyResolvedFromDb_FirstSyncIsStillBaseline() Orders = 150 }; MakeShipTrackingRegistry.Upsert(tracking); - var service = MakeService(PetitionHandler(sales: 190, pledges: 197)); + MakeShipService service = MakeService(PetitionHandler(190, 197)); - var events = await CaptureEventsFromPoll(service, tracking); + List events = await CaptureEventsFromPoll(service, tracking); Assert.Empty(events); Assert.Equal(197, tracking.Orders); - var second = await CaptureEventsFromPoll( - MakeServiceReusingSync(service, PetitionHandler(sales: 190, pledges: 199)), tracking); - var ev = Assert.Single(second); + List second = await CaptureEventsFromPoll( + MakeServiceReusingSync(service, PetitionHandler(190, 199)), tracking); + SubathonEvent ev = Assert.Single(second); Assert.Equal(2, ev.Amount); } [Fact] - public async Task Campaign_FirstSync_ResolvesAndBaselines() - { + public async Task Campaign_FirstSync_ResolvesAndBaselines() { var tracking = new MakeShipTracking { Url = CampaignUrl }; MakeShipTrackingRegistry.Upsert(tracking); - var service = MakeService(CampaignHandler(salesQuantity: 800, pledges: 42)); + MakeShipService service = MakeService(CampaignHandler(800, 42)); - var events = await CaptureEventsFromPoll(service, tracking); + List events = await CaptureEventsFromPoll(service, tracking); Assert.Empty(events); Assert.Equal(MakeShipProductType.Campaign, tracking.ProductType); Assert.Equal(CampaignProductId, tracking.ShopifyProductId); - Assert.Equal("Cool Jumbo Plush", tracking.Name); + Assert.Equal("Cool Jumbo Plush", tracking.Name); Assert.Equal(800, tracking.Sales); Assert.Equal(42, tracking.Orders); } [Fact] - public async Task Campaign_SalesIncrease_RaisesOrderEventWithDelta() - { + public async Task Campaign_SalesIncrease_RaisesOrderEventWithDelta() { var tracking = new MakeShipTracking { Url = CampaignUrl }; MakeShipTrackingRegistry.Upsert(tracking); - var service = MakeService(CampaignHandler(salesQuantity: 800, pledges: 42)); + MakeShipService service = MakeService(CampaignHandler(800, 42)); await service.PollTrackingAsync(tracking, CancellationToken.None); - var events = await CaptureEventsFromPoll( - MakeServiceReusingSync(service, CampaignHandler(salesQuantity: 805, pledges: 42)), tracking); + List events = await CaptureEventsFromPoll( + MakeServiceReusingSync(service, CampaignHandler(805, 42)), tracking); - var ev = Assert.Single(events); + SubathonEvent ev = Assert.Single(events); Assert.Equal(SubathonEventType.MakeShipSale, ev.EventType); Assert.Equal("5", ev.Value); Assert.Equal(5, ev.Amount); @@ -229,34 +220,31 @@ public async Task Campaign_SalesIncrease_RaisesOrderEventWithDelta() } [Fact] - public async Task Campaign_PledgeCountChangeAlone_RaisesNothing() - { + public async Task Campaign_PledgeCountChangeAlone_RaisesNothing() { var tracking = new MakeShipTracking { Url = CampaignUrl }; MakeShipTrackingRegistry.Upsert(tracking); - var service = MakeService(CampaignHandler(salesQuantity: 800, pledges: 42)); + MakeShipService service = MakeService(CampaignHandler(800, 42)); await service.PollTrackingAsync(tracking, CancellationToken.None); - var events = await CaptureEventsFromPoll( - MakeServiceReusingSync(service, CampaignHandler(salesQuantity: 800, pledges: 99)), tracking); + List events = await CaptureEventsFromPoll( + MakeServiceReusingSync(service, CampaignHandler(800, 99)), tracking); Assert.Empty(events); Assert.Equal(99, tracking.Orders); } [Fact] - public async Task Campaign_NoSalesQuantity_StaysResolvedButPollFailing() - { + public async Task Campaign_NoSalesQuantity_StaysResolvedButPollFailing() { var tracking = new MakeShipTracking { Url = CampaignUrl }; MakeShipTrackingRegistry.Upsert(tracking); - var handler = new RoutedHttpHandler - { + var handler = new RoutedHttpHandler { [CampaignUrl] = (HttpStatusCode.OK, CampaignHtml), [$"https://storefront.makeship.com/products/{CampaignProductId}/sales-quantity"] = (HttpStatusCode.NotFound, null) }; - var service = MakeService(handler); + MakeShipService service = MakeService(handler); - var events = await CaptureEventsFromPoll(service, tracking); + List events = await CaptureEventsFromPoll(service, tracking); Assert.Empty(events); Assert.Equal(MakeShipProductType.Campaign, tracking.ProductType); @@ -265,14 +253,13 @@ public async Task Campaign_NoSalesQuantity_StaysResolvedButPollFailing() } [Fact] - public async Task InvalidUrl_MarkedInvalidWithoutAnyHttpRequests() - { + public async Task InvalidUrl_MarkedInvalidWithoutAnyHttpRequests() { var tracking = new MakeShipTracking { Url = "https://www.makeship.com/about" }; MakeShipTrackingRegistry.Upsert(tracking); var handler = new RoutedHttpHandler(); - var service = MakeService(handler); + MakeShipService service = MakeService(handler); - var events = await CaptureEventsFromPoll(service, tracking); + List events = await CaptureEventsFromPoll(service, tracking); Assert.Empty(events); Assert.Equal(MakeShipProductType.Invalid, tracking.ProductType); @@ -280,15 +267,13 @@ public async Task InvalidUrl_MarkedInvalidWithoutAnyHttpRequests() } [Fact] - public async Task PetitionPage_WithoutPledgeDiv_MarkedInvalidAfterConsecutiveFailures() - { + public async Task PetitionPage_WithoutPledgeDiv_MarkedInvalidAfterConsecutiveFailures() { var tracking = new MakeShipTracking { Url = PetitionUrl }; MakeShipTrackingRegistry.Upsert(tracking); - var handler = new RoutedHttpHandler - { + var handler = new RoutedHttpHandler { [PetitionUrl] = (HttpStatusCode.OK, "nope") }; - var service = MakeService(handler); + MakeShipService service = MakeService(handler); await service.PollTrackingAsync(tracking, CancellationToken.None); Assert.Equal(MakeShipProductType.Unknown, tracking.ProductType); @@ -300,10 +285,9 @@ public async Task PetitionPage_WithoutPledgeDiv_MarkedInvalidAfterConsecutiveFai } [Fact] - public void Simulate_Pledge_RaisesSimulatedEventWithQuantity() - { - var ev = EventUtil.SubathonEventCapture.CaptureRequired( - () => MakeShipService.Simulate("Test 123", isPetition: true, count: 7)); + public void Simulate_Pledge_RaisesSimulatedEventWithQuantity() { + SubathonEvent? ev = + EventUtil.SubathonEventCapture.CaptureRequired(() => MakeShipService.Simulate("Test 123", true, 7)); Assert.NotNull(ev); Assert.Equal(SubathonEventSource.Simulated, ev!.Source); @@ -316,10 +300,9 @@ public void Simulate_Pledge_RaisesSimulatedEventWithQuantity() } [Fact] - public void Simulate_Campaign_DefaultsNameAndClampsCount() - { - var ev = EventUtil.SubathonEventCapture.CaptureRequired( - () => MakeShipService.Simulate(" ", isPetition: false, count: 0)); + public void Simulate_Campaign_DefaultsNameAndClampsCount() { + SubathonEvent? ev = + EventUtil.SubathonEventCapture.CaptureRequired(() => MakeShipService.Simulate(" ", false, 0)); Assert.NotNull(ev); Assert.Equal(SubathonEventType.MakeShipSale, ev!.EventType); @@ -329,9 +312,8 @@ public void Simulate_Campaign_DefaultsNameAndClampsCount() Assert.Equal("Test Plush", ev.EventTypeMeta); } - private static MakeShipService MakeServiceReusingSync(MakeShipService service, RoutedHttpHandler handler) - { - var factoryField = typeof(MakeShipService) + private static MakeShipService MakeServiceReusingSync(MakeShipService service, RoutedHttpHandler handler) { + FieldInfo factoryField = typeof(MakeShipService) .GetFields(BindingFlags.Instance | BindingFlags.NonPublic) .First(f => f.FieldType == typeof(IHttpClientFactory)); var mock = new Mock(); @@ -341,22 +323,20 @@ private static MakeShipService MakeServiceReusingSync(MakeShipService service, R return service; } - private sealed class RoutedHttpHandler : HttpMessageHandler - { + private sealed class RoutedHttpHandler : HttpMessageHandler { private readonly Dictionary _routes = new(); public List Requests { get; } = new(); - public (HttpStatusCode, string?) this[string urlPrefix] - { + public (HttpStatusCode, string?) this[string urlPrefix] { set => _routes[urlPrefix] = value; } - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) { var url = request.RequestUri!.ToString(); Requests.Add(url); - var match = _routes.FirstOrDefault(r => url.StartsWith(r.Key, StringComparison.OrdinalIgnoreCase)); + KeyValuePair match = + _routes.FirstOrDefault(r => url.StartsWith(r.Key, StringComparison.OrdinalIgnoreCase)); if (match.Key == null) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); @@ -366,4 +346,4 @@ protected override Task SendAsync(HttpRequestMessage reques return Task.FromResult(response); } } -} +} \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs index e469d985..e06b5248 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/PallyServiceTests.cs @@ -10,55 +10,52 @@ namespace SubathonManager.Tests.IntegrationUnitTests; [Collection("GlobalState")] -public class PallyServiceTests -{ - private static SubathonEvent? CaptureEvent(Action trigger) => - EventUtil.SubathonEventCapture.CaptureRequired(trigger); +public class PallyServiceTests { + private const string TipMessage = """ + { + "type": "campaigntip.notify", + "payload": { + "campaignTip": { + "createdAt": "2024-03-13T18:02:33.743Z", + "displayName": "Someone", + "grossAmountInCents": 500, + "id": "b1w2pjwjtb9fx0v1se9ex4n2", + "message": "", + "netAmountInCents": 500, + "processingFeeInCents": 0, + "updatedAt": "2024-03-13T18:02:33.743Z" + }, + "page": { + "id": "1627451579049x550722173620715500", + "slug": "pally", + "title": "PallyGG.gg's Team Page", + "url": "https://pally.gg/p/pally" + } + } + } + """; + + private static SubathonEvent? CaptureEvent(Action trigger) { + return EventUtil.SubathonEventCapture.CaptureRequired(trigger); + } - private static PallyService MakeService(string? apiKey = null, string room = "", bool enabled = true) - { + private static PallyService MakeService(string? apiKey = null, string room = "", bool enabled = true) { var logger = new Mock>(); var storage = new InMemorySecureStorage(apiKey != null ? new Dictionary { [StorageKeys.PallyApiKey] = apiKey } : null); - IConfig config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> - { + IConfig config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { { ("PallyGG", "Room"), room }, { ("PallyGG", "Enabled"), enabled.ToString() } }); return new PallyService(logger.Object, config, storage); } - private const string TipMessage = """ - { - "type": "campaigntip.notify", - "payload": { - "campaignTip": { - "createdAt": "2024-03-13T18:02:33.743Z", - "displayName": "Someone", - "grossAmountInCents": 500, - "id": "b1w2pjwjtb9fx0v1se9ex4n2", - "message": "", - "netAmountInCents": 500, - "processingFeeInCents": 0, - "updatedAt": "2024-03-13T18:02:33.743Z" - }, - "page": { - "id": "1627451579049x550722173620715500", - "slug": "pally", - "title": "PallyGG.gg's Team Page", - "url": "https://pally.gg/p/pally" - } - } - } - """; - [Fact] - public void ProcessMessage_ValidTip_RaisesEvent() - { - var service = MakeService("key"); - bool result = false; - var ev = CaptureEvent(() => result = service.ProcessMessage(TipMessage)); + public void ProcessMessage_ValidTip_RaisesEvent() { + PallyService service = MakeService("key"); + var result = false; + SubathonEvent? ev = CaptureEvent(() => result = service.ProcessMessage(TipMessage)); Assert.True(result); Assert.NotNull(ev); @@ -71,11 +68,10 @@ public void ProcessMessage_ValidTip_RaisesEvent() } [Fact] - public void ProcessMessage_SameTip_HasDeterministicId() - { - var service = MakeService("key"); - var ev1 = CaptureEvent(() => service.ProcessMessage(TipMessage)); - var ev2 = CaptureEvent(() => service.ProcessMessage(TipMessage)); + public void ProcessMessage_SameTip_HasDeterministicId() { + PallyService service = MakeService("key"); + SubathonEvent? ev1 = CaptureEvent(() => service.ProcessMessage(TipMessage)); + SubathonEvent? ev2 = CaptureEvent(() => service.ProcessMessage(TipMessage)); Assert.NotNull(ev1); Assert.NotNull(ev2); @@ -84,21 +80,19 @@ public void ProcessMessage_SameTip_HasDeterministicId() } [Fact] - public void ProcessMessage_MissingDisplayName_UsesAnonymous() - { - var service = MakeService("key"); - var message = TipMessage.Replace("\"displayName\": \"Someone\",", "\"displayName\": \"\","); - var ev = CaptureEvent(() => service.ProcessMessage(message)); + public void ProcessMessage_MissingDisplayName_UsesAnonymous() { + PallyService service = MakeService("key"); + string message = TipMessage.Replace("\"displayName\": \"Someone\",", "\"displayName\": \"\","); + SubathonEvent? ev = CaptureEvent(() => service.ProcessMessage(message)); Assert.NotNull(ev); Assert.Equal("Anonymous", ev!.User); } [Fact] - public void ProcessMessage_Simulated_UsesSystemAndSimulatedSource() - { - var service = MakeService("key"); - var ev = CaptureEvent(() => service.ProcessMessage(TipMessage, simulated: true)); + public void ProcessMessage_Simulated_UsesSystemAndSimulatedSource() { + PallyService service = MakeService("key"); + SubathonEvent? ev = CaptureEvent(() => service.ProcessMessage(TipMessage, true)); Assert.NotNull(ev); Assert.Equal("SYSTEM", ev!.User); @@ -111,25 +105,22 @@ public void ProcessMessage_Simulated_UsesSystemAndSimulatedSource() [InlineData("not json at all")] [InlineData("{\"type\":\"something.else\",\"payload\":{}}")] [InlineData("{\"type\":\"campaigntip.notify\",\"payload\":{}}")] - public void ProcessMessage_InvalidOrIrrelevant_ReturnsFalse(string message) - { - var service = MakeService("key"); + public void ProcessMessage_InvalidOrIrrelevant_ReturnsFalse(string message) { + PallyService service = MakeService("key"); Assert.False(service.ProcessMessage(message)); } [Fact] - public void ProcessMessage_ZeroAmount_ReturnsFalse() - { - var service = MakeService("key"); - var message = TipMessage.Replace("\"grossAmountInCents\": 500,", "\"grossAmountInCents\": 0,"); + public void ProcessMessage_ZeroAmount_ReturnsFalse() { + PallyService service = MakeService("key"); + string message = TipMessage.Replace("\"grossAmountInCents\": 500,", "\"grossAmountInCents\": 0,"); Assert.False(service.ProcessMessage(message)); } [Fact] - public void BuildUri_NoRoom_UsesFirehose() - { - var service = MakeService("my-key", room: ""); - var uri = service.BuildUri(); + public void BuildUri_NoRoom_UsesFirehose() { + PallyService service = MakeService("my-key"); + Uri uri = service.BuildUri(); Assert.StartsWith("wss://events.pally.gg", uri.ToString()); Assert.Contains("auth=my-key", uri.Query); @@ -138,19 +129,17 @@ public void BuildUri_NoRoom_UsesFirehose() } [Fact] - public void BuildUri_WithRoom_UsesActivityFeed() - { - var service = MakeService("my-key", room: "my-page"); - var uri = service.BuildUri(); + public void BuildUri_WithRoom_UsesActivityFeed() { + PallyService service = MakeService("my-key", "my-page"); + Uri uri = service.BuildUri(); Assert.Contains("channel=activity-feed", uri.Query); Assert.Contains("room=my-page", uri.Query); } [Fact] - public void SimulateTip_RaisesSimulatedUsdEvent() - { - var ev = CaptureEvent(() => PallyService.SimulateTip("12.34")); + public void SimulateTip_RaisesSimulatedUsdEvent() { + SubathonEvent? ev = CaptureEvent(() => PallyService.SimulateTip("12.34")); Assert.NotNull(ev); Assert.Equal("SYSTEM", ev!.User); @@ -161,20 +150,18 @@ public void SimulateTip_RaisesSimulatedUsdEvent() } [Fact] - public async Task StartAsync_NoApiKey_StaysDisconnected() - { - var service = MakeService(apiKey: null); + public async Task StartAsync_NoApiKey_StaysDisconnected() { + PallyService service = MakeService(); await service.StartAsync(TestContext.Current.CancellationToken); Assert.False(service.Connected); await service.StopAsync(TestContext.Current.CancellationToken); } [Fact] - public async Task StartAsync_Disabled_StaysDisconnected() - { - var service = MakeService(apiKey: "key", enabled: false); + public async Task StartAsync_Disabled_StaysDisconnected() { + PallyService service = MakeService("key", enabled: false); await service.StartAsync(TestContext.Current.CancellationToken); Assert.False(service.Connected); await service.StopAsync(TestContext.Current.CancellationToken); } -} +} \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs index 7d85b5fc..e149ed62 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs @@ -1,19 +1,19 @@ using System.Net.WebSockets; -using Moq; -using Microsoft.Extensions.Logging; -using SubathonManager.Core.Enums; -using SubathonManager.Core.Events; -using SubathonManager.Core.Models; -using SubathonManager.Services; -using SubathonManager.Integration; -using PicartoEventsLib.Abstractions.Models; -using PicartoEventsLib.Internal; using System.Reflection; using IniParser.Model; +using Microsoft.Extensions.Logging; +using Moq; +using PicartoEventsLib.Abstractions.Models; using PicartoEventsLib.Clients; +using PicartoEventsLib.Internal; using PicartoEventsLib.Options; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; +using SubathonManager.Integration; +using SubathonManager.Services; using SubathonManager.Tests.Utility; // ReSharper disable NullableWarningSuppressionIsUsed @@ -21,86 +21,44 @@ namespace SubathonManager.Tests.IntegrationUnitTests; [Collection("GlobalState")] -public class PicartoServiceTests -{ - public PicartoServiceTests() - { - var path = Path.GetFullPath(Path.Combine(string.Empty +public class PicartoServiceTests { + public PicartoServiceTests() { + string path = Path.GetFullPath(Path.Combine(string.Empty , "data")); Directory.CreateDirectory(path); - } - + } + // istg if this works - private static SubathonEvent? CaptureEvent(Action trigger) => - EventUtil.SubathonEventCapture.CaptureRequired(trigger); + private static SubathonEvent? CaptureEvent(Action trigger) { + return EventUtil.SubathonEventCapture.CaptureRequired(trigger); + } - private static IConfig MockConfig(Dictionary<(string, string), string>? values = null) - { + private static IConfig MockConfig(Dictionary<(string, string), string>? values = null) { var mock = new Mock(); mock.Setup(c => c.Get(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((string s, string k, string d) => - values != null && values.TryGetValue((s, k), out var v) ? v : d); - + values != null && values.TryGetValue((s, k), out string? v) ? v : d); + var kd = new KeyData("Commands.Pause"); kd.Value = "pause"; - mock.Setup(c => c.GetSection("Chat")).Returns(() => - { + mock.Setup(c => c.GetSection("Chat")).Returns(() => { var kdc = new KeyDataCollection(); kdc.AddKey(kd); return kdc; }); - - return mock.Object; - } - private class MockPicartoClientOptions : PicartoClientOptions - { - public override async Task InitAsync(string? channel = null) - { - var tokenProp = typeof(PicartoClientOptions).GetProperty("JwtToken", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - tokenProp!.SetValue(this, "1234567890"); - await Task.Delay(1); - } - } - - - private class TestEventClient(PicartoClientOptions options, ILogger? logger) - : PicartoEventsClient(options, (ILogger?)logger) - { - public override async Task ConnectAsync() - { - await Task.Delay(1); - OnConnected(new PicartoWebSocketConnectedEventArgs(new Uri("wss://example.com"))); - } - - public override async Task DisconnectAsync() - { - await Task.Delay(1); - OnDisconnected(new PicartoWebSocketDisconnectedEventArgs(WebSocketCloseStatus.NormalClosure, - "Disconnect", null, true)); - } - } - - private class TestChatClient(PicartoClientOptions options, ILogger? logger) - : PicartoChatClient(options, (ILogger?)logger) - { - public new async Task ConnectAsync() - { - await Task.Delay(1); - } + return mock.Object; } [Fact] - public void SimulateTip_RaisesTipEvent() - { - PicartoTip tip = new PicartoTip - { + public void SimulateTip_RaisesTipEvent() { + var tip = new PicartoTip { Username = "TestUser", Amount = 562, Channel = "TestChannel" }; - - var ev = CaptureEvent(() => PicartoService.ProcessAlert(tip)); + + SubathonEvent? ev = CaptureEvent(() => PicartoService.ProcessAlert(tip)); Assert.NotNull(ev); Assert.Equal(SubathonEventType.PicartoTip, ev.EventType); @@ -109,7 +67,7 @@ public void SimulateTip_RaisesTipEvent() Assert.Equal("562", ev.Value); Assert.Equal("TestUser", ev.User); } - + [Theory] [InlineData(4.99, 1, 1)] [InlineData(9.99, 2, 1)] @@ -117,15 +75,13 @@ public void SimulateTip_RaisesTipEvent() [InlineData(14.97, 1, 3)] [InlineData(29.94, 1, 6)] [InlineData(59.88, 1, 12)] - public void SimulateSubscription_ShouldRaiseSubEvent(decimal amount, int tier, int months) - { - PicartoSubscription sub = new PicartoSubscription - { + public void SimulateSubscription_ShouldRaiseSubEvent(decimal amount, int tier, int months) { + var sub = new PicartoSubscription { Username = "TestUser", Amount = amount, Channel = "TestChannel" }; - SubathonEvent? ev = CaptureEvent(() => + SubathonEvent? ev = CaptureEvent(() => PicartoService.ProcessAlert(sub)); Assert.NotNull(ev); @@ -137,25 +93,23 @@ public void SimulateSubscription_ShouldRaiseSubEvent(decimal amount, int tier, i Assert.Equal("TestUser", ev.User); Assert.Equal(SubathonEventSubType.SubLike, ev.EventType.GetSubType()); } - - + + [Theory] [InlineData(4.99, 1, 1)] [InlineData(4.99, 1, 2)] // two subs [InlineData(4.99, 1, 5)] // 5 subs [InlineData(14.97, 3, 1)] [InlineData(14.97, 3, 2)] // two subs - public void SimulateGiftSubscription_ShouldRaiseSubEvent(decimal amount, int months, int quantity) - { - PicartoSubscription sub = new PicartoSubscription - { + public void SimulateGiftSubscription_ShouldRaiseSubEvent(decimal amount, int months, int quantity) { + var sub = new PicartoSubscription { Username = "TestUser", Amount = amount * quantity, Quantity = quantity, Channel = "TestChannel", IsGift = true }; - SubathonEvent? ev = CaptureEvent(() => + SubathonEvent? ev = CaptureEvent(() => PicartoService.ProcessAlert(sub)); Assert.NotNull(ev); @@ -163,22 +117,20 @@ public void SimulateGiftSubscription_ShouldRaiseSubEvent(decimal amount, int mon Assert.Equal(SubathonEventSource.Picarto, ev.Source); Assert.Equal("sub", ev.Currency); Assert.Equal(months * quantity, ev.Amount); - Assert.Equal($"T1", ev.Value); + Assert.Equal("T1", ev.Value); Assert.Equal("TestUser", ev.User); Assert.Equal(SubathonEventSubType.GiftSubLike, ev.EventType.GetSubType()); } - + [Theory] [InlineData(SubathonEventSource.Picarto, "TestUser")] [InlineData(SubathonEventSource.Simulated, "SYSTEM")] - public void SimulateFollow_RaisesFollowEvent(SubathonEventSource src, string user) - { - PicartoFollow fl = new PicartoFollow - { + public void SimulateFollow_RaisesFollowEvent(SubathonEventSource src, string user) { + var fl = new PicartoFollow { Username = user, Channel = "TestChannel" }; - var ev = CaptureEvent(() => PicartoService.ProcessAlert(fl)); + SubathonEvent? ev = CaptureEvent(() => PicartoService.ProcessAlert(fl)); Assert.NotNull(ev); Assert.Equal(SubathonEventType.PicartoFollow, ev.EventType); @@ -186,30 +138,27 @@ public void SimulateFollow_RaisesFollowEvent(SubathonEventSource src, string use Assert.Equal(user, ev.User); Assert.Equal(src, ev.Source); } - + [Theory] [InlineData("!pause", false, "TestUser", "TestStreamer", false)] [InlineData("!pause", true, "TestStreamer", "TestStreamer", true)] [InlineData("!pause", false, "specialguy", "TestStreamer", true)] [InlineData("hey wassup", false, "specialguy", "TestStreamer", false)] - public void OnChatReceived_ChatCommand_InvokesCommandService(string cmd, bool isBroadcaster, string user, string channel, bool output) - { - var configCs = MockConfig(new() - { + public void OnChatReceived_ChatCommand_InvokesCommandService(string cmd, bool isBroadcaster, string user, + string channel, bool output) { + IConfig configCs = MockConfig(new Dictionary<(string, string), string> { { ("Chat", "Commands.Pause"), "pause" }, { ("Chat", "Commands.Pause.permissions.Mods"), "false" }, { ("Chat", "Commands.Pause.permissions.VIPs"), "false" }, { ("Chat", "Commands.Pause.permissions.Whitelist"), "specialguy" } }); - + CommandService.SetConfig(configCs); - PicartoChatMessage msg = new PicartoChatMessage - { + var msg = new PicartoChatMessage { ChannelId = 123456, Channel = channel, - User = new PicartoUser - { + User = new PicartoUser { Id = isBroadcaster ? 123456 : 987654, Username = user, Avatar = "", @@ -219,38 +168,33 @@ public void OnChatReceived_ChatCommand_InvokesCommandService(string cmd, bool is Message = cmd, MsgId = Guid.Empty }; - - - SubathonEvent? captured = CaptureEvent( () => PicartoService.ProcessChatMessage(msg)); - if (output) - { + + SubathonEvent? captured = CaptureEvent(() => PicartoService.ProcessChatMessage(msg)); + + if (output) { Assert.NotNull(captured); Assert.Equal(SubathonEventSource.Picarto, captured.Source); Assert.Equal(SubathonEventType.Command, captured.EventType); Assert.Equal(SubathonCommandType.Pause, captured.Command); Assert.Equal(user, captured.User); } - else - { + else { Assert.Null(captured); } } [Fact] - public void OnDisposedProperlyTest() - { - - var configCs = MockConfig(new Dictionary<(string, string), string> - { + public void OnDisposedProperlyTest() { + IConfig configCs = MockConfig(new Dictionary<(string, string), string> { { ("Chat", "Commands.Pause"), "pause" }, { ("Chat", "Commands.Pause.permissions.Mods"), "false" }, { ("Chat", "Commands.Pause.permissions.VIPs"), "false" }, { ("Chat", "Commands.Pause.permissions.Whitelist"), "specialguy" }, - { ("Picarto", "Username"), "TestChannel"} + { ("Picarto", "Username"), "TestChannel" } }); - PicartoService service = new PicartoService(null, configCs, null, null); - + var service = new PicartoService(null, configCs, null, null); + Assert.False(service._disposed); service.Dispose(); @@ -260,22 +204,18 @@ public void OnDisposedProperlyTest() Assert.True(service._disposed); } - + [Fact] - public async Task StartAsync_Test_RaisesConnections() - { - - var configCs = MockConfig(new() - { + public async Task StartAsync_Test_RaisesConnections() { + IConfig configCs = MockConfig(new Dictionary<(string, string), string> { { ("Chat", "Commands.Pause"), "pause" }, { ("Chat", "Commands.Pause.permissions.Mods"), "false" }, { ("Chat", "Commands.Pause.permissions.VIPs"), "false" }, { ("Chat", "Commands.Pause.permissions.Whitelist"), "specialguy" }, - { ("Picarto", "Username"), "TestChannel"} + { ("Picarto", "Username"), "TestChannel" } }); - PicartoService service = new PicartoService(null, configCs, null, null); - MockPicartoClientOptions mockOpts = new MockPicartoClientOptions() - { + var service = new PicartoService(null, configCs, null, null); + var mockOpts = new MockPicartoClientOptions { Channel = "TestChannel" }; await mockOpts.InitAsync(); @@ -283,15 +223,13 @@ public async Task StartAsync_Test_RaisesConnections() await service.Opts.InitAsync(); service._eventClient = new TestEventClient(mockOpts, null); service._chatClient = new TestChatClient(mockOpts, null); - - bool eventChatConnectRaised = false; - bool eventAlertsConnectRaised = false; - bool eventChatDisconnectRaised = false; - bool eventAlertsDisconnectRaised = false; - Action handler = (conn) => - { - if (conn.Source == SubathonEventSource.Picarto) - { + + var eventChatConnectRaised = false; + var eventAlertsConnectRaised = false; + var eventChatDisconnectRaised = false; + var eventAlertsDisconnectRaised = false; + Action handler = conn => { + if (conn.Source == SubathonEventSource.Picarto) { if (conn.Service == "Chat") if (conn.Status) eventChatConnectRaised = true; @@ -306,42 +244,38 @@ public async Task StartAsync_Test_RaisesConnections() } }; IntegrationEvents.ConnectionUpdated += handler; - + await service.StartAsync(TestContext.Current.CancellationToken); - + await Task.Delay(200, TestContext.Current.CancellationToken); - + Assert.True(eventChatConnectRaised); Assert.True(eventAlertsConnectRaised); - + Assert.False(service._disposed); service.Dispose(); Assert.True(service._disposed); service.Dispose(); Assert.True(service._disposed); - + await Task.Delay(200, TestContext.Current.CancellationToken); Assert.True(eventChatDisconnectRaised); Assert.True(eventAlertsDisconnectRaised); await service.StopAsync(TestContext.Current.CancellationToken); } - - + + [Fact] - public async Task Test_ChangeChannel_And_Connect_RaisesConnections() - { - - var configCs = MockConfig(new() - { + public async Task Test_ChangeChannel_And_Connect_RaisesConnections() { + IConfig configCs = MockConfig(new Dictionary<(string, string), string> { { ("Chat", "Commands.Pause"), "pause" }, { ("Chat", "Commands.Pause.permissions.Mods"), "false" }, { ("Chat", "Commands.Pause.permissions.VIPs"), "false" }, { ("Chat", "Commands.Pause.permissions.Whitelist"), "specialguy" }, - { ("Picarto", "Username"), "TestChannel"} + { ("Picarto", "Username"), "TestChannel" } }); - PicartoService service = new PicartoService(null, configCs, null, null); - MockPicartoClientOptions mockOpts = new MockPicartoClientOptions() - { + var service = new PicartoService(null, configCs, null, null); + var mockOpts = new MockPicartoClientOptions { Channel = "TestChannel" }; await mockOpts.InitAsync(); @@ -350,17 +284,15 @@ public async Task Test_ChangeChannel_And_Connect_RaisesConnections() service._eventClient = new TestEventClient(mockOpts, null); service._chatClient = new TestChatClient(mockOpts, null); - string lastChannelSeen = string.Empty; - - bool eventChatConnectRaised = false; - bool eventAlertsConnectRaised = false; - bool eventChatDisconnectRaised = false; - bool eventAlertsDisconnectRaised = false; - - Action handler = (conn) => - { - if (conn.Source == SubathonEventSource.Picarto) - { + var lastChannelSeen = string.Empty; + + var eventChatConnectRaised = false; + var eventAlertsConnectRaised = false; + var eventChatDisconnectRaised = false; + var eventAlertsDisconnectRaised = false; + + Action handler = conn => { + if (conn.Source == SubathonEventSource.Picarto) { if (conn.Service == "Chat") if (conn.Status) eventChatConnectRaised = true; @@ -374,21 +306,21 @@ public async Task Test_ChangeChannel_And_Connect_RaisesConnections() } }; IntegrationEvents.ConnectionUpdated += handler; - + await service.StartAsync(TestContext.Current.CancellationToken); - + await Task.Delay(200, TestContext.Current.CancellationToken); - + Assert.True(eventChatConnectRaised); Assert.True(eventAlertsConnectRaised); // configCs.Set("Picarto", "Username", "NewChannel"); await service.UpdateChannel(); - + await Task.Delay(200, TestContext.Current.CancellationToken); Assert.True(eventChatDisconnectRaised); Assert.True(eventAlertsDisconnectRaised); - + Assert.False(service._disposed); service.Dispose(); Assert.True(service._disposed); @@ -396,4 +328,34 @@ public async Task Test_ChangeChannel_And_Connect_RaisesConnections() Assert.True(service._disposed); } -} + private class MockPicartoClientOptions : PicartoClientOptions { + public override async Task InitAsync(string? channel = null) { + PropertyInfo? tokenProp = typeof(PicartoClientOptions).GetProperty("JwtToken", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + tokenProp!.SetValue(this, "1234567890"); + await Task.Delay(1); + } + } + + + private class TestEventClient(PicartoClientOptions options, ILogger? logger) + : PicartoEventsClient(options, (ILogger?)logger) { + public override async Task ConnectAsync() { + await Task.Delay(1); + OnConnected(new PicartoWebSocketConnectedEventArgs(new Uri("wss://example.com"))); + } + + public override async Task DisconnectAsync() { + await Task.Delay(1); + OnDisconnected(new PicartoWebSocketDisconnectedEventArgs(WebSocketCloseStatus.NormalClosure, + "Disconnect", null, true)); + } + } + + private class TestChatClient(PicartoClientOptions options, ILogger? logger) + : PicartoChatClient(options, (ILogger?)logger) { + public new async Task ConnectAsync() { + await Task.Delay(1); + } + } +} \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs index 145356f1..dc9eddc6 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs @@ -1,101 +1,92 @@ -using SubathonManager.Integration; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Moq; +using StreamElements.WebSocket.Models.Internal; +using StreamElements.WebSocket.Models.Tip; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Models; -using System.Reflection; -using Moq; -using StreamElements.WebSocket.Models.Tip; -using StreamElements.WebSocket.Models.Internal; -using Microsoft.Extensions.Logging; using SubathonManager.Core.Objects; using SubathonManager.Core.Security; +using SubathonManager.Integration; using SubathonManager.Tests.Utility; namespace SubathonManager.Tests.IntegrationUnitTests; [Collection("GlobalState")] -public class StreamElementsServiceTests -{ - - private static SubathonEvent? CaptureEvent(Action trigger) => - EventUtil.SubathonEventCapture.CaptureRequired(trigger); - +public class StreamElementsServiceTests { + private static SubathonEvent? CaptureEvent(Action trigger) { + return EventUtil.SubathonEventCapture.CaptureRequired(trigger); + } + [Fact] - public async Task InitClient_ShouldReturnFalse_WhenJwtIsEmpty() - { + public async Task InitClient_ShouldReturnFalse_WhenJwtIsEmpty() { var logger = new Mock>(); - + //config.Setup(c => c.Get("StreamElements", "JWT", "")).Returns(""); - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamElementsJwt] = "", + + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamElementsJwt] = "" }); var service = new StreamElementsService(logger.Object, storage); await service.StartAsync(TestContext.Current.CancellationToken); - + Assert.False(service.Connected); Assert.True(service.IsTokenEmpty()); - + await service.StopAsync(TestContext.Current.CancellationToken); } - + [Fact] - public void InitClient_ShouldReturnTrue_WhenJwtIsSet() - { + public void InitClient_ShouldReturnTrue_WhenJwtIsSet() { var logger = new Mock>(); - - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamElementsJwt] = "TEST_JWT", + + + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamElementsJwt] = "TEST_JWT" }); var service = new StreamElementsService(logger.Object, storage); - var result = service.InitClient(); + bool result = service.InitClient(); - Assert.True(result); + Assert.True(result); Assert.False(service.Connected); Assert.False(service.IsTokenEmpty()); } - + [Fact] - public void SetJwtToken_ShouldUpdateConfigAndSave() - { + public void SetJwtToken_ShouldUpdateConfigAndSave() { var logger = new Mock>(); - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamElementsJwt] = "OLD_JWT", + + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamElementsJwt] = "OLD_JWT" }); var service = new StreamElementsService(logger.Object, storage); - var result = service.SetJwtToken("NEW_JWT"); + bool result = service.SetJwtToken("NEW_JWT"); // config.Verify(c => c.Set("StreamElements", "JWT", "NEW_JWT"), Times.Once); // config.Verify(c => c.Save(), Times.Once); Assert.True(result); Assert.Equal(1, storage.SetSuccessCount); } - + [Fact] - public void SetJwtToken_ShouldNotUpdateConfigAndSave() - { + public void SetJwtToken_ShouldNotUpdateConfigAndSave() { var logger = new Mock>(); - - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamElementsJwt] = "OLD_JWT", + + + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamElementsJwt] = "OLD_JWT" }); var service = new StreamElementsService(logger.Object, storage); - var result = service.SetJwtToken("OLD_JWT"); + bool result = service.SetJwtToken("OLD_JWT"); // config.Verify(c => c.Set("StreamElements", "JWT", "OLD_JWT"), Times.Once); // config.Verify(c => c.Save(), Times.Never); @@ -103,16 +94,15 @@ public void SetJwtToken_ShouldNotUpdateConfigAndSave() Assert.Equal(0, storage.SetSuccessCount); Assert.Equal(1, storage.SetCount); } - + [Fact] - public void SimulateTip_ShouldRaiseSubathonEvent() - { + public void SimulateTip_ShouldRaiseSubathonEvent() { typeof(SubathonEvents) .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); - - SubathonEvent? capturedEvent = CaptureEvent( () => StreamElementsService.SimulateTip("15.5", "USD")); + + SubathonEvent? capturedEvent = CaptureEvent(() => StreamElementsService.SimulateTip("15.5")); Assert.NotNull(capturedEvent); Assert.Equal("15.5", capturedEvent.Value); @@ -120,32 +110,31 @@ public void SimulateTip_ShouldRaiseSubathonEvent() Assert.Equal(SubathonEventSource.Simulated, capturedEvent.Source); Assert.Equal(SubathonEventType.StreamElementsDonation, capturedEvent.EventType); } - + [Fact] - public void OnTip_ShouldRaiseSubathonEvent() - { + public void OnTip_ShouldRaiseSubathonEvent() { var logger = new Mock>(); - + var service = new StreamElementsService(logger.Object, new InMemorySecureStorage()); - + typeof(SubathonEvents) .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); var tip = new Tip( - tipId: Guid.NewGuid().ToString(), - username: "Test", + Guid.NewGuid().ToString(), + "Test", currency: "USD", amount: 12.5, avatar: string.Empty, message: "Test message" ); - var method = typeof(StreamElementsService) + MethodInfo? method = typeof(StreamElementsService) .GetMethod("_OnTip", BindingFlags.NonPublic | BindingFlags.Instance); - - SubathonEvent? capturedEvent = CaptureEvent( () => method?.Invoke(service, new object?[] { null, tip })); + + SubathonEvent? capturedEvent = CaptureEvent(() => method?.Invoke(service, new object?[] { null, tip })); Assert.NotNull(capturedEvent); Assert.Equal("Test", capturedEvent.User); @@ -156,15 +145,14 @@ public void OnTip_ShouldRaiseSubathonEvent() Assert.Equal(Guid.Parse(tip.TipId), capturedEvent.Id); } - + [Fact] - public void OnConnected_ShouldSetReconnectingFalse_AndLogInformation() - { + public void OnConnected_ShouldSetReconnectingFalse_AndLogInformation() { var logger = new Mock>(); - + var service = new StreamElementsService(logger.Object, new InMemorySecureStorage()); - var method = typeof(StreamElementsService) + MethodInfo? method = typeof(StreamElementsService) .GetMethod("_OnConnected", BindingFlags.NonPublic | BindingFlags.Instance); method?.Invoke(service, [null, EventArgs.Empty]); @@ -177,30 +165,27 @@ public void OnConnected_ShouldSetReconnectingFalse_AndLogInformation() It.IsAny>() ), Times.Once); } - + [Fact] - public async Task OnDisconnected_ShouldSetConnectedFalse_AndRaiseEvent() - { + public async Task OnDisconnected_ShouldSetConnectedFalse_AndRaiseEvent() { var logger = new Mock>(); - + var service = new StreamElementsService(logger.Object, new InMemorySecureStorage()); - bool eventRaised = false; - - Action handler = (conn) => - { - if (conn.Source == SubathonEventSource.StreamElements) - { + var eventRaised = false; + + Action handler = conn => { + if (conn.Source == SubathonEventSource.StreamElements) { eventRaised = true; Assert.False(conn.Status); } }; - + IntegrationEvents.ConnectionUpdated += handler; - var method = typeof(StreamElementsService) + MethodInfo? method = typeof(StreamElementsService) .GetMethod("_OnDisconnected", BindingFlags.NonPublic | BindingFlags.Instance); - + method?.Invoke(service, [null, EventArgs.Empty]); await Task.Delay(100, TestContext.Current.CancellationToken); Assert.False(service.Connected); @@ -213,27 +198,24 @@ public async Task OnDisconnected_ShouldSetConnectedFalse_AndRaiseEvent() null, It.IsAny>() ), Times.Once); - + IntegrationEvents.ConnectionUpdated -= handler; } - + [Fact] - public void OnAuthenticated_ShouldSetConnectedTrue_AndRaiseEvent() - { + public void OnAuthenticated_ShouldSetConnectedTrue_AndRaiseEvent() { var logger = new Mock>(); - + var service = new StreamElementsService(logger.Object, new InMemorySecureStorage()); - bool eventRaised = false; - + var eventRaised = false; + typeof(IntegrationEvents) .GetField("ConnectionUpdated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); - - Action handler = (conn) => - { - if (conn.Source == SubathonEventSource.StreamElements) - { + + Action handler = conn => { + if (conn.Source == SubathonEventSource.StreamElements) { eventRaised = true; Assert.True(conn.Status); } @@ -241,9 +223,8 @@ public void OnAuthenticated_ShouldSetConnectedTrue_AndRaiseEvent() IntegrationEvents.ConnectionUpdated += handler; - try - { - var method = typeof(StreamElementsService) + try { + MethodInfo? method = typeof(StreamElementsService) .GetMethod("_OnAuthenticated", BindingFlags.NonPublic | BindingFlags.Instance); method?.Invoke(service, [null, new Authenticated("testId", "testId2")]); @@ -259,49 +240,43 @@ public void OnAuthenticated_ShouldSetConnectedTrue_AndRaiseEvent() It.IsAny>() ), Times.Once); } - finally - { + finally { IntegrationEvents.ConnectionUpdated -= handler; } } [Fact] - public void OnAuthenticateError_ShouldSetConnectedFalse_AndRaiseErrorEvent() - { + public void OnAuthenticateError_ShouldSetConnectedFalse_AndRaiseErrorEvent() { var logger = new Mock>(); - + var service = new StreamElementsService(logger.Object, new InMemorySecureStorage()); - + typeof(IntegrationEvents) .GetField("ConnectionUpdated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); - + typeof(ErrorMessageEvents) .GetField("ErrorEventOccured", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); - bool eventRaised = false; - Action handler = (conn) => - { - if (conn.Source == SubathonEventSource.StreamElements) - { + var eventRaised = false; + Action handler = conn => { + if (conn.Source == SubathonEventSource.StreamElements) { eventRaised = true; Assert.False(conn.Status); } }; IntegrationEvents.ConnectionUpdated += handler; - bool errorEventRaised = false; - Action errorHandler = (level, source, message, time) => - { + var errorEventRaised = false; + Action errorHandler = (level, source, message, time) => { errorEventRaised = true; Assert.Equal(nameof(SubathonEventSource.StreamElements), source); }; ErrorMessageEvents.ErrorEventOccured += errorHandler; - try - { - var method = typeof(StreamElementsService) + try { + MethodInfo? method = typeof(StreamElementsService) .GetMethod("_OnAuthenticateError", BindingFlags.NonPublic | BindingFlags.Instance); method?.Invoke(service, [null, EventArgs.Empty]); @@ -318,11 +293,9 @@ public void OnAuthenticateError_ShouldSetConnectedFalse_AndRaiseErrorEvent() It.IsAny>() ), Times.Once); } - finally - { + finally { IntegrationEvents.ConnectionUpdated -= handler; ErrorMessageEvents.ErrorEventOccured -= errorHandler; } } - -} +} \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs index ca27a7e2..11fad195 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs @@ -1,386 +1,343 @@ -using Moq; +using System.Reflection; using Microsoft.Extensions.Logging; -using SubathonManager.Integration; -using SubathonManager.Core.Events; -using SubathonManager.Core.Enums; -using SubathonManager.Core.Models; -using Streamlabs.SocketClient.Messages; -using System.Reflection; +using Moq; using Streamlabs.SocketClient; +using Streamlabs.SocketClient.Messages; using Streamlabs.SocketClient.Messages.DataTypes; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Core.Security; +using SubathonManager.Integration; using SubathonManager.Tests.Utility; -namespace SubathonManager.Tests.IntegrationUnitTests -{ - [Collection("GlobalState")] - public class StreamLabsServiceTests - { - private static SubathonEvent? CaptureEvent(Action trigger) => - EventUtil.SubathonEventCapture.CaptureRequired(trigger); - - public StreamLabsServiceTests() - { - typeof(IntegrationEvents) - .GetField("RaiseConnectionUpdate", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - } - - private static (StreamLabsService service, Mock mockClient) MakeService( - string token = "valid_token") - { - var logger = new Mock>(); - - - var mockClient = new Mock(); - mockClient.Setup(c => c.ConnectAsync()).Returns(Task.CompletedTask); - mockClient.Setup(c => c.DisconnectAsync()).Returns(Task.CompletedTask); - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamLabsSocketToken] = token - }); - - var service = new StreamLabsService(logger.Object, storage) - { - ClientFactory = _ => mockClient.Object - }; - - return (service, mockClient); - } - - [Fact] - public void IsTokenEmpty_ShouldReturnTrue_WhenTokenNotSet() - { - var logger = new Mock>(); - - // config.Setup(c => c.Get("StreamLabs", "SocketToken", "")).Returns(""); - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamLabsSocketToken] = "", - }); - var service = new StreamLabsService(logger.Object, storage); - Assert.True(service.IsTokenEmpty()); - } - - [Fact] - public void SetSocketToken_ShouldUpdateConfigAndSave() - { - var logger = new Mock>(); - - // config.Setup(c => c.Set("StreamLabs", "SocketToken", "NEW_TOKEN")).Returns(true); - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamLabsSocketToken] = "OLD_TOKEN", - }); - var service = new StreamLabsService(logger.Object, storage); - - var result = service.SetSocketToken("NEW_TOKEN"); - - // config.Verify(c => c.Set("StreamLabs", "SocketToken", "NEW_TOKEN"), Times.Once); - // config.Verify(c => c.Save(), Times.Once); - Assert.True(result); - Assert.Equal(1, storage.SetSuccessCount); - Assert.Equal(1, storage.SetCount); - } - - [Fact] - public void SetSocketToken_ShouldNotSave_WhenConfigSetReturnsFalse() - { - var logger = new Mock>(); - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamLabsSocketToken] = "OLD_TOKEN", - }); - var service = new StreamLabsService(logger.Object, storage); - - var result = service.SetSocketToken("OLD_TOKEN"); - - Assert.False(result); - Assert.Equal(0, storage.SetSuccessCount); - Assert.Equal(1, storage.SetCount); - } - - [Fact] - public async Task InitClientAsync_ReturnsFalse_WhenTokenEmpty() - { - var logger = new Mock>(); - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamLabsSocketToken] = "", - }); - var service = new StreamLabsService(logger.Object, storage); - - var result = await service.InitClientAsync(); - - Assert.False(result); - Assert.False(service.Connected); - } - - [Fact] - public async Task InitClientAsync_ReturnsTrue_AndSetsConnected_WhenTokenValid() - { - var (service, mockClient) = MakeService("valid_token"); - - var result = await service.InitClientAsync(); - - Assert.True(result); - Assert.True(service.Connected); - mockClient.Verify(c => c.ConnectAsync(), Times.Once); - } - - [Fact] - public async Task InitClientAsync_ReturnsFalse_WhenConnectThrows() - { - var (service, mockClient) = MakeService("valid_token"); - mockClient.Setup(c => c.ConnectAsync()) - .ThrowsAsync(new Exception("connection refused")); - - var result = await service.InitClientAsync(); - - Assert.False(result); - Assert.False(service.Connected); - } - - [Fact] - public async Task InitClientAsync_RaisesConnectionUpdate_TrueOnSuccess() - { - var (service, _) = MakeService("valid_token"); - - bool? lastStatus = null; - Action handler = (conn) => - { - if (conn is { Source: SubathonEventSource.StreamLabs, Service: "Socket" }) - { - lastStatus = conn.Status; - } - }; - IntegrationEvents.ConnectionUpdated += handler; - - await service.InitClientAsync(); - - Assert.True(lastStatus); - IntegrationEvents.ConnectionUpdated -= handler; - Assert.True(Utils.GetConnection(SubathonEventSource.StreamLabs, "Socket").Status); - } - - [Fact] - public async Task StartAsync_Works() - { - var logger = new Mock>(); - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamLabsSocketToken] = "", - }); - var service = new StreamLabsService(logger.Object, storage); - - - await service.StartAsync(TestContext.Current.CancellationToken); - Assert.False(service.Connected); - await service.StopAsync(TestContext.Current.CancellationToken); - } - - [Fact] - public async Task InitClientAsync_RaisesConnectionUpdate_FalseOnFailure() - { - var (service, mockClient) = MakeService("valid_token"); - mockClient.Setup(c => c.ConnectAsync()) - .ThrowsAsync(new Exception("fail")); - - bool? lastStatus = null; - Action handler = (conn) => - { - if (conn is { Source: SubathonEventSource.StreamLabs, Service: "Socket" }) - { - lastStatus = conn.Status; - } - }; - IntegrationEvents.ConnectionUpdated += handler; - - await service.InitClientAsync(); - - Assert.False(lastStatus); - IntegrationEvents.ConnectionUpdated -= handler; - } - - [Fact] - public async Task InitClientAsync_DisconnectsExistingClient_BeforeCreatingNew() - { - var (service, mockClient) = MakeService("valid_token"); - mockClient.Setup(c => c.ConnectAsync()).Returns(Task.CompletedTask); - mockClient.Setup(c => c.DisconnectAsync()).Returns(Task.CompletedTask); - - await service.InitClientAsync(); - Assert.True(service.Connected); - - // disconnects old client first - await service.InitClientAsync(); - - mockClient.Verify(c => c.DisconnectAsync(), Times.Once); - mockClient.Verify(c => c.ConnectAsync(), Times.Exactly(2)); - } - - [Fact] - public async Task DisconnectAsync_SetsConnectedFalse_AndRaisesUpdate() - { - var (service, mockClient) = MakeService(); - await service.InitClientAsync(); - Assert.True(service.Connected); - - bool? lastStatus = null; - Action handler = (conn) => - { - if (conn is { Source: SubathonEventSource.StreamLabs, Service: "Socket" }) - { - lastStatus = conn.Status; - } - }; - IntegrationEvents.ConnectionUpdated += handler; - - await service.DisconnectAsync(); - - Assert.False(service.Connected); - Assert.False(lastStatus); - IntegrationEvents.ConnectionUpdated -= handler; - mockClient.Verify(c => c.DisconnectAsync(), Times.Once); - } - - [Fact] - public async Task DisconnectAsync_DoesNothing_WhenNotConnected() - { - var (service, mockClient) = MakeService(); - - await service.DisconnectAsync(); - - mockClient.Verify(c => c.DisconnectAsync(), Times.Never); - } - - [Fact] - public async Task StopAsync_Works_WhenConnected() - { - var (service, mockClient) = MakeService(); - await service.InitClientAsync(); - - await service.StopAsync(TestContext.Current.CancellationToken); - - Assert.False(service.Connected); - mockClient.Verify(c => c.DisconnectAsync(), Times.Once); - } - - [Fact] - public void SimulateTip_ShouldRaiseSubathonEvent() - { - - SubathonEvent? capturedEvent = CaptureEvent( () => - StreamLabsService.SimulateTip("25.5", "USD")); - - Assert.NotNull(capturedEvent); - Assert.Equal("25.5", capturedEvent.Value); - Assert.Equal("USD", capturedEvent.Currency); - Assert.Equal(SubathonEventSource.Simulated, capturedEvent.Source); - Assert.Equal(SubathonEventType.StreamLabsDonation, capturedEvent.EventType); - } - - [Fact] - public async Task OnDonation_SubscribedAfterInit_UnsubscribedAfterDisconnect() - { - var (service, mockClient) = MakeService(); - await service.InitClientAsync(); - - mockClient.VerifyAdd(c => c.OnDonation += It.IsAny>(), Times.Once); - - await service.DisconnectAsync(); - - mockClient.VerifyRemove(c => c.OnDonation -= It.IsAny>(), Times.Once); - } - - [Fact] - public async Task OnDonation_ShouldRaiseSubathonEvent() - { - var logger = new Mock>(); - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.StreamLabsSocketToken] = "", - }); - var service = new StreamLabsService(logger.Object, storage); - - - var donation = new DonationMessage - { - Id = long.MaxValue, - FormattedAmount = "$5.00", - Emotes = "", - IconClassName = "", - To = new Recipient{Name = "TestTo"}, - From = "TestFrom", - MessageId = Guid.NewGuid().ToString(), - FromUserId = 123456, - Source = "TestSource", - Priority = long.MinValue, - Name = "Donor", - Amount = (decimal)5.0, - Currency = Currency.Usd, - Message = "Test message" - }; - - var method = typeof(StreamLabsService) - .GetMethod("OnDonation", BindingFlags.NonPublic | BindingFlags.Instance); - - SubathonEvent? capturedEvent = CaptureEvent( () => - method?.Invoke(service, [null, donation])); - - Assert.NotNull(capturedEvent); - Assert.Equal("Donor", capturedEvent.User); - Assert.Equal("USD", capturedEvent.Currency); // should be uppercased - Assert.Equal("5", capturedEvent.Value); - Assert.Equal(SubathonEventSource.StreamLabs, capturedEvent.Source); - Assert.Equal(SubathonEventType.StreamLabsDonation, capturedEvent.EventType); - Assert.Equal(Guid.Parse(donation.MessageId), capturedEvent.Id); - - await service.DisconnectAsync(); - } - - [Fact] - public async Task OnDonation_RaisesSubathonEvent_WithCorrectFields() - { - var (service, _) = MakeService(); - await service.InitClientAsync(); - - var msgId = Guid.NewGuid().ToString(); - var donation = new DonationMessage - { - Id = 1, - Name = "Donor", - Amount = 5.0m, - Currency = Currency.Usd, - Message = "Test", - MessageId = msgId, - FormattedAmount = "$5.00", - Emotes = "", - IconClassName = "", - To = new Recipient { Name = "TestTo" }, - From = "TestFrom", - FromUserId = 123456, - Source = "TestSource", - Priority = 0 - }; - - SubathonEvent? captured = CaptureEvent( () => - typeof(StreamLabsService) - .GetMethod("OnDonation", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, donation])); - - Assert.NotNull(captured); - Assert.Equal("Donor", captured.User); - Assert.Equal("USD", captured.Currency); - Assert.Equal("5.0", captured.Value); - Assert.Equal(SubathonEventSource.StreamLabs, captured.Source); - Assert.Equal(SubathonEventType.StreamLabsDonation, captured.EventType); - Assert.Equal(Guid.Parse(msgId), captured.Id); - } +namespace SubathonManager.Tests.IntegrationUnitTests; + +[Collection("GlobalState")] +public class StreamLabsServiceTests { + public StreamLabsServiceTests() { + typeof(IntegrationEvents) + .GetField("RaiseConnectionUpdate", BindingFlags.Static | BindingFlags.NonPublic) + ?.SetValue(null, null); + } + + private static SubathonEvent? CaptureEvent(Action trigger) { + return EventUtil.SubathonEventCapture.CaptureRequired(trigger); + } + + private static (StreamLabsService service, Mock mockClient) MakeService( + string token = "valid_token") { + var logger = new Mock>(); + + + var mockClient = new Mock(); + mockClient.Setup(c => c.ConnectAsync()).Returns(Task.CompletedTask); + mockClient.Setup(c => c.DisconnectAsync()).Returns(Task.CompletedTask); + + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamLabsSocketToken] = token + }); + + var service = new StreamLabsService(logger.Object, storage) { + ClientFactory = _ => mockClient.Object + }; + + return (service, mockClient); + } + + [Fact] + public void IsTokenEmpty_ShouldReturnTrue_WhenTokenNotSet() { + var logger = new Mock>(); + + // config.Setup(c => c.Get("StreamLabs", "SocketToken", "")).Returns(""); + + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamLabsSocketToken] = "" + }); + var service = new StreamLabsService(logger.Object, storage); + Assert.True(service.IsTokenEmpty()); + } + + [Fact] + public void SetSocketToken_ShouldUpdateConfigAndSave() { + var logger = new Mock>(); + + // config.Setup(c => c.Set("StreamLabs", "SocketToken", "NEW_TOKEN")).Returns(true); + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamLabsSocketToken] = "OLD_TOKEN" + }); + var service = new StreamLabsService(logger.Object, storage); + + bool result = service.SetSocketToken("NEW_TOKEN"); + + // config.Verify(c => c.Set("StreamLabs", "SocketToken", "NEW_TOKEN"), Times.Once); + // config.Verify(c => c.Save(), Times.Once); + Assert.True(result); + Assert.Equal(1, storage.SetSuccessCount); + Assert.Equal(1, storage.SetCount); + } + + [Fact] + public void SetSocketToken_ShouldNotSave_WhenConfigSetReturnsFalse() { + var logger = new Mock>(); + + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamLabsSocketToken] = "OLD_TOKEN" + }); + var service = new StreamLabsService(logger.Object, storage); + + bool result = service.SetSocketToken("OLD_TOKEN"); + + Assert.False(result); + Assert.Equal(0, storage.SetSuccessCount); + Assert.Equal(1, storage.SetCount); + } + + [Fact] + public async Task InitClientAsync_ReturnsFalse_WhenTokenEmpty() { + var logger = new Mock>(); + + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamLabsSocketToken] = "" + }); + var service = new StreamLabsService(logger.Object, storage); + + bool result = await service.InitClientAsync(); + + Assert.False(result); + Assert.False(service.Connected); + } + + [Fact] + public async Task InitClientAsync_ReturnsTrue_AndSetsConnected_WhenTokenValid() { + (StreamLabsService service, Mock mockClient) = MakeService(); + + bool result = await service.InitClientAsync(); + + Assert.True(result); + Assert.True(service.Connected); + mockClient.Verify(c => c.ConnectAsync(), Times.Once); + } + + [Fact] + public async Task InitClientAsync_ReturnsFalse_WhenConnectThrows() { + (StreamLabsService service, Mock mockClient) = MakeService(); + mockClient.Setup(c => c.ConnectAsync()) + .ThrowsAsync(new Exception("connection refused")); + + bool result = await service.InitClientAsync(); + + Assert.False(result); + Assert.False(service.Connected); + } + + [Fact] + public async Task InitClientAsync_RaisesConnectionUpdate_TrueOnSuccess() { + (StreamLabsService service, _) = MakeService(); + + bool? lastStatus = null; + Action handler = conn => { + if (conn is { Source: SubathonEventSource.StreamLabs, Service: "Socket" }) lastStatus = conn.Status; + }; + IntegrationEvents.ConnectionUpdated += handler; + + await service.InitClientAsync(); + + Assert.True(lastStatus); + IntegrationEvents.ConnectionUpdated -= handler; + Assert.True(Utils.GetConnection(SubathonEventSource.StreamLabs, "Socket").Status); + } + + [Fact] + public async Task StartAsync_Works() { + var logger = new Mock>(); + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamLabsSocketToken] = "" + }); + var service = new StreamLabsService(logger.Object, storage); + + + await service.StartAsync(TestContext.Current.CancellationToken); + Assert.False(service.Connected); + await service.StopAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task InitClientAsync_RaisesConnectionUpdate_FalseOnFailure() { + (StreamLabsService service, Mock mockClient) = MakeService(); + mockClient.Setup(c => c.ConnectAsync()) + .ThrowsAsync(new Exception("fail")); + + bool? lastStatus = null; + Action handler = conn => { + if (conn is { Source: SubathonEventSource.StreamLabs, Service: "Socket" }) lastStatus = conn.Status; + }; + IntegrationEvents.ConnectionUpdated += handler; + + await service.InitClientAsync(); + + Assert.False(lastStatus); + IntegrationEvents.ConnectionUpdated -= handler; + } + + [Fact] + public async Task InitClientAsync_DisconnectsExistingClient_BeforeCreatingNew() { + (StreamLabsService service, Mock mockClient) = MakeService(); + mockClient.Setup(c => c.ConnectAsync()).Returns(Task.CompletedTask); + mockClient.Setup(c => c.DisconnectAsync()).Returns(Task.CompletedTask); + + await service.InitClientAsync(); + Assert.True(service.Connected); + + // disconnects old client first + await service.InitClientAsync(); + + mockClient.Verify(c => c.DisconnectAsync(), Times.Once); + mockClient.Verify(c => c.ConnectAsync(), Times.Exactly(2)); + } + + [Fact] + public async Task DisconnectAsync_SetsConnectedFalse_AndRaisesUpdate() { + (StreamLabsService service, Mock mockClient) = MakeService(); + await service.InitClientAsync(); + Assert.True(service.Connected); + + bool? lastStatus = null; + Action handler = conn => { + if (conn is { Source: SubathonEventSource.StreamLabs, Service: "Socket" }) lastStatus = conn.Status; + }; + IntegrationEvents.ConnectionUpdated += handler; + + await service.DisconnectAsync(); + + Assert.False(service.Connected); + Assert.False(lastStatus); + IntegrationEvents.ConnectionUpdated -= handler; + mockClient.Verify(c => c.DisconnectAsync(), Times.Once); + } + + [Fact] + public async Task DisconnectAsync_DoesNothing_WhenNotConnected() { + (StreamLabsService service, Mock mockClient) = MakeService(); + + await service.DisconnectAsync(); + + mockClient.Verify(c => c.DisconnectAsync(), Times.Never); + } + + [Fact] + public async Task StopAsync_Works_WhenConnected() { + (StreamLabsService service, Mock mockClient) = MakeService(); + await service.InitClientAsync(); + + await service.StopAsync(TestContext.Current.CancellationToken); + + Assert.False(service.Connected); + mockClient.Verify(c => c.DisconnectAsync(), Times.Once); + } + + [Fact] + public void SimulateTip_ShouldRaiseSubathonEvent() { + SubathonEvent? capturedEvent = CaptureEvent(() => + StreamLabsService.SimulateTip("25.5")); + + Assert.NotNull(capturedEvent); + Assert.Equal("25.5", capturedEvent.Value); + Assert.Equal("USD", capturedEvent.Currency); + Assert.Equal(SubathonEventSource.Simulated, capturedEvent.Source); + Assert.Equal(SubathonEventType.StreamLabsDonation, capturedEvent.EventType); + } + + [Fact] + public async Task OnDonation_SubscribedAfterInit_UnsubscribedAfterDisconnect() { + (StreamLabsService service, Mock mockClient) = MakeService(); + await service.InitClientAsync(); + + mockClient.VerifyAdd(c => c.OnDonation += It.IsAny>(), Times.Once); + + await service.DisconnectAsync(); + + mockClient.VerifyRemove(c => c.OnDonation -= It.IsAny>(), Times.Once); + } + + [Fact] + public async Task OnDonation_ShouldRaiseSubathonEvent() { + var logger = new Mock>(); + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.StreamLabsSocketToken] = "" + }); + var service = new StreamLabsService(logger.Object, storage); + + + var donation = new DonationMessage { + Id = long.MaxValue, + FormattedAmount = "$5.00", + Emotes = "", + IconClassName = "", + To = new Recipient { Name = "TestTo" }, + From = "TestFrom", + MessageId = Guid.NewGuid().ToString(), + FromUserId = 123456, + Source = "TestSource", + Priority = long.MinValue, + Name = "Donor", + Amount = (decimal)5.0, + Currency = Currency.Usd, + Message = "Test message" + }; + + MethodInfo? method = typeof(StreamLabsService) + .GetMethod("OnDonation", BindingFlags.NonPublic | BindingFlags.Instance); + + SubathonEvent? capturedEvent = CaptureEvent(() => + method?.Invoke(service, [null, donation])); + + Assert.NotNull(capturedEvent); + Assert.Equal("Donor", capturedEvent.User); + Assert.Equal("USD", capturedEvent.Currency); // should be uppercased + Assert.Equal("5", capturedEvent.Value); + Assert.Equal(SubathonEventSource.StreamLabs, capturedEvent.Source); + Assert.Equal(SubathonEventType.StreamLabsDonation, capturedEvent.EventType); + Assert.Equal(Guid.Parse(donation.MessageId), capturedEvent.Id); + + await service.DisconnectAsync(); + } + + [Fact] + public async Task OnDonation_RaisesSubathonEvent_WithCorrectFields() { + (StreamLabsService service, _) = MakeService(); + await service.InitClientAsync(); + + var msgId = Guid.NewGuid().ToString(); + var donation = new DonationMessage { + Id = 1, + Name = "Donor", + Amount = 5.0m, + Currency = Currency.Usd, + Message = "Test", + MessageId = msgId, + FormattedAmount = "$5.00", + Emotes = "", + IconClassName = "", + To = new Recipient { Name = "TestTo" }, + From = "TestFrom", + FromUserId = 123456, + Source = "TestSource", + Priority = 0 + }; + + SubathonEvent? captured = CaptureEvent(() => + typeof(StreamLabsService) + .GetMethod("OnDonation", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, donation])); + + Assert.NotNull(captured); + Assert.Equal("Donor", captured.User); + Assert.Equal("USD", captured.Currency); + Assert.Equal("5.0", captured.Value); + Assert.Equal(SubathonEventSource.StreamLabs, captured.Source); + Assert.Equal(SubathonEventType.StreamLabsDonation, captured.EventType); + Assert.Equal(Guid.Parse(msgId), captured.Id); } -} +} \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/TangiaServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TangiaServiceTests.cs index 8449bb0b..a01d1e69 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TangiaServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TangiaServiceTests.cs @@ -14,31 +14,28 @@ namespace SubathonManager.Tests.IntegrationUnitTests; [Collection("GlobalState")] -public class TangiaServiceTests -{ - public TangiaServiceTests() - { +public class TangiaServiceTests { + public TangiaServiceTests() { typeof(IntegrationEvents) .GetField("ConnectionUpdated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); } - private static SubathonEvent? CaptureEvent(Action trigger) => - EventUtil.SubathonEventCapture.CaptureRequired(trigger); + private static SubathonEvent? CaptureEvent(Action trigger) { + return EventUtil.SubathonEventCapture.CaptureRequired(trigger); + } - private static TangiaService MakeService(string? eventKey = null, IHttpClientFactory? httpFactory = null) - { + private static TangiaService MakeService(string? eventKey = null, IHttpClientFactory? httpFactory = null) { var logger = new Mock>(); var storage = new InMemorySecureStorage(eventKey != null ? new Dictionary { [StorageKeys.TangiaEventKey] = eventKey } : null); - var factory = httpFactory ?? new Mock().Object; + IHttpClientFactory factory = httpFactory ?? new Mock().Object; return new TangiaService(logger.Object, factory, storage); } private static (IHttpClientFactory Factory, MockHttpHandler Handler) MakeHttpFactory( - HttpStatusCode statusCode, string? body = null, bool throwTimeout = false) - { + HttpStatusCode statusCode, string? body = null, bool throwTimeout = false) { var handler = new MockHttpHandler(statusCode, body, throwTimeout); var mock = new Mock(); mock.Setup(f => f.CreateClient(It.IsAny())) @@ -46,33 +43,34 @@ private static (IHttpClientFactory Factory, MockHttpHandler Handler) MakeHttpFac return (mock.Object, handler); } - private static async Task CaptureEventFromPoll(TangiaService service, string key) - { + private static async Task CaptureEventFromPoll(TangiaService service, string key) { typeof(SubathonEvents) .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); SubathonEvent? captured = null; - void Handler(SubathonEvent e) => captured = e; + + void Handler(SubathonEvent e) { + captured = e; + } + SubathonEvents.SubathonEventCreated += Handler; - try - { + try { await service.PollOnceAsync(key, CancellationToken.None); return captured; } - finally - { + finally { SubathonEvents.SubathonEventCreated -= Handler; } } [Theory] - [InlineData("https://overlays.tangia.co/stream-overlay/fullscreen/evt_a1a11AaAAaaAaAaa1Aa1A1?tp=a", "evt_a1a11AaAAaaAaAaa1Aa1A1")] + [InlineData("https://overlays.tangia.co/stream-overlay/fullscreen/evt_a1a11AaAAaaAaAaa1Aa1A1?tp=a", + "evt_a1a11AaAAaaAaAaa1Aa1A1")] [InlineData("https://overlays.tangia.co/stream-overlay/fullscreen/evt_abc123", "evt_abc123")] [InlineData("https://overlays.tangia.co/stream-overlay/fullscreen/evt_xyz?foo=bar&baz=qux", "evt_xyz")] - public void TryParseEventKey_ValidUrl_ExtractsKey(string url, string expectedKey) - { - var result = TangiaService.TryParseEventKey(url, out var key); + public void TryParseEventKey_ValidUrl_ExtractsKey(string url, string expectedKey) { + bool result = TangiaService.TryParseEventKey(url, out string key); Assert.True(result); Assert.Equal(expectedKey, key); @@ -81,18 +79,16 @@ public void TryParseEventKey_ValidUrl_ExtractsKey(string url, string expectedKey [Theory] [InlineData("")] [InlineData(" ")] - public void TryParseEventKey_EmptyOrWhitespace_ReturnsFalse(string url) - { - var result = TangiaService.TryParseEventKey(url, out var key); + public void TryParseEventKey_EmptyOrWhitespace_ReturnsFalse(string url) { + bool result = TangiaService.TryParseEventKey(url, out string key); Assert.False(result); Assert.Equal(string.Empty, key); } [Fact] - public void TryParseEventKey_NotAUrl_ReturnsFalse() - { - var result = TangiaService.TryParseEventKey("not-a-valid-url", out var key); + public void TryParseEventKey_NotAUrl_ReturnsFalse() { + bool result = TangiaService.TryParseEventKey("not-a-valid-url", out string key); Assert.False(result); Assert.Equal(string.Empty, key); @@ -102,9 +98,8 @@ public void TryParseEventKey_NotAUrl_ReturnsFalse() [InlineData("https://overlays.tangia.co/stream-overlay/fullscreen/not_an_evt")] [InlineData("https://overlays.tangia.co/stream-overlay/fullscreen/")] [InlineData("https://overlays.tangia.co/")] - public void TryParseEventKey_NoEvtSegment_ReturnsFalse(string url) - { - var result = TangiaService.TryParseEventKey(url, out var key); + public void TryParseEventKey_NoEvtSegment_ReturnsFalse(string url) { + bool result = TangiaService.TryParseEventKey(url, out string key); Assert.False(result); Assert.Equal(string.Empty, key); @@ -112,9 +107,8 @@ public void TryParseEventKey_NoEvtSegment_ReturnsFalse(string url) [Fact] - public void SimulateTangiaTokens_RaisesEventWithCorrectFields() - { - var ev = CaptureEvent(() => TangiaService.SimulateTangiaTokens(250)); + public void SimulateTangiaTokens_RaisesEventWithCorrectFields() { + SubathonEvent? ev = CaptureEvent(() => TangiaService.SimulateTangiaTokens(250)); Assert.NotNull(ev); Assert.Equal(SubathonEventType.TangiaTokens, ev.EventType); @@ -126,110 +120,135 @@ public void SimulateTangiaTokens_RaisesEventWithCorrectFields() [Fact] - public async Task StartAsync_NoKey_BroadcastsFalse() - { - var service = MakeService(); + public async Task StartAsync_NoKey_BroadcastsFalse() { + TangiaService service = MakeService(); bool? status = null; - void Handler(IntegrationConnection c) { if (c.Source == SubathonEventSource.Tangia) status = c.Status; } + + void Handler(IntegrationConnection c) { + if (c.Source == SubathonEventSource.Tangia) status = c.Status; + } + IntegrationEvents.ConnectionUpdated += Handler; - try { await service.StartAsync(TestContext.Current.CancellationToken); } - finally { IntegrationEvents.ConnectionUpdated -= Handler; } + try { + await service.StartAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } Assert.False(status); } [Fact] - public async Task StartAsync_EmptyKey_BroadcastsFalse() - { - var service = MakeService(eventKey: ""); + public async Task StartAsync_EmptyKey_BroadcastsFalse() { + TangiaService service = MakeService(""); bool? status = null; - void Handler(IntegrationConnection c) { if (c.Source == SubathonEventSource.Tangia) status = c.Status; } + + void Handler(IntegrationConnection c) { + if (c.Source == SubathonEventSource.Tangia) status = c.Status; + } + IntegrationEvents.ConnectionUpdated += Handler; - try { await service.StartAsync(TestContext.Current.CancellationToken); } - finally { IntegrationEvents.ConnectionUpdated -= Handler; } + try { + await service.StartAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } Assert.False(status); } [Fact] - public async Task StartAsync_ValidKey_BroadcastsTrue() - { - var (factory, _) = MakeHttpFactory(HttpStatusCode.NoContent); - var service = MakeService("evt_testkey", factory); + public async Task StartAsync_ValidKey_BroadcastsTrue() { + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.NoContent); + TangiaService service = MakeService("evt_testkey", factory); bool? status = null; - void Handler(IntegrationConnection c) { if (c.Source == SubathonEventSource.Tangia) status = c.Status; } + + void Handler(IntegrationConnection c) { + if (c.Source == SubathonEventSource.Tangia) status = c.Status; + } + IntegrationEvents.ConnectionUpdated += Handler; - try { await service.StartAsync(TestContext.Current.CancellationToken); } - finally { IntegrationEvents.ConnectionUpdated -= Handler; } + try { + await service.StartAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } Assert.True(status); await service.StopAsync(TestContext.Current.CancellationToken); } [Fact] - public async Task StopAsync_BroadcastsFalse() - { - var service = MakeService("evt_testkey"); + public async Task StopAsync_BroadcastsFalse() { + TangiaService service = MakeService("evt_testkey"); bool? status = null; - void Handler(IntegrationConnection c) { if (c.Source == SubathonEventSource.Tangia) status = c.Status; } + + void Handler(IntegrationConnection c) { + if (c.Source == SubathonEventSource.Tangia) status = c.Status; + } + IntegrationEvents.ConnectionUpdated += Handler; - try { await service.StopAsync(TestContext.Current.CancellationToken); } - finally { IntegrationEvents.ConnectionUpdated -= Handler; } + try { + await service.StopAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } Assert.False(status); } [Fact] - public async Task PollOnce_NoContent_NoEventRaised() - { - var (factory, _) = MakeHttpFactory(HttpStatusCode.NoContent); - var service = MakeService("evt_testkey", factory); + public async Task PollOnce_NoContent_NoEventRaised() { + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.NoContent); + TangiaService service = MakeService("evt_testkey", factory); - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.Null(ev); } [Fact] - public async Task PollOnce_ErrorStatus_NoEventRaised() - { - var (factory, _) = MakeHttpFactory(HttpStatusCode.InternalServerError, "error"); - var service = MakeService("evt_testkey", factory); + public async Task PollOnce_ErrorStatus_NoEventRaised() { + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.InternalServerError, "error"); + TangiaService service = MakeService("evt_testkey", factory); - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.Null(ev); } [Fact] - public async Task PollOnce_ValidEvent_RaisesEventWithCorrectFields() - { + public async Task PollOnce_ValidEvent_RaisesEventWithCorrectFields() { const string json = """ - { - "Events": [{ - "UnixMS": 1718600000000, - "EventID": "evt_abc123", - "Data": { - "type": "interaction", - "URL": "", - "OverlayParams": { - "TriggerData": { "price": 50 }, - "BuyerInfo": { "name": "TestUser", "has-plus": false }, - "name": "TestUser" - } - } - }] - } - """; - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); - var service = MakeService("evt_testkey", factory); - - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + { + "Events": [{ + "UnixMS": 1718600000000, + "EventID": "evt_abc123", + "Data": { + "type": "interaction", + "URL": "", + "OverlayParams": { + "TriggerData": { "price": 50 }, + "BuyerInfo": { "name": "TestUser", "has-plus": false }, + "name": "TestUser" + } + } + }] + } + """; + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); + TangiaService service = MakeService("evt_testkey", factory); + + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.NotNull(ev); Assert.Equal(SubathonEventType.TangiaTokens, ev.EventType); @@ -242,21 +261,20 @@ public async Task PollOnce_ValidEvent_RaisesEventWithCorrectFields() [Theory] [InlineData("evt__test_abc")] [InlineData("evt_xyz_test_123")] - public async Task PollOnce_TestEventId_Filtered(string eventId) - { + public async Task PollOnce_TestEventId_Filtered(string eventId) { var json = $$""" - { - "Events": [{ - "UnixMS": 1718600000000, - "EventID": "{{eventId}}", - "Data": { "OverlayParams": { "TriggerData": { "price": 50 }, "BuyerInfo": { "name": "User" } } } - }] - } - """; - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); - var service = MakeService("evt_testkey", factory); - - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + { + "Events": [{ + "UnixMS": 1718600000000, + "EventID": "{{eventId}}", + "Data": { "OverlayParams": { "TriggerData": { "price": 50 }, "BuyerInfo": { "name": "User" } } } + }] + } + """; + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); + TangiaService service = MakeService("evt_testkey", factory); + + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.Null(ev); } @@ -264,41 +282,39 @@ public async Task PollOnce_TestEventId_Filtered(string eventId) [Theory] [InlineData("evt__cp_abc")] [InlineData("evt_xyz_cp_123")] - public async Task PollOnce_CpEventId_Filtered(string eventId) - { + public async Task PollOnce_CpEventId_Filtered(string eventId) { var json = $$""" - { - "Events": [{ - "UnixMS": 1718600000000, - "EventID": "{{eventId}}", - "Data": { "OverlayParams": { "TriggerData": { "price": 50 }, "BuyerInfo": { "name": "User" } } } - }] - } - """; - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); - var service = MakeService("evt_testkey", factory); - - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + { + "Events": [{ + "UnixMS": 1718600000000, + "EventID": "{{eventId}}", + "Data": { "OverlayParams": { "TriggerData": { "price": 50 }, "BuyerInfo": { "name": "User" } } } + }] + } + """; + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); + TangiaService service = MakeService("evt_testkey", factory); + + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.Null(ev); } [Fact] - public async Task PollOnce_EmptyEventId_Filtered() - { + public async Task PollOnce_EmptyEventId_Filtered() { const string json = """ - { - "Events": [{ - "UnixMS": 1718600000000, - "EventID": "", - "Data": { "OverlayParams": { "TriggerData": { "price": 50 }, "BuyerInfo": { "name": "User" } } } - }] - } - """; - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); - var service = MakeService("evt_testkey", factory); - - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + { + "Events": [{ + "UnixMS": 1718600000000, + "EventID": "", + "Data": { "OverlayParams": { "TriggerData": { "price": 50 }, "BuyerInfo": { "name": "User" } } } + }] + } + """; + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); + TangiaService service = MakeService("evt_testkey", factory); + + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.Null(ev); } @@ -307,127 +323,118 @@ public async Task PollOnce_EmptyEventId_Filtered() [InlineData(0)] [InlineData(-1)] [InlineData(-100)] - public async Task PollOnce_ZeroOrNegativePrice_Filtered(int price) - { + public async Task PollOnce_ZeroOrNegativePrice_Filtered(int price) { var json = $$""" - { - "Events": [{ - "UnixMS": 1718600000000, - "EventID": "evt_valid123", - "Data": { "OverlayParams": { "TriggerData": { "price": {{price}} }, "BuyerInfo": { "name": "User" } } } - }] - } - """; - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); - var service = MakeService("evt_testkey", factory); - - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + { + "Events": [{ + "UnixMS": 1718600000000, + "EventID": "evt_valid123", + "Data": { "OverlayParams": { "TriggerData": { "price": {{price}} }, "BuyerInfo": { "name": "User" } } } + }] + } + """; + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); + TangiaService service = MakeService("evt_testkey", factory); + + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.Null(ev); } [Fact] - public async Task PollOnce_BuyerInfoName_UsedAsUser() - { + public async Task PollOnce_BuyerInfoName_UsedAsUser() { const string json = """ - { - "Events": [{ - "UnixMS": 1718600000000, - "EventID": "evt_abc123", - "Data": { "OverlayParams": { "TriggerData": { "price": 10 }, "BuyerInfo": { "name": "BuyerName" }, "name": "BuyerName" } } - }] - } - """; - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); - var service = MakeService("evt_testkey", factory); - - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + { + "Events": [{ + "UnixMS": 1718600000000, + "EventID": "evt_abc123", + "Data": { "OverlayParams": { "TriggerData": { "price": 10 }, "BuyerInfo": { "name": "BuyerName" }, "name": "BuyerName" } } + }] + } + """; + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); + TangiaService service = MakeService("evt_testkey", factory); + + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.NotNull(ev); Assert.Equal("BuyerName", ev.User); } [Fact] - public async Task PollOnce_NoBuyerInfo_FallsBackToOverlayParamsName() - { + public async Task PollOnce_NoBuyerInfo_FallsBackToOverlayParamsName() { const string json = """ - { - "Events": [{ - "UnixMS": 1718600000000, - "EventID": "evt_abc123", - "Data": { "OverlayParams": { "TriggerData": { "price": 10 }, "name": "BuyerName" } } - }] - } - """; - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); - var service = MakeService("evt_testkey", factory); - - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + { + "Events": [{ + "UnixMS": 1718600000000, + "EventID": "evt_abc123", + "Data": { "OverlayParams": { "TriggerData": { "price": 10 }, "name": "BuyerName" } } + }] + } + """; + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); + TangiaService service = MakeService("evt_testkey", factory); + + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.NotNull(ev); Assert.Equal("BuyerName", ev.User); } [Fact] - public async Task PollOnce_NoNamesAtAll_FallsBackToDefault() - { + public async Task PollOnce_NoNamesAtAll_FallsBackToDefault() { const string json = """ - { - "Events": [{ - "UnixMS": 1718600000000, - "EventID": "evt_abc123", - "Data": { "OverlayParams": { "TriggerData": { "price": 10 } } } - }] - } - """; - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); - var service = MakeService("evt_testkey", factory); - - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + { + "Events": [{ + "UnixMS": 1718600000000, + "EventID": "evt_abc123", + "Data": { "OverlayParams": { "TriggerData": { "price": 10 } } } + }] + } + """; + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); + TangiaService service = MakeService("evt_testkey", factory); + + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.NotNull(ev); Assert.Equal("Tangia User", ev.User); } [Fact] - public async Task PollOnce_EmptyEventsList_NoEventRaised() - { + public async Task PollOnce_EmptyEventsList_NoEventRaised() { const string json = """{ "Events": [] }"""; - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); - var service = MakeService("evt_testkey", factory); + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, json); + TangiaService service = MakeService("evt_testkey", factory); - var ev = await CaptureEventFromPoll(service, "evt_testkey"); + SubathonEvent? ev = await CaptureEventFromPoll(service, "evt_testkey"); Assert.Null(ev); } [Fact] - public async Task PollOnce_InvalidJson_DoesNotThrow() - { - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, "{{not valid json{{"); - var service = MakeService("evt_testkey", factory); + public async Task PollOnce_InvalidJson_DoesNotThrow() { + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, "{{not valid json{{"); + TangiaService service = MakeService("evt_testkey", factory); - var ex = await Record.ExceptionAsync(() => service.PollOnceAsync("evt_testkey", CancellationToken.None)); + Exception? ex = await Record.ExceptionAsync(() => service.PollOnceAsync("evt_testkey", CancellationToken.None)); Assert.Null(ex); } [Fact] - public async Task PollOnce_Timeout_DoesNotThrow() - { - var (factory, _) = MakeHttpFactory(HttpStatusCode.OK, throwTimeout: true); - var service = MakeService("evt_testkey", factory); + public async Task PollOnce_Timeout_DoesNotThrow() { + (IHttpClientFactory factory, _) = MakeHttpFactory(HttpStatusCode.OK, throwTimeout: true); + TangiaService service = MakeService("evt_testkey", factory); - var ex = await Record.ExceptionAsync(() => service.PollOnceAsync("evt_testkey", CancellationToken.None)); + Exception? ex = await Record.ExceptionAsync(() => service.PollOnceAsync("evt_testkey", CancellationToken.None)); Assert.Null(ex); } private class MockHttpHandler(HttpStatusCode statusCode, string? body = null, bool throwTimeout = false) - : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { + : HttpMessageHandler { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) { if (throwTimeout) throw new TaskCanceledException("Simulated timeout"); @@ -437,4 +444,4 @@ protected override Task SendAsync(HttpRequestMessage reques return Task.FromResult(response); } } -} +} \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs index 81556d14..f84b7ae5 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/ThroneServiceTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Text; using DevTunnels.Client; using DevTunnels.Client.Tunnels; using Microsoft.Extensions.Logging; @@ -15,10 +16,8 @@ namespace SubathonManager.Tests.IntegrationUnitTests; [Collection("GlobalState")] -public class ThroneServiceTests -{ - public ThroneServiceTests() - { +public class ThroneServiceTests { + public ThroneServiceTests() { typeof(IntegrationEvents) .GetField("ConnectionUpdated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); @@ -26,21 +25,21 @@ public ThroneServiceTests() .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); } - + private static (ThroneService service, DevTunnelsService devTunnels) MakeService( - Dictionary<(string, string), string>? configValues = null) - { + Dictionary<(string, string), string>? configValues = null) { var logger = new Mock>(); var dtLogger = new Mock>(); var mockClient = new Mock(); mockClient.Setup(c => c.CreateOrUpdateTunnelAsync( It.IsAny(), It.IsAny(), It.IsAny())) - .Returns( - async (_, _, ct) => { await Task.Delay(Timeout.Infinite, ct); return new DevTunnelStatus(); }); + .Returns(async (_, _, ct) => { + await Task.Delay(Timeout.Infinite, ct); + return new DevTunnelStatus(); + }); - IConfig dtConfig = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> - { + IConfig dtConfig = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { { ("Server", "Port"), "14040" } }); var devTunnels = new DevTunnelsService(dtLogger.Object, dtConfig, mockClient.Object); @@ -53,8 +52,7 @@ private static (ThroneService service, DevTunnelsService devTunnels) MakeService private static string BuildGiftPurchasedJson( string eventId, string gifterUsername, string itemName, - int priceCents, string currency = "USD", bool isSurprise = false) - { + int priceCents, string currency = "USD", bool isSurprise = false) { return $$""" { "event_id": "{{eventId}}", @@ -76,8 +74,7 @@ private static string BuildGiftPurchasedJson( private static string BuildContributionPurchasedJson( string eventId, string gifterUsername, string itemName, - int amountCents, string currency = "USD") - { + int amountCents, string currency = "USD") { return $$""" { "event_id": "{{eventId}}", @@ -97,8 +94,7 @@ private static string BuildContributionPurchasedJson( } private static string BuildCrowdfundedJson( - string eventId, string itemName, int priceCents, string currency = "USD") - { + string eventId, string itemName, int priceCents, string currency = "USD") { return $$""" { "event_id": "{{eventId}}", @@ -116,10 +112,13 @@ private static string BuildCrowdfundedJson( """; } - private static SubathonEvent? CaptureEvent(Action action) - { + private static SubathonEvent? CaptureEvent(Action action) { SubathonEvent? captured = null; - void Handler(SubathonEvent e) => captured = e; + + void Handler(SubathonEvent e) { + captured = e; + } + SubathonEvents.SubathonEventCreated += Handler; action(); SubathonEvents.SubathonEventCreated -= Handler; @@ -127,64 +126,71 @@ private static string BuildCrowdfundedJson( } [Fact] - public async Task StartAsync_WhenDisabled_BroadcastsDisconnectedStatus() - { + public async Task StartAsync_WhenDisabled_BroadcastsDisconnectedStatus() { (ThroneService service, _) = MakeService(); bool? status = null; - void Handler(IntegrationConnection conn) - { + + void Handler(IntegrationConnection conn) { if (conn.Source == SubathonEventSource.Throne) status = conn.Status; } IntegrationEvents.ConnectionUpdated += Handler; - try { await service.StartAsync(TestContext.Current.CancellationToken); } - finally { IntegrationEvents.ConnectionUpdated -= Handler; } + try { + await service.StartAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } Assert.False(status); await service.StopAsync(TestContext.Current.CancellationToken); } [Fact] - public async Task StartAsync_WhenEnabled_DoesNotImmediatelyBroadcastConnected() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public async Task StartAsync_WhenEnabled_DoesNotImmediatelyBroadcastConnected() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", "Enabled"), "True" } }); - bool receivedTrue = false; - void Handler(IntegrationConnection conn) - { + var receivedTrue = false; + + void Handler(IntegrationConnection conn) { if (conn is { Source: SubathonEventSource.Throne, Status: true }) receivedTrue = true; } IntegrationEvents.ConnectionUpdated += Handler; - try { await service.StartAsync(TestContext.Current.CancellationToken); } - finally { IntegrationEvents.ConnectionUpdated -= Handler; } + try { + await service.StartAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } Assert.False(receivedTrue); await service.StopAsync(TestContext.Current.CancellationToken); } - - + + [Fact] - public async Task StartAsync_WhenDisabled() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public async Task StartAsync_WhenDisabled() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", "Enabled"), "False" } }); - bool receivedFalse = false; - void Handler(IntegrationConnection conn) - { + var receivedFalse = false; + + void Handler(IntegrationConnection conn) { if (conn is { Source: SubathonEventSource.Throne, Status: false }) receivedFalse = true; } IntegrationEvents.ConnectionUpdated += Handler; - try { await service.StartAsync(TestContext.Current.CancellationToken); } - finally { IntegrationEvents.ConnectionUpdated -= Handler; } + try { + await service.StartAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } Assert.True(receivedFalse); await service.StopAsync(TestContext.Current.CancellationToken); @@ -192,42 +198,42 @@ void Handler(IntegrationConnection conn) [Fact] - public async Task StopAsync_BroadcastsDisconnectedStatus() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public async Task StopAsync_BroadcastsDisconnectedStatus() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", "Enabled"), "True" } }); await service.StartAsync(TestContext.Current.CancellationToken); bool? status = null; - void Handler(IntegrationConnection conn) - { + + void Handler(IntegrationConnection conn) { if (conn.Source == SubathonEventSource.Throne) status = conn.Status; } IntegrationEvents.ConnectionUpdated += Handler; - try { await service.StopAsync(TestContext.Current.CancellationToken); } - finally { IntegrationEvents.ConnectionUpdated -= Handler; } + try { + await service.StopAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } Assert.False(status); } [Fact] - public async Task HandleWebhookAsync_WhenDisabled_DoesNotRaiseEvent() - { + public async Task HandleWebhookAsync_WhenDisabled_DoesNotRaiseEvent() { (ThroneService service, _) = MakeService(); // Enabled = false by default - var json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Cat Plushie", 1000); - var body = System.Text.Encoding.UTF8.GetBytes(json); - var headers = new Dictionary - { + string json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Cat Plushie", 1000); + byte[] body = Encoding.UTF8.GetBytes(json); + var headers = new Dictionary { { "X-Signature-Timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() }, { "X-Signature-Ed25519", "deadbeef" } }; - var ev = CaptureEvent(() => + SubathonEvent? ev = CaptureEvent(() => service.HandleWebhookAsync(body, headers, TestContext.Current.CancellationToken).GetAwaiter().GetResult()); Assert.Null(ev); @@ -235,21 +241,18 @@ public async Task HandleWebhookAsync_WhenDisabled_DoesNotRaiseEvent() } [Fact] - public async Task HandleWebhookAsync_MissingTimestampHeader_DoesNotRaiseEvent() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public async Task HandleWebhookAsync_MissingTimestampHeader_DoesNotRaiseEvent() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", "Enabled"), "True" } }); - var body = "{}"u8.ToArray(); - var headers = new Dictionary - { + byte[] body = "{}"u8.ToArray(); + var headers = new Dictionary { { "X-Signature-Ed25519", "deadbeef" } // no X-Signature-Timestamp }; - var ev = CaptureEvent(() => + SubathonEvent? ev = CaptureEvent(() => service.HandleWebhookAsync(body, headers, TestContext.Current.CancellationToken).GetAwaiter().GetResult()); Assert.Null(ev); @@ -257,21 +260,18 @@ public async Task HandleWebhookAsync_MissingTimestampHeader_DoesNotRaiseEvent() } [Fact] - public async Task HandleWebhookAsync_MissingSignatureHeader_DoesNotRaiseEvent() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public async Task HandleWebhookAsync_MissingSignatureHeader_DoesNotRaiseEvent() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", "Enabled"), "True" } }); - var body = "{}"u8.ToArray(); - var headers = new Dictionary - { + byte[] body = "{}"u8.ToArray(); + var headers = new Dictionary { { "X-Signature-Timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() } // no X-Signature-Ed25519 }; - var ev = CaptureEvent(() => + SubathonEvent? ev = CaptureEvent(() => service.HandleWebhookAsync(body, headers, TestContext.Current.CancellationToken).GetAwaiter().GetResult()); Assert.Null(ev); @@ -279,22 +279,19 @@ public async Task HandleWebhookAsync_MissingSignatureHeader_DoesNotRaiseEvent() } [Fact] - public async Task HandleWebhookAsync_StaleTimestamp_DoesNotRaiseEvent() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public async Task HandleWebhookAsync_StaleTimestamp_DoesNotRaiseEvent() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", "Enabled"), "True" } }); var staleTimestamp = DateTimeOffset.UtcNow.AddMinutes(-15).ToUnixTimeSeconds().ToString(); - var body = "{}"u8.ToArray(); - var headers = new Dictionary - { + byte[] body = "{}"u8.ToArray(); + var headers = new Dictionary { { "X-Signature-Timestamp", staleTimestamp }, { "X-Signature-Ed25519", "deadbeef" } }; - var ev = CaptureEvent(() => + SubathonEvent? ev = CaptureEvent(() => service.HandleWebhookAsync(body, headers, TestContext.Current.CancellationToken).GetAwaiter().GetResult()); Assert.Null(ev); @@ -302,23 +299,21 @@ public async Task HandleWebhookAsync_StaleTimestamp_DoesNotRaiseEvent() } [Fact] - public async Task HandleWebhookAsync_InvalidSignature_DoesNotRaiseEvent() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public async Task HandleWebhookAsync_InvalidSignature_DoesNotRaiseEvent() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", "Enabled"), "True" } }); - var json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Hat", 500); - var body = System.Text.Encoding.UTF8.GetBytes(json); - var headers = new Dictionary - { - { "X-Signature-Timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() }, - { "X-Signature-Ed25519", "0000000000000000000000000000000000000000000000000000000000000000" + - "0000000000000000000000000000000000000000000000000000000000000000" } + string json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Hat", 500); + byte[] body = Encoding.UTF8.GetBytes(json); + var headers = new Dictionary { + { "X-Signature-Timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() }, { + "X-Signature-Ed25519", "0000000000000000000000000000000000000000000000000000000000000000" + + "0000000000000000000000000000000000000000000000000000000000000000" + } }; - var ev = CaptureEvent(() => + SubathonEvent? ev = CaptureEvent(() => service.HandleWebhookAsync(body, headers, TestContext.Current.CancellationToken).GetAwaiter().GetResult()); Assert.Null(ev); @@ -326,17 +321,15 @@ public async Task HandleWebhookAsync_InvalidSignature_DoesNotRaiseEvent() } [Fact] - public void ProcessData_GiftPurchased_DollarMode_MapsCorrectly() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public void ProcessData_GiftPurchased_DollarMode_MapsCorrectly() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", $"{SubathonEventType.ThroneGiftPurchase}.Mode"), "Dollar" } }); var eventId = Guid.NewGuid().ToString(); - var json = BuildGiftPurchasedJson(eventId, "Gifter", "Cat Plushie", 1000, "USD"); + string json = BuildGiftPurchasedJson(eventId, "Gifter", "Cat Plushie", 1000); - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal(SubathonEventType.ThroneGiftPurchase, ev.EventType); @@ -350,16 +343,14 @@ public void ProcessData_GiftPurchased_DollarMode_MapsCorrectly() } [Fact] - public void ProcessData_GiftPurchased_DollarMode_PriceConvertedFromCents() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public void ProcessData_GiftPurchased_DollarMode_PriceConvertedFromCents() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", $"{SubathonEventType.ThroneGiftPurchase}.Mode"), "Dollar" } }); - var json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Mug", 2550, "GBP"); + string json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Mug", 2550, "GBP"); - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal("25.50", ev.Value); @@ -367,16 +358,14 @@ public void ProcessData_GiftPurchased_DollarMode_PriceConvertedFromCents() } [Fact] - public void ProcessData_GiftPurchased_ItemMode_UsesItemNameAsValue() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public void ProcessData_GiftPurchased_ItemMode_UsesItemNameAsValue() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", $"{SubathonEventType.ThroneGiftPurchase}.Mode"), "Item" } }); - var json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Rainbow Hoodie", 4999, "USD"); + string json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Rainbow Hoodie", 4999); - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal(SubathonEventType.ThroneGiftPurchase, ev.EventType); @@ -385,19 +374,17 @@ public void ProcessData_GiftPurchased_ItemMode_UsesItemNameAsValue() Assert.Equal("item", ev.Currency); Assert.Equal(1, ev.Amount); } - - + + [Fact] - public void ProcessData_GiftPurchased_ItemMode_UsesSurpriseGiftNameAsValue() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public void ProcessData_GiftPurchased_ItemMode_UsesSurpriseGiftNameAsValue() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", $"{SubathonEventType.ThroneGiftPurchase}.Mode"), "Item" } }); - var json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Rainbow Hoodie", 4999, "USD", true); + string json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Rainbow Hoodie", 4999, "USD", true); - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal(SubathonEventType.ThroneGiftPurchase, ev.EventType); @@ -408,10 +395,8 @@ public void ProcessData_GiftPurchased_ItemMode_UsesSurpriseGiftNameAsValue() } [Fact] - public void ProcessData_GiftPurchased_ItemMode_NullItemName_FallsBackToOne() - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + public void ProcessData_GiftPurchased_ItemMode_NullItemName_FallsBackToOne() { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", $"{SubathonEventType.ThroneGiftPurchase}.Mode"), "Item" } }); @@ -427,7 +412,7 @@ public void ProcessData_GiftPurchased_ItemMode_NullItemName_FallsBackToOne() } """; - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal("1", ev.Value); @@ -436,27 +421,25 @@ public void ProcessData_GiftPurchased_ItemMode_NullItemName_FallsBackToOne() } [Fact] - public void ProcessData_GiftPurchased_IsSim_SetsSimulatedSource() - { + public void ProcessData_GiftPurchased_IsSim_SetsSimulatedSource() { (ThroneService service, _) = MakeService(); - var json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "SYSTEM", "Test Gift", 500); + string json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "SYSTEM", "Test Gift", 500); - var ev = CaptureEvent(() => service.ProcessData(json, isSim: true)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json, true)); Assert.NotNull(ev); Assert.Equal(SubathonEventSource.Simulated, ev.Source); } [Fact] - public void ProcessData_ContributionPurchased_MapsCorrectly() - { + public void ProcessData_ContributionPurchased_MapsCorrectly() { (ThroneService service, _) = MakeService(); var eventId = Guid.NewGuid().ToString(); - var json = BuildContributionPurchasedJson(eventId, "Gifter", "Expensive Lego", 750, "USD"); + string json = BuildContributionPurchasedJson(eventId, "Gifter", "Expensive Lego", 750); - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal(SubathonEventType.ThroneGiftContribution, ev.EventType); @@ -468,13 +451,12 @@ public void ProcessData_ContributionPurchased_MapsCorrectly() } [Fact] - public void ProcessData_ContributionPurchased_DifferentCurrency_UsesCorrectCurrency() - { + public void ProcessData_ContributionPurchased_DifferentCurrency_UsesCorrectCurrency() { (ThroneService service, _) = MakeService(); - var json = BuildContributionPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Campaign", 5000, "EUR"); + string json = BuildContributionPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Campaign", 5000, "EUR"); - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal("50.00", ev.Value); @@ -482,13 +464,12 @@ public void ProcessData_ContributionPurchased_DifferentCurrency_UsesCorrectCurre } [Fact] - public void ProcessData_ContributionPurchased_IsSim_SetsSimulatedSource() - { + public void ProcessData_ContributionPurchased_IsSim_SetsSimulatedSource() { (ThroneService service, _) = MakeService(); - var json = BuildContributionPurchasedJson(Guid.NewGuid().ToString(), "SYSTEM", "Sim Drive", 1000); + string json = BuildContributionPurchasedJson(Guid.NewGuid().ToString(), "SYSTEM", "Sim Drive", 1000); - var ev = CaptureEvent(() => service.ProcessData(json, isSim: true)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json, true)); Assert.NotNull(ev); Assert.Equal(SubathonEventSource.Simulated, ev.Source); @@ -496,26 +477,24 @@ public void ProcessData_ContributionPurchased_IsSim_SetsSimulatedSource() } [Fact] - public void ProcessData_GiftCrowdfunded_MapsCorrectly() - { + public void ProcessData_GiftCrowdfunded_MapsCorrectly() { (ThroneService service, _) = MakeService(); var eventId = Guid.NewGuid().ToString(); - var json = BuildCrowdfundedJson(eventId, "Gold or something", 20000, "USD"); + string json = BuildCrowdfundedJson(eventId, "Gold or something", 20000); - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal(SubathonEventType.ThroneCrowdGiftComplete, ev.EventType); Assert.Equal(SubathonEventSource.Throne, ev.Source); - Assert.Equal("Gold or something", ev.User); + Assert.Equal("Gold or something", ev.User); Assert.Equal("200.00", ev.Value); Assert.Equal("USD", ev.Currency); } [Fact] - public void ProcessData_GiftCrowdfunded_NullItemName_FallsBackToDefault() - { + public void ProcessData_GiftCrowdfunded_NullItemName_FallsBackToDefault() { (ThroneService service, _) = MakeService(); var json = $$""" @@ -529,7 +508,7 @@ public void ProcessData_GiftCrowdfunded_NullItemName_FallsBackToDefault() } """; - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal("Crowdfunding Complete!", ev.User); // fake user @@ -537,21 +516,19 @@ public void ProcessData_GiftCrowdfunded_NullItemName_FallsBackToDefault() } [Fact] - public void ProcessData_GiftCrowdfunded_IsSim_SetsSimulatedSource() - { + public void ProcessData_GiftCrowdfunded_IsSim_SetsSimulatedSource() { (ThroneService service, _) = MakeService(); - var json = BuildCrowdfundedJson(Guid.NewGuid().ToString(), "Chair", 10000); + string json = BuildCrowdfundedJson(Guid.NewGuid().ToString(), "Chair", 10000); - var ev = CaptureEvent(() => service.ProcessData(json, isSim: true)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json, true)); Assert.NotNull(ev); Assert.Equal(SubathonEventSource.Simulated, ev.Source); } [Fact] - public void ProcessData_UnknownEventType_DoesNotRaiseEvent() - { + public void ProcessData_UnknownEventType_DoesNotRaiseEvent() { (ThroneService service, _) = MakeService(); var json = $$""" @@ -566,50 +543,46 @@ public void ProcessData_UnknownEventType_DoesNotRaiseEvent() } """; - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.Null(ev); } [Fact] - public void ProcessData_NullPayload_DoesNotThrowOrRaiseEvent() - { + public void ProcessData_NullPayload_DoesNotThrowOrRaiseEvent() { (ThroneService service, _) = MakeService(); - var ev = CaptureEvent(() => service.ProcessData("null")); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData("null")); Assert.Null(ev); } [Fact] - public void ProcessData_EmptyDataObject_DoesNotRaiseEvent() - { + public void ProcessData_EmptyDataObject_DoesNotRaiseEvent() { (ThroneService service, _) = MakeService(); var json = """{ "event_id": "abc", "event_type": "gift_purchased", "data": "" }"""; - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.Null(ev); } [Fact] - public void ProcessData_GiftPurchased_EventIdBecomesSubathonEventId() - { + public void ProcessData_GiftPurchased_EventIdBecomesSubathonEventId() { (ThroneService service, _) = MakeService(); var knownGuid = Guid.NewGuid(); - var json = BuildGiftPurchasedJson(knownGuid.ToString(), "Gifter", "Jacket", 3000); + string json = BuildGiftPurchasedJson(knownGuid.ToString(), "Gifter", "Jacket", 3000); - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal(knownGuid, ev.Id); } [Fact] - public void ProcessData_GiftPurchased_NullGifterUsername_FallsBackToDefaultUser() - { + public void ProcessData_GiftPurchased_NullGifterUsername_FallsBackToDefaultUser() { (ThroneService service, _) = MakeService(); var json = $$""" @@ -624,49 +597,52 @@ public void ProcessData_GiftPurchased_NullGifterUsername_FallsBackToDefaultUser( } """; - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal("Crowdfunding Complete!", ev.User); } [Fact] - public void WebhookPath_IsCorrectValue() - { + public void WebhookPath_IsCorrectValue() { (ThroneService service, _) = MakeService(); Assert.Equal("/api/webhooks/throne", service.WebhookPath); } [Fact] - public async Task StopAsync_AfterStart_BroadcastsCorrectSource() - { + public async Task StopAsync_AfterStart_BroadcastsCorrectSource() { (ThroneService service, _) = MakeService(); await service.StartAsync(TestContext.Current.CancellationToken); SubathonEventSource? capturedSource = null; - void Handler(IntegrationConnection conn) => capturedSource = conn.Source; + + void Handler(IntegrationConnection conn) { + capturedSource = conn.Source; + } IntegrationEvents.ConnectionUpdated += Handler; - try { await service.StopAsync(TestContext.Current.CancellationToken); } - finally { IntegrationEvents.ConnectionUpdated -= Handler; } + try { + await service.StopAsync(TestContext.Current.CancellationToken); + } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + } Assert.Equal(SubathonEventSource.Throne, capturedSource); } [Theory] [InlineData("Dollar", "25.00", "USD")] - [InlineData("Item", "Fancy Mug", "item")] + [InlineData("Item", "Fancy Mug", "item")] public void ProcessData_GiftPurchased_RespectsModeConfig( - string mode, string expectedValue, string expectedCurrency) - { - (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> - { + string mode, string expectedValue, string expectedCurrency) { + (ThroneService service, _) = MakeService(new Dictionary<(string, string), string> { { ("Throne", $"{SubathonEventType.ThroneGiftPurchase}.Mode"), mode } }); - var json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Fancy Mug", 2500, "USD"); + string json = BuildGiftPurchasedJson(Guid.NewGuid().ToString(), "Gifter", "Fancy Mug", 2500); - var ev = CaptureEvent(() => service.ProcessData(json)); + SubathonEvent? ev = CaptureEvent(() => service.ProcessData(json)); Assert.NotNull(ev); Assert.Equal(expectedValue, ev.Value); diff --git a/SubathonManager.Tests/IntegrationUnitTests/TipeeeStreamServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TipeeeStreamServiceTests.cs index 314082cf..03c5621f 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TipeeeStreamServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TipeeeStreamServiceTests.cs @@ -13,24 +13,22 @@ namespace SubathonManager.Tests.IntegrationUnitTests; [Collection("GlobalState")] -public class TipeeeStreamServiceTests -{ - public TipeeeStreamServiceTests() - { +public class TipeeeStreamServiceTests { + public TipeeeStreamServiceTests() { typeof(IntegrationEvents) .GetField("ConnectionUpdated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); } - private static SubathonEvent? CaptureEvent(Action trigger) => - EventUtil.SubathonEventCapture.CaptureRequired(trigger); + private static SubathonEvent? CaptureEvent(Action trigger) { + return EventUtil.SubathonEventCapture.CaptureRequired(trigger); + } private static TipeeeStreamService MakeService( Dictionary? storageData = null, - Dictionary<(string, string), string>? configValues = null) - { + Dictionary<(string, string), string>? configValues = null) { var logger = new Mock>(); - var config = MockConfig.MakeMockConfig(configValues); + IConfig config = MockConfig.MakeMockConfig(configValues); var httpFactory = new Mock(); var storage = new InMemorySecureStorage(storageData); var timerService = new Mock(); @@ -40,32 +38,28 @@ private static TipeeeStreamService MakeService( return new TipeeeStreamService(logger.Object, httpFactory.Object, storage, timerService.Object); } - private static SubathonEvent? InvokeProcessEventJson(TipeeeStreamService service, string json) - { - var method = typeof(TipeeeStreamService) + private static SubathonEvent? InvokeProcessEventJson(TipeeeStreamService service, string json) { + MethodInfo? method = typeof(TipeeeStreamService) .GetMethod("ProcessEventJson", BindingFlags.NonPublic | BindingFlags.Instance); return CaptureEvent(() => method?.Invoke(service, [json])); } [Fact] - public async Task StartAsync_NoTokens_BroadcastsStatusFalse() - { - var service = MakeService(); + public async Task StartAsync_NoTokens_BroadcastsStatusFalse() { + TipeeeStreamService service = MakeService(); bool? status = null; - void Handler(IntegrationConnection conn) - { + + void Handler(IntegrationConnection conn) { if (conn.Source == SubathonEventSource.TipeeeStream) status = conn.Status; } IntegrationEvents.ConnectionUpdated += Handler; - try - { + try { await service.StartAsync(TestContext.Current.CancellationToken); } - finally - { + finally { IntegrationEvents.ConnectionUpdated -= Handler; } @@ -73,28 +67,24 @@ void Handler(IntegrationConnection conn) } [Fact] - public async Task StartAsync_EmptyTokenValues_BroadcastsStatusFalse() - { - var service = MakeService(new Dictionary - { + public async Task StartAsync_EmptyTokenValues_BroadcastsStatusFalse() { + TipeeeStreamService service = MakeService(new Dictionary { [StorageKeys.TipeeeStreamAccessToken] = "", - [StorageKeys.TipeeeStreamRefreshToken] = "", + [StorageKeys.TipeeeStreamRefreshToken] = "" }); bool? status = null; - void Handler(IntegrationConnection conn) - { + + void Handler(IntegrationConnection conn) { if (conn.Source == SubathonEventSource.TipeeeStream) status = conn.Status; } IntegrationEvents.ConnectionUpdated += Handler; - try - { + try { await service.StartAsync(TestContext.Current.CancellationToken); } - finally - { + finally { IntegrationEvents.ConnectionUpdated -= Handler; } @@ -102,9 +92,8 @@ void Handler(IntegrationConnection conn) } [Fact] - public void SimulateDonation_ValidAmount_RaisesDonationEvent() - { - var ev = CaptureEvent(() => TipeeeStreamService.SimulateDonation("10.50", "USD")); + public void SimulateDonation_ValidAmount_RaisesDonationEvent() { + SubathonEvent? ev = CaptureEvent(() => TipeeeStreamService.SimulateDonation("10.50", "USD")); Assert.NotNull(ev); Assert.Equal(SubathonEventType.TipeeeStreamDonation, ev.EventType); @@ -115,29 +104,27 @@ public void SimulateDonation_ValidAmount_RaisesDonationEvent() } [Fact] - public void SimulateDonation_EmptyCurrency_DefaultsToEur() - { - var ev = CaptureEvent(() => TipeeeStreamService.SimulateDonation("5.00", "")); + public void SimulateDonation_EmptyCurrency_DefaultsToEur() { + SubathonEvent? ev = CaptureEvent(() => TipeeeStreamService.SimulateDonation("5.00", "")); Assert.NotNull(ev); Assert.Equal("EUR", ev.Currency); } [Fact] - public void SimulateDonation_InvalidAmount_DoesNotRaiseEvent() - { - var ev = CaptureEvent(() => TipeeeStreamService.SimulateDonation("not-a-number", "USD")); + public void SimulateDonation_InvalidAmount_DoesNotRaiseEvent() { + SubathonEvent? ev = CaptureEvent(() => TipeeeStreamService.SimulateDonation("not-a-number", "USD")); Assert.Null(ev); } [Fact] - public void ProcessEventJson_DonationType_RaisesDonationEvent() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"TIPEEESTREAM_abc","parameters":{"username":"Donor","amount":15.0,"currency":"USD"}}}]"""; + public void ProcessEventJson_DonationType_RaisesDonationEvent() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"TIPEEESTREAM_abc","parameters":{"username":"Donor","amount":15.0,"currency":"USD"}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal(SubathonEventType.TipeeeStreamDonation, ev.EventType); @@ -148,12 +135,12 @@ public void ProcessEventJson_DonationType_RaisesDonationEvent() } [Fact] - public void ProcessEventJson_TipeeeType_RaisesDonationEvent() - { - var service = MakeService(); - const string json = """[{"event":{"type":"tipeee","ref":"TIPEEESTREAM_abc","parameters":{"username":"SubGuy","amount":5.0,"currency":"EUR"}}}]"""; + public void ProcessEventJson_TipeeeType_RaisesDonationEvent() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"tipeee","ref":"TIPEEESTREAM_abc","parameters":{"username":"SubGuy","amount":5.0,"currency":"EUR"}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal(SubathonEventType.TipeeeStreamDonation, ev.EventType); @@ -161,34 +148,34 @@ public void ProcessEventJson_TipeeeType_RaisesDonationEvent() } [Fact] - public void ProcessEventJson_UnknownType_DoesNotRaise() - { - var service = MakeService(); - const string json = """[{"event":{"type":"follow","ref":"abc","parameters":{"username":"Follower","amount":0,"currency":""}}}]"""; + public void ProcessEventJson_UnknownType_DoesNotRaise() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"follow","ref":"abc","parameters":{"username":"Follower","amount":0,"currency":""}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.Null(ev); } [Fact] - public void ProcessEventJson_TwitchRef_DoesNotRaise() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"TWITCH_SUB_abc","parameters":{"username":"TwitchUser","amount":5.0,"currency":"USD"}}}]"""; + public void ProcessEventJson_TwitchRef_DoesNotRaise() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"TWITCH_SUB_abc","parameters":{"username":"TwitchUser","amount":5.0,"currency":"USD"}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.Null(ev); } [Fact] - public void ProcessEventJson_YoutubeRef_DoesNotRaise() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"YOUTUBE_MEMBERSHIP_abc","parameters":{"username":"YtUser","amount":5.0,"currency":"USD"}}}]"""; + public void ProcessEventJson_YoutubeRef_DoesNotRaise() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"YOUTUBE_MEMBERSHIP_abc","parameters":{"username":"YtUser","amount":5.0,"currency":"USD"}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.Null(ev); } @@ -197,13 +184,13 @@ public void ProcessEventJson_YoutubeRef_DoesNotRaise() [InlineData("TWITCH_sub")] [InlineData("twitch_sub")] [InlineData("Twitch_Sub")] - public void ProcessEventJson_TwitchRef_CaseInsensitive_DoesNotRaise(string refValue) - { - var service = MakeService(); - string json = """[{"event":{"type":"donation","ref":"REF","parameters":{"username":"User","amount":5.0,"currency":"USD"}}}]""" - .Replace("REF", refValue); + public void ProcessEventJson_TwitchRef_CaseInsensitive_DoesNotRaise(string refValue) { + TipeeeStreamService service = MakeService(); + string json = + """[{"event":{"type":"donation","ref":"REF","parameters":{"username":"User","amount":5.0,"currency":"USD"}}}]""" + .Replace("REF", refValue); - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.Null(ev); } @@ -212,24 +199,24 @@ public void ProcessEventJson_TwitchRef_CaseInsensitive_DoesNotRaise(string refVa [InlineData("YOUTUBE_member")] [InlineData("youtube_member")] [InlineData("YouTube_Member")] - public void ProcessEventJson_YoutubeRef_CaseInsensitive_DoesNotRaise(string refValue) - { - var service = MakeService(); - string json = """[{"event":{"type":"donation","ref":"REF","parameters":{"username":"User","amount":5.0,"currency":"USD"}}}]""" - .Replace("REF", refValue); + public void ProcessEventJson_YoutubeRef_CaseInsensitive_DoesNotRaise(string refValue) { + TipeeeStreamService service = MakeService(); + string json = + """[{"event":{"type":"donation","ref":"REF","parameters":{"username":"User","amount":5.0,"currency":"USD"}}}]""" + .Replace("REF", refValue); - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.Null(ev); } [Fact] - public void ProcessEventJson_SimulationRef_SetsSystemUser() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"simulation_abc","parameters":{"username":"RealDonor","amount":10.0,"currency":"USD"}}}]"""; + public void ProcessEventJson_SimulationRef_SetsSystemUser() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"simulation_abc","parameters":{"username":"RealDonor","amount":10.0,"currency":"USD"}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal("SYSTEM", ev.User); @@ -239,122 +226,121 @@ public void ProcessEventJson_SimulationRef_SetsSystemUser() [InlineData("simulation_test")] [InlineData("SIMULATION_TEST")] [InlineData("Simulation123")] - public void ProcessEventJson_SimulationRef_CaseInsensitive(string refValue) - { - var service = MakeService(); - string json = """[{"event":{"type":"donation","ref":"REF","parameters":{"username":"Donor","amount":10.0,"currency":"USD"}}}]""" - .Replace("REF", refValue); + public void ProcessEventJson_SimulationRef_CaseInsensitive(string refValue) { + TipeeeStreamService service = MakeService(); + string json = + """[{"event":{"type":"donation","ref":"REF","parameters":{"username":"Donor","amount":10.0,"currency":"USD"}}}]""" + .Replace("REF", refValue); - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal("SYSTEM", ev.User); } [Fact] - public void ProcessEventJson_ParametersUsernameUsedOverProvider() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"ref","parameters":{"username":"DonorName","amount":5.0,"currency":"EUR"},"user":{"providers":[{"code":"twitch","username":"ProviderUser"}]}}}]"""; + public void ProcessEventJson_ParametersUsernameUsedOverProvider() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"ref","parameters":{"username":"DonorName","amount":5.0,"currency":"EUR"},"user":{"providers":[{"code":"twitch","username":"ProviderUser"}]}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal("DonorName", ev.User); } [Fact] - public void ProcessEventJson_FallsBackToProviderUsername() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"ref","parameters":{"username":"","amount":5.0,"currency":"EUR"},"user":{"providers":[{"code":"twitch","username":"ProviderUser"}]}}}]"""; + public void ProcessEventJson_FallsBackToProviderUsername() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"ref","parameters":{"username":"","amount":5.0,"currency":"EUR"},"user":{"providers":[{"code":"twitch","username":"ProviderUser"}]}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal("ProviderUser", ev.User); } [Fact] - public void ProcessEventJson_FallsBackToTipeeeStream() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"ref","parameters":{"username":"","amount":5.0,"currency":"EUR"}}}]"""; + public void ProcessEventJson_FallsBackToTipeeeStream() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"ref","parameters":{"username":"","amount":5.0,"currency":"EUR"}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal("TipeeeStream", ev.User); } [Fact] - public void ProcessEventJson_EmptyCurrency_DefaultsToEur() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"ref","parameters":{"username":"Donor","amount":10.0,"currency":""}}}]"""; + public void ProcessEventJson_EmptyCurrency_DefaultsToEur() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"ref","parameters":{"username":"Donor","amount":10.0,"currency":""}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal("EUR", ev.Currency); } [Fact] - public void ProcessEventJson_AmountFormattedToTwoDecimals() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"ref","parameters":{"username":"Donor","amount":7.5,"currency":"USD"}}}]"""; + public void ProcessEventJson_AmountFormattedToTwoDecimals() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"ref","parameters":{"username":"Donor","amount":7.5,"currency":"USD"}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal("7.50", ev.Value); } [Fact] - public void ProcessEventJson_NullParameters_DoesNotRaise() - { - var service = MakeService(); + public void ProcessEventJson_NullParameters_DoesNotRaise() { + TipeeeStreamService service = MakeService(); const string json = """[{"event":{"type":"donation","ref":"ref"}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.Null(ev); } [Fact] - public void ProcessEventJson_CreatedAt_Iso8601_UsedAsTimestamp() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"ref","created_at":"2026-06-06T15:13:02.103Z","parameters":{"username":"Donor","amount":5.0,"currency":"USD"}}}]"""; + public void ProcessEventJson_CreatedAt_Iso8601_UsedAsTimestamp() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"ref","created_at":"2026-06-06T15:13:02.103Z","parameters":{"username":"Donor","amount":5.0,"currency":"USD"}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); - var expected = DateTimeOffset.Parse("2026-06-06T15:13:02.103Z").LocalDateTime; + DateTime expected = DateTimeOffset.Parse("2026-06-06T15:13:02.103Z").LocalDateTime; Assert.Equal(expected, ev.EventTimestamp); } [Fact] - public void ProcessEventJson_CreatedAt_UnparseableFormat_FallsBackToNow() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":"ref","created_at":"not-a-date","parameters":{"username":"Donor","amount":5.0,"currency":"USD"}}}]"""; - var before = DateTime.Now.ToLocalTime(); + public void ProcessEventJson_CreatedAt_UnparseableFormat_FallsBackToNow() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":"ref","created_at":"not-a-date","parameters":{"username":"Donor","amount":5.0,"currency":"USD"}}}]"""; + DateTime before = DateTime.Now.ToLocalTime(); - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.True(ev.EventTimestamp >= before); } [Fact] - public void ProcessEventJson_StringAmount_ParsesSuccessfully() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":442715.728856922,"parameters":{"username":"Anonymous21","amount":"2.77","currency":"USD"}}}]"""; + public void ProcessEventJson_StringAmount_ParsesSuccessfully() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":442715.728856922,"parameters":{"username":"Anonymous21","amount":"2.77","currency":"USD"}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal("2.77", ev.Value); @@ -362,12 +348,12 @@ public void ProcessEventJson_StringAmount_ParsesSuccessfully() } [Fact] - public void ProcessEventJson_NumericRef_ParsesSuccessfully() - { - var service = MakeService(); - const string json = """[{"event":{"type":"donation","ref":704638.4114700294,"parameters":{"username":"Anonymous19","amount":25.0,"currency":"USD"}}}]"""; + public void ProcessEventJson_NumericRef_ParsesSuccessfully() { + TipeeeStreamService service = MakeService(); + const string json = + """[{"event":{"type":"donation","ref":704638.4114700294,"parameters":{"username":"Anonymous19","amount":25.0,"currency":"USD"}}}]"""; - var ev = InvokeProcessEventJson(service, json); + SubathonEvent? ev = InvokeProcessEventJson(service, json); Assert.NotNull(ev); Assert.Equal("Anonymous19", ev.User); @@ -375,23 +361,20 @@ public void ProcessEventJson_NumericRef_ParsesSuccessfully() } [Fact] - public void ProcessEventJson_InvalidJson_DoesNotThrow() - { - var service = MakeService(); + public void ProcessEventJson_InvalidJson_DoesNotThrow() { + TipeeeStreamService service = MakeService(); - var ex = Record.Exception(() => InvokeProcessEventJson(service, "not-valid-json{{{")); + Exception? ex = Record.Exception(() => InvokeProcessEventJson(service, "not-valid-json{{{")); Assert.Null(ex); } [Fact] - public void RevokeTokens_DeletesAllStorageKeys() - { - var storage = new InMemorySecureStorage(new Dictionary - { + public void RevokeTokens_DeletesAllStorageKeys() { + var storage = new InMemorySecureStorage(new Dictionary { [StorageKeys.TipeeeStreamAccessToken] = "access", [StorageKeys.TipeeeStreamRefreshToken] = "refresh", - [StorageKeys.TipeeeStreamApiKey] = "apikey", + [StorageKeys.TipeeeStreamApiKey] = "apikey" }); var service = new TipeeeStreamService( new Mock>().Object, @@ -406,4 +389,4 @@ public void RevokeTokens_DeletesAllStorageKeys() Assert.False(storage.Exists(StorageKeys.TipeeeStreamApiKey)); Assert.Equal(3, storage.DeleteCount); } -} +} \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs index 1953dab9..504fd853 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TreatStreamServiceTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Microsoft.Extensions.Logging; using Moq; using SubathonManager.Core.Enums; @@ -11,42 +12,40 @@ namespace SubathonManager.Tests.IntegrationUnitTests; [Collection("GlobalState")] -public class TreatStreamServiceTests -{ - private static SubathonEvent? CaptureEvent(Action trigger) => - EventUtil.SubathonEventCapture.CaptureRequired(trigger); +public class TreatStreamServiceTests { + private const string TreatJson = """ + { + "message": "enjoy!", + "sender": "GenerousViewer", + "receiver": "Streamer", + "title": "Large Pizza", + "sender_type": "user", + "receiver_type": "streamer", + "date_created": "2026-07-08 12:34:56" + } + """; + + private static SubathonEvent? CaptureEvent(Action trigger) { + return EventUtil.SubathonEventCapture.CaptureRequired(trigger); + } private static (TreatStreamService Service, ISecureStorage Storage) MakeService( - string? accessToken = null, string? refreshToken = null) - { + string? accessToken = null, string? refreshToken = null) { var logger = new Mock>(); var seed = new Dictionary(); if (accessToken != null) seed[StorageKeys.TreatStreamAccessToken] = accessToken; if (refreshToken != null) seed[StorageKeys.TreatStreamRefreshToken] = refreshToken; var storage = new InMemorySecureStorage(seed.Count > 0 ? seed : null); - var httpFactory = new Mock().Object; - var timer = new Mock().Object; + IHttpClientFactory httpFactory = new Mock().Object; + ITimerService timer = new Mock().Object; return (new TreatStreamService(logger.Object, httpFactory, storage, timer), storage); } - private const string TreatJson = """ - { - "message": "enjoy!", - "sender": "GenerousViewer", - "receiver": "Streamer", - "title": "Large Pizza", - "sender_type": "user", - "receiver_type": "streamer", - "date_created": "2026-07-08 12:34:56" - } - """; - [Fact] - public void ProcessTreatJson_ValidTreat_RaisesOrderEvent() - { - var (service, _) = MakeService(); - bool result = false; - var ev = CaptureEvent(() => result = service.ProcessTreatJson(TreatJson)); + public void ProcessTreatJson_ValidTreat_RaisesOrderEvent() { + (TreatStreamService service, _) = MakeService(); + var result = false; + SubathonEvent? ev = CaptureEvent(() => result = service.ProcessTreatJson(TreatJson)); Assert.True(result); Assert.NotNull(ev); @@ -60,11 +59,10 @@ public void ProcessTreatJson_ValidTreat_RaisesOrderEvent() } [Fact] - public void ProcessTreatJson_SameTreat_HasDeterministicId() - { - var (service, _) = MakeService(); - var ev1 = CaptureEvent(() => service.ProcessTreatJson(TreatJson)); - var ev2 = CaptureEvent(() => service.ProcessTreatJson(TreatJson)); + public void ProcessTreatJson_SameTreat_HasDeterministicId() { + (TreatStreamService service, _) = MakeService(); + SubathonEvent? ev1 = CaptureEvent(() => service.ProcessTreatJson(TreatJson)); + SubathonEvent? ev2 = CaptureEvent(() => service.ProcessTreatJson(TreatJson)); Assert.NotNull(ev1); Assert.NotNull(ev2); @@ -73,21 +71,19 @@ public void ProcessTreatJson_SameTreat_HasDeterministicId() } [Fact] - public void ProcessTreatJson_DifferentDate_HasDifferentId() - { - var (service, _) = MakeService(); - var ev1 = CaptureEvent(() => service.ProcessTreatJson(TreatJson)); - var ev2 = CaptureEvent(() => service.ProcessTreatJson( + public void ProcessTreatJson_DifferentDate_HasDifferentId() { + (TreatStreamService service, _) = MakeService(); + SubathonEvent? ev1 = CaptureEvent(() => service.ProcessTreatJson(TreatJson)); + SubathonEvent? ev2 = CaptureEvent(() => service.ProcessTreatJson( TreatJson.Replace("12:34:56", "12:34:57"))); Assert.NotEqual(ev1!.Id, ev2!.Id); } [Fact] - public void ProcessTreatJson_SystemSender_IsSimulated() - { - var (service, _) = MakeService(); - var ev = CaptureEvent(() => service.ProcessTreatJson( + public void ProcessTreatJson_SystemSender_IsSimulated() { + (TreatStreamService service, _) = MakeService(); + SubathonEvent? ev = CaptureEvent(() => service.ProcessTreatJson( TreatJson.Replace("GenerousViewer", "SYSTEM"))); Assert.NotNull(ev); @@ -100,16 +96,14 @@ public void ProcessTreatJson_SystemSender_IsSimulated() [InlineData("{}")] [InlineData("{\"sender\":\"a\",\"title\":\"\"}")] [InlineData("[1,2,3]")] - public void ProcessTreatJson_Invalid_ReturnsFalse(string json) - { - var (service, _) = MakeService(); + public void ProcessTreatJson_Invalid_ReturnsFalse(string json) { + (TreatStreamService service, _) = MakeService(); Assert.False(service.ProcessTreatJson(json)); } [Fact] - public void SimulateTreat_RaisesSimulatedEvent() - { - var ev = CaptureEvent(() => TreatStreamService.SimulateTreat("Cupcake")); + public void SimulateTreat_RaisesSimulatedEvent() { + SubathonEvent? ev = CaptureEvent(() => TreatStreamService.SimulateTreat("Cupcake")); Assert.NotNull(ev); Assert.Equal(SubathonEventSource.Simulated, ev!.Source); @@ -121,38 +115,35 @@ public void SimulateTreat_RaisesSimulatedEvent() } [Fact] - public void StoreExpiry_UsesExpiresIn() - { - var (service, storage) = MakeService("token", "refresh"); + public void StoreExpiry_UsesExpiresIn() { + (TreatStreamService service, ISecureStorage storage) = MakeService("token", "refresh"); service.StoreExpiry("3600"); - var raw = storage.GetOrDefault(StorageKeys.TreatStreamTokenExpiry, ""); - Assert.True(DateTime.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, - out var expiry)); + string? raw = storage.GetOrDefault(StorageKeys.TreatStreamTokenExpiry, ""); + Assert.True(DateTime.TryParse(raw, CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out DateTime expiry)); Assert.InRange(expiry, DateTime.UtcNow.AddMinutes(55), DateTime.UtcNow.AddMinutes(65)); } [Fact] - public void StoreExpiry_MissingExpiresIn_DefaultsTo15Days() - { + public void StoreExpiry_MissingExpiresIn_DefaultsTo15Days() { // api says 30, we do half for safety - var (service, storage) = MakeService("token", "refresh"); + (TreatStreamService service, ISecureStorage storage) = MakeService("token", "refresh"); service.StoreExpiry(null); - var raw = storage.GetOrDefault(StorageKeys.TreatStreamTokenExpiry, ""); - Assert.True(DateTime.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, - out var expiry)); + string? raw = storage.GetOrDefault(StorageKeys.TreatStreamTokenExpiry, ""); + Assert.True(DateTime.TryParse(raw, CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out DateTime expiry)); Assert.InRange(expiry, DateTime.UtcNow.AddDays(14), DateTime.UtcNow.AddDays(16)); } [Fact] - public async Task StartAsync_NoTokens_StaysDisconnected() - { - var (service, _) = MakeService(); + public async Task StartAsync_NoTokens_StaysDisconnected() { + (TreatStreamService service, _) = MakeService(); await service.StartAsync(TestContext.Current.CancellationToken); Assert.False(service.HasTokens()); await service.StopAsync(TestContext.Current.CancellationToken); } -} +} \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs index 6d37365b..12aad4fa 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs @@ -1,1253 +1,1115 @@ -using TwitchLib.Client.Events; -using TwitchLib.EventSub.Core.EventArgs.Stream; -using TwitchLib.EventSub.Core.EventArgs.Channel; -using TwitchLib.EventSub.Websockets.Core.Models; -using TwitchLib.Client.Enums; -using System.Net; +using System.Net; +using System.Net.Sockets; using System.Net.WebSockets; -using System.Text.Json; -using SubathonManager.Core.Enums; -using SubathonManager.Core.Events; -using SubathonManager.Core.Models; -using SubathonManager.Services; -using SubathonManager.Integration; using System.Reflection; using System.Text; -using TwitchLib.Client.Models; +using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Core.Security; +using SubathonManager.Integration; +using SubathonManager.Services; using SubathonManager.Tests.Utility; +using TwitchLib.Client.Enums; +using TwitchLib.Client.Events; +using TwitchLib.Client.Models; +using TwitchLib.EventSub.Core.EventArgs.Channel; +using TwitchLib.EventSub.Core.EventArgs.Stream; +using TwitchLib.EventSub.Core.Models; +using TwitchLib.EventSub.Core.Models.Charity; +using TwitchLib.EventSub.Core.SubscriptionTypes.Channel; +using TwitchLib.EventSub.Websockets.Core.Models; using UserType = TwitchLib.Client.Enums.UserType; + // ReSharper disable NullableWarningSuppressionIsUsed // ReSharper disable SuggestVarOrType_SimpleTypes -namespace SubathonManager.Tests.IntegrationUnitTests -{ - [Collection("GlobalState")] - public class TwitchServiceTests - { - public TwitchServiceTests() - { - var path = Path.GetFullPath(Path.Combine(string.Empty - , "data")); - Directory.CreateDirectory(path); - } +namespace SubathonManager.Tests.IntegrationUnitTests; - private static SubathonEvent? CaptureEvent(Action trigger) => - EventUtil.SubathonEventCapture.CaptureRequired(trigger); - - public class MockEventSubServer : IAsyncDisposable - { - private readonly WebApplication _app; - private WebSocket? _currentSocket; - private readonly CancellationTokenSource _cts = new(); - - public string SessionId { get; } = Guid.NewGuid().ToString("N")[..16]; - public Uri Uri { get; } - - public MockEventSubServer() - { - int port = GetFreePort(); - Uri = new Uri($"ws://127.0.0.1:{port}/ws"); - string httpBase = $"http://127.0.0.1:{port}"; - - var builder = WebApplication.CreateBuilder(); - builder.WebHost.UseUrls(httpBase); - builder.Logging.ClearProviders(); - _app = builder.Build(); - _app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) }); - - _app.Map("/ws", async context => - { - if (!context.WebSockets.IsWebSocketRequest) - { - context.Response.StatusCode = 400; - return; - } +[Collection("GlobalState")] +public class TwitchServiceTests { + public TwitchServiceTests() { + string path = Path.GetFullPath(Path.Combine(string.Empty + , "data")); + Directory.CreateDirectory(path); + } - var socket = await context.WebSockets.AcceptWebSocketAsync(); - _currentSocket = socket; - - var welcome = new - { - metadata = new - { - message_id = Guid.NewGuid().ToString(), - message_type = "session_welcome", - message_timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ") - }, - payload = new - { - session = new - { - id = SessionId, - status = "connected", - keepalive_timeout_seconds = 10, - reconnect_url = (string?)null, - connected_at = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ") - } - } - }; - - await SendRawAsync(socket, JsonSerializer.Serialize(welcome)); - - var buffer = new byte[4096]; - while (socket.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested) - { - try - { - using var readCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); - readCts.CancelAfter(TimeSpan.FromSeconds(9)); - var result = await socket.ReceiveAsync(buffer, readCts.Token); - if (result.MessageType == WebSocketMessageType.Close) break; - } - catch (OperationCanceledException) - { - if (socket.State == WebSocketState.Open) - { - var keepalive = new - { - metadata = new - { - message_id = Guid.NewGuid().ToString(), - message_type = "session_keepalive", - message_timestamp = - DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ") - }, - payload = new { } - }; - await SendRawAsync(socket, JsonSerializer.Serialize(keepalive)); - } - } - catch - { - break; - } - } - }); + private static SubathonEvent? CaptureEvent(Action trigger) { + return EventUtil.SubathonEventCapture.CaptureRequired(trigger); + } - _app.StartAsync().GetAwaiter().GetResult(); - } - public async Task SendNotificationAsync(string subscriptionType, string subscriptionVersion, - object eventPayload) - { - if (_currentSocket?.State != WebSocketState.Open) return; + [Fact] + public void SimulateRaid_RaisesRaidEvent() { + var ev = CaptureEvent(() => TwitchService.SimulateRaid(123)); - var notification = new - { - metadata = new - { - message_id = Guid.NewGuid().ToString(), - message_type = "notification", - message_timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ"), - subscription_type = subscriptionType, - subscription_version = subscriptionVersion - }, - payload = new - { - subscription = new - { - id = Guid.NewGuid().ToString(), - type = subscriptionType, - version = subscriptionVersion, - status = "enabled", - condition = new { broadcaster_user_id = "123456" }, - transport = new { method = "websocket", session_id = SessionId }, - created_at = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ") - }, - @event = eventPayload - } - }; + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.Simulated, ev.Source); + Assert.Equal(SubathonEventType.TwitchRaid, ev.EventType); + Assert.Equal("123", ev.Value); + Assert.Equal("SYSTEM", ev.User); + } - await SendRawAsync(_currentSocket, JsonSerializer.Serialize(notification)); - } - private static async Task SendRawAsync(WebSocket socket, string json) - { - var bytes = Encoding.UTF8.GetBytes(json); - await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); - } + [Fact] + public void SimulateCheer_RaisesCheerEvent() { + var ev = CaptureEvent(() => TwitchService.SimulateCheer(500)); - public static int GetFreePort() - { - var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); - l.Start(); - int port = ((IPEndPoint)l.LocalEndpoint).Port; - l.Stop(); - return port; - } + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchCheer, ev.EventType); + Assert.Equal("bits", ev.Currency); + Assert.Equal("500", ev.Value); + } - public async ValueTask DisposeAsync() - { - _cts.Cancel(); - if (_currentSocket?.State == WebSocketState.Open) - { - try - { - await _currentSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); - } - catch {/**/} - } + [Fact] + public void SimulateSubscription_InvalidTier_DoesNotRaise() { + SubathonEvent? captured = CaptureEvent(() => TwitchService.SimulateSubscription("9000")); + Assert.Null(captured); + } - await _app.StopAsync(); - await _app.DisposeAsync(); - _cts.Dispose(); - } - } + [Theory] + [InlineData("1000")] + [InlineData("2000")] + [InlineData("3000")] + public void SimulateSubscription_ShouldRaiseSubEvent(string tier) { + SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateSubscription(tier)); + TwitchService.SimulateSubscription(tier); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchSub, ev.EventType); + Assert.Equal(SubathonEventSource.Simulated, ev.Source); + Assert.Equal("sub", ev.Currency); + Assert.Equal(1, ev.Amount); + Assert.Equal(tier, ev.Value); + } - [Fact] - public void SimulateRaid_RaisesRaidEvent() - { - var ev = CaptureEvent(() => TwitchService.SimulateRaid(123)); + [Fact] + public void SimulateGiftSubscriptions_ShouldRaiseGiftSubEvent() { + SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateGiftSubscriptions("1000", 5)); + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchGiftSub, ev.EventType); + Assert.Equal(SubathonEventSource.Simulated, ev.Source); + Assert.Equal("sub", ev.Currency); + Assert.Equal("1000", ev.Value); + Assert.Equal(5, ev.Amount); + } - Assert.NotNull(ev); - Assert.Equal(SubathonEventSource.Simulated, ev.Source); - Assert.Equal(SubathonEventType.TwitchRaid, ev.EventType); - Assert.Equal("123", ev.Value); - Assert.Equal("SYSTEM", ev.User); - } + [Fact] + public void SimulateFollow_ShouldRaiseFollowEvent() { + SubathonEvent? ev = CaptureEvent(TwitchService.SimulateFollow); + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchFollow, ev.EventType); + Assert.Equal(SubathonEventSource.Simulated, ev.Source); + } + [Fact] + public void SimulateCharityDonation_ShouldRaiseDonationEvent() { + SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateCharityDonation("25.50", "CAD")); - [Fact] - public void SimulateCheer_RaisesCheerEvent() - { - var ev = CaptureEvent(() => TwitchService.SimulateCheer(500)); + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchCharityDonation, ev.EventType); + Assert.Equal(SubathonEventSource.Simulated, ev.Source); + Assert.Equal("25.50", ev.Value); + Assert.Equal("CAD", ev.Currency); + } - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchCheer, ev.EventType); - Assert.Equal("bits", ev.Currency); - Assert.Equal("500", ev.Value); - } + [Fact] + public void SimulateHypeTrainStart_ShouldRaiseStartEvent() { + SubathonEvent? ev = CaptureEvent(TwitchService.SimulateHypeTrainStart); - [Fact] - public void SimulateSubscription_InvalidTier_DoesNotRaise() - { - SubathonEvent? captured = CaptureEvent(() => TwitchService.SimulateSubscription("9000")); - Assert.Null(captured); - } + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.Simulated, ev.Source); + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); + Assert.Equal("start", ev.Value); + Assert.Equal(1, ev.Amount); + } + [Fact] + public void SimulateHypeTrainProgress_ShouldOnlyRaiseIfLevelIncreases() { + SubathonEvent? ev = CaptureEvent(TwitchService.SimulateHypeTrainStart); + SubathonEvent? ev2 = CaptureEvent(() => TwitchService.SimulateHypeTrainProgress(5)); + + Assert.NotNull(ev2); + Assert.Equal(SubathonEventSource.Simulated, ev2.Source); + Assert.Equal(SubathonEventType.TwitchHypeTrain, ev2.EventType); + Assert.NotNull(ev2); + Assert.Equal("progress", ev2.Value); + Assert.Equal(5, ev2.Amount); + } - [Theory] - [InlineData("1000")] - [InlineData("2000")] - [InlineData("3000")] - public void SimulateSubscription_ShouldRaiseSubEvent(string tier) - { - SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateSubscription(tier)); - TwitchService.SimulateSubscription(tier); + [Fact] + public void SimulateHypeTrainEnd_ShouldRun() { + SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateHypeTrainEnd()); + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.Simulated, ev.Source); + Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); + Assert.NotNull(ev); + Assert.Equal("end", ev.Value); + Assert.Equal(10, ev.Amount); + } - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchSub, ev.EventType); - Assert.Equal(SubathonEventSource.Simulated, ev.Source); - Assert.Equal("sub", ev.Currency); - Assert.Equal(1, ev.Amount); - Assert.Equal(tier, ev.Value); + [Fact] + public async Task HandleChannelOnline_ResumeOnStart_RaisesResumeCommand() { + var config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { + { ("Twitch", "ResumeOnStart"), "true" } + }); - } + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); - [Fact] - public void SimulateGiftSubscriptions_ShouldRaiseGiftSubEvent() - { - SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateGiftSubscriptions("1000", 5)); - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchGiftSub, ev.EventType); - Assert.Equal(SubathonEventSource.Simulated, ev.Source); - Assert.Equal("sub", ev.Currency); - Assert.Equal("1000", ev.Value); - Assert.Equal(5, ev.Amount); - } - [Fact] - public void SimulateFollow_ShouldRaiseFollowEvent() - { - SubathonEvent? ev = CaptureEvent(TwitchService.SimulateFollow); - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchFollow, ev.EventType); - Assert.Equal(SubathonEventSource.Simulated, ev.Source); - } + var service = new TwitchService(null, config, storage); - [Fact] - public void SimulateCharityDonation_ShouldRaiseDonationEvent() - { - SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateCharityDonation("25.50", "CAD")); + var ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleChannelOnline", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new StreamOnlineArgs()]) + ); - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchCharityDonation, ev.EventType); - Assert.Equal(SubathonEventSource.Simulated, ev.Source); - Assert.Equal("25.50", ev.Value); - Assert.Equal("CAD", ev.Currency); - } + Assert.NotNull(ev); + Assert.Equal(SubathonCommandType.Resume, ev.Command); + Assert.Equal(SubathonEventType.Command, ev.EventType); + Assert.Equal("AUTO", ev.User); + await service.StopAsync(TestContext.Current.CancellationToken); + } - [Fact] - public void SimulateHypeTrainStart_ShouldRaiseStartEvent() - { - SubathonEvent? ev = CaptureEvent(TwitchService.SimulateHypeTrainStart); - - Assert.NotNull(ev); - Assert.Equal(SubathonEventSource.Simulated, ev.Source); - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); - Assert.Equal("start", ev.Value); - Assert.Equal(1, ev.Amount); - } + [Fact] + public async Task HandleChannelOnline_UnlockOnStart_RaisesResumeCommand() { + var config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { + { ("Twitch", "UnlockOnStart"), "true" } + }); - [Fact] - public void SimulateHypeTrainProgress_ShouldOnlyRaiseIfLevelIncreases() - { - SubathonEvent? ev = CaptureEvent(TwitchService.SimulateHypeTrainStart); - SubathonEvent? ev2 = CaptureEvent(() => TwitchService.SimulateHypeTrainProgress(5)); - - Assert.NotNull(ev2); - Assert.Equal(SubathonEventSource.Simulated, ev2.Source); - Assert.Equal(SubathonEventType.TwitchHypeTrain, ev2.EventType); - Assert.NotNull(ev2); - Assert.Equal("progress", ev2.Value); - Assert.Equal(5, ev2.Amount); - } + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); - [Fact] - public void SimulateHypeTrainEnd_ShouldRun() - { - SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateHypeTrainEnd(10)); - Assert.NotNull(ev); - Assert.Equal(SubathonEventSource.Simulated, ev.Source); - Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); - Assert.NotNull(ev); - Assert.Equal("end", ev.Value); - Assert.Equal(10, ev.Amount); - } - [Fact] - public async Task HandleChannelOnline_ResumeOnStart_RaisesResumeCommand() - { - var config = MockConfig.MakeMockConfig(new() - { - { ("Twitch", "ResumeOnStart"), "true" } - }); - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); + var service = new TwitchService(null, config, storage); + var ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleChannelOnline", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new StreamOnlineArgs()]) + ); - var service = new TwitchService(null, config, storage); + Assert.NotNull(ev); + Assert.Equal(SubathonCommandType.Unlock, ev.Command); + Assert.Equal(SubathonEventType.Command, ev.EventType); + Assert.Equal("AUTO", ev.User); + await service.StopAsync(TestContext.Current.CancellationToken); + } - var ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleChannelOnline", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new StreamOnlineArgs()]) - ); + [Fact] + public async Task HandleChannelOffline_PauseOnEnd_RaisesPauseCommand() { + var config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { + { ("Twitch", "PauseOnEnd"), "true" } + }); + + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + + var service = new TwitchService(null, config, storage); + + var ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleChannelOffline", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new StreamOfflineArgs()]) + ); + + Assert.NotNull(ev); + Assert.Equal(SubathonCommandType.Pause, ev.Command); + Assert.Equal(SubathonEventType.Command, ev.EventType); + Assert.Equal("AUTO", ev.User); + await service.StopAsync(TestContext.Current.CancellationToken); + } - Assert.NotNull(ev); - Assert.Equal(SubathonCommandType.Resume, ev.Command); - Assert.Equal(SubathonEventType.Command, ev.EventType); - Assert.Equal("AUTO", ev.User); - await service.StopAsync(TestContext.Current.CancellationToken); - } + [Fact] + public async Task HandleChannelOffline_LockOnEnd_RaisesPauseCommand() { + var config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { + { ("Twitch", "LockOnEnd"), "true" } + }); + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + + var service = new TwitchService(null, config, storage); + + var ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleChannelOffline", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new StreamOfflineArgs()]) + ); + + Assert.NotNull(ev); + Assert.Equal(SubathonCommandType.Lock, ev.Command); + Assert.Equal(SubathonEventType.Command, ev.EventType); + Assert.Equal("AUTO", ev.User); + await service.StopAsync(TestContext.Current.CancellationToken); + } - [Fact] - public async Task HandleChannelOnline_UnlockOnStart_RaisesResumeCommand() - { - var config =MockConfig.MakeMockConfig(new() - { - { ("Twitch", "UnlockOnStart"), "true" } - }); + [Fact] + public async Task HandleChannelFollow_RaisesFollowEvent() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); + var meta = new WebsocketEventSubMetadata { + MessageId = Guid.NewGuid().ToString(), + MessageTimestamp = DateTime.UtcNow + }; - var service = new TwitchService(null, config, storage); + var args = new ChannelFollowArgs { + Metadata = meta, + Payload = new EventSubNotificationPayload { + Event = new ChannelFollow { + UserName = "Follower123" + } + } + }; + + var ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleChannelFollow", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args]) + ); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchFollow, ev.EventType); + Assert.Equal("Follower123", ev.User); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + await service.StopAsync(TestContext.Current.CancellationToken); + } - var ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleChannelOnline", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new StreamOnlineArgs()]) - ); + [Fact] + public void HandleHypeTrainProgress_IgnoresSameOrLowerLevel() { + TwitchService.SimulateHypeTrainStart(); - Assert.NotNull(ev); - Assert.Equal(SubathonCommandType.Unlock, ev.Command); - Assert.Equal(SubathonEventType.Command, ev.EventType); - Assert.Equal("AUTO", ev.User); - await service.StopAsync(TestContext.Current.CancellationToken); - } + SubathonEvent? capturedEvent = CaptureEvent(() => + TwitchService.SimulateHypeTrainProgress(1)); + Assert.Null(capturedEvent); + } - [Fact] - public async Task HandleChannelOffline_PauseOnEnd_RaisesPauseCommand() - { - var config =MockConfig.MakeMockConfig(new() - { - { ("Twitch", "PauseOnEnd"), "true" } - }); - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); + [Fact] + public async Task HandleChatMessage_Command_RaisesCommandEvent() { + var config = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { + { ("Chat", "Commands.Pause"), "pause" }, + + { ("Chat", "Commands.Pause.permissions.Mods"), "true" }, + { ("Chat", "Commands.Pause.permissions.VIPs"), "false" }, + { ("Chat", "Commands.Pause.permissions.Whitelist"), "specialguy" } + }); + CommandService.SetConfig(config); + + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + + var service = new TwitchService(null, config, storage); + service.Login = "teststreamer"; + + ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcaster, string userName, + string displayName) { + UserType type = UserType.Viewer; + if (isMod) type = UserType.Moderator; + if (isBroadcaster) type = UserType.Broadcaster; + + var details = UserDetails.Subscriber; + if (isMod) details |= UserDetails.Moderator; + if (isVip) details |= UserDetails.Vip; + + return new ChatMessage( + "", + "123456789", + userName, + displayName, + "#ffffff", + new EmoteSet("", ""), + message, + type, + "teststreamer", + Guid.NewGuid().ToString(), + 4, + "098765432", + false, + isBroadcaster, + Noisy.NotSet, + "", + "", + [], + new CheerBadge(0), + 0, + 0, + new UserDetail(details) + ); + } - var service = new TwitchService(null, config, storage); + var chatMsg = MakeMessage("!pause", false, false, + true, "test", "Test"); + var args = new OnMessageReceivedArgs(chatMsg); + + + var ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args]) + ); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.Command, ev.EventType); + Assert.Equal(SubathonCommandType.Pause, ev.Command); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("test", ev.User); + + + chatMsg = MakeMessage("!pause", false, false, + false, "specialguy", "specialguy"); + args = new OnMessageReceivedArgs(chatMsg); + + ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args]) + ); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.Command, ev.EventType); + Assert.Equal(SubathonCommandType.Pause, ev.Command); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("specialguy", ev.User); + + chatMsg = MakeMessage("!pause", false, true, + false, "testuser", "TestUser"); + args = new OnMessageReceivedArgs(chatMsg); + + ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args]) + ); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.Command, ev.EventType); + Assert.Equal(SubathonCommandType.Pause, ev.Command); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("testuser", ev.User); + + chatMsg = MakeMessage("!pause", true, false, + false, "testuser", "TestUser"); + args = new OnMessageReceivedArgs(chatMsg); + + ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args]) + ); + + Assert.Null(ev); + + chatMsg = MakeMessage("!resume", true, true, + false, "testuser", "TestUser"); + args = new OnMessageReceivedArgs(chatMsg); + + ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args]) + ); + + Assert.Null(ev); + await service.StopAsync(TestContext.Current.CancellationToken); + } - var ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleChannelOffline", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new StreamOfflineArgs()]) - ); + [Fact] + public async Task HasTokenFile_ReturnsCorrectValue() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); + Assert.True(service.HasTokenFile()); + storage.Delete(StorageKeys.TwitchAccessToken); + Assert.False(service.HasTokenFile()); + await service.StopAsync(TestContext.Current.CancellationToken); + } - Assert.NotNull(ev); - Assert.Equal(SubathonCommandType.Pause, ev.Command); - Assert.Equal(SubathonEventType.Command, ev.EventType); - Assert.Equal("AUTO", ev.User); - await service.StopAsync(TestContext.Current.CancellationToken); - } + [Fact] + public async Task RevokeTokenFile_DeletesFileAndClearsAccessToken() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); + service.RevokeTokenFile(); + Assert.False(storage.Exists(StorageKeys.TwitchAccessToken)); + await service.StopAsync(TestContext.Current.CancellationToken); + } - [Fact] - public async Task HandleChannelOffline_LockOnEnd_RaisesPauseCommand() - { - var config =MockConfig.MakeMockConfig(new() - { - { ("Twitch", "LockOnEnd"), "true" } - }); - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); + [Fact] + public async Task ValidateTokenAsync_ReturnsFalse_WhenTokenInvalid() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "badtoken" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); - var service = new TwitchService(null, config, storage); + bool result = await service.ValidateTokenAsync(); - var ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleChannelOffline", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new StreamOfflineArgs()]) - ); + Assert.False(result); + await service.StopAsync(TestContext.Current.CancellationToken); + } - Assert.NotNull(ev); - Assert.Equal(SubathonCommandType.Lock, ev.Command); - Assert.Equal(SubathonEventType.Command, ev.EventType); - Assert.Equal("AUTO", ev.User); - await service.StopAsync(TestContext.Current.CancellationToken); - } + [Fact] + public async Task HandleSubGift_RaisesGiftSubEvent() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); + + var meta = new WebsocketEventSubMetadata + { MessageId = Guid.NewGuid().ToString(), MessageTimestamp = DateTime.UtcNow }; + var args = new ChannelSubscriptionGiftArgs { + Metadata = meta, + Payload = new EventSubNotificationPayload + { Event = new ChannelSubscriptionGift { Tier = "1000", Total = 3, UserName = "gifter" } } + }; + + var ev = CaptureEvent(() => + service.InvokePrivate("HandleSubGift", null, args).Wait() + ); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchGiftSub, ev.EventType); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal(3, ev.Amount); + Assert.Equal("gifter", ev.User); + await service.StopAsync(TestContext.Current.CancellationToken); + } - [Fact] - public async Task HandleChannelFollow_RaisesFollowEvent() - { - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); + [Fact] + public async Task HandleBitsUse_RaisesCheerEvent() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); + + var meta = new WebsocketEventSubMetadata + { MessageId = Guid.NewGuid().ToString(), MessageTimestamp = DateTime.UtcNow }; + var args = new ChannelBitsUseArgs { + Metadata = meta, + Payload = new EventSubNotificationPayload + { Event = new ChannelBitsUse { Bits = 500, UserName = "cheerer", Type = "cheer" } } + }; + + var ev = CaptureEvent(() => + service.InvokePrivate("HandleBitsUse", null, args).Wait() + ); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchCheer, ev.EventType); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("cheerer", ev.User); + Assert.Equal("500", ev.Value); + await service.StopAsync(TestContext.Current.CancellationToken); + } - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); + [Fact] + public async Task HandleChannelSubscribe_RaisesSubEvent() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); - var meta = new WebsocketEventSubMetadata - { + var args = new ChannelSubscribeArgs { + Metadata = new WebsocketEventSubMetadata { MessageId = Guid.NewGuid().ToString(), - MessageTimestamp = DateTime.UtcNow, - }; - - var args = new ChannelFollowArgs - { - Metadata = meta, - Payload = new() - { - Event = new() - { - UserName = "Follower123" - } + MessageTimestamp = DateTime.UtcNow + }, + Payload = new EventSubNotificationPayload { + Event = new ChannelSubscribe { + Tier = "1000", + UserName = "subscriber", + IsGift = false } - }; + } + }; - var ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleChannelFollow", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]) - ); + var ev = CaptureEvent(() => service.InvokePrivate("HandleChannelSubscribe", + null, args).Wait()); - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchFollow, ev.EventType); - Assert.Equal("Follower123", ev.User); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - await service.StopAsync(TestContext.Current.CancellationToken); - } + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchSub, ev.EventType); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("subscriber", ev.User); + Assert.Equal("1000", ev.Value); + await service.StopAsync(TestContext.Current.CancellationToken); + } - [Fact] - public void HandleHypeTrainProgress_IgnoresSameOrLowerLevel() - { - TwitchService.SimulateHypeTrainStart(); - SubathonEvent? capturedEvent = CaptureEvent(() => - TwitchService.SimulateHypeTrainProgress(1)); - Assert.Null(capturedEvent); - } + [Fact] + public async Task HandleSubscriptionMsg_RaisesSubEvent() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); - [Fact] - public async Task HandleChatMessage_Command_RaisesCommandEvent() - { - var config =MockConfig.MakeMockConfig(new() - { - { ("Chat", "Commands.Pause"), "pause" }, + var args = new ChannelSubscriptionMessageArgs { + Metadata = new WebsocketEventSubMetadata { + MessageId = Guid.NewGuid().ToString(), + MessageTimestamp = DateTime.UtcNow + }, + Payload = new EventSubNotificationPayload { + Event = new ChannelSubscriptionMessage { + Tier = "2000", + UserName = "subscriber" + } + } + }; - { ("Chat", "Commands.Pause.permissions.Mods"), "true" }, - { ("Chat", "Commands.Pause.permissions.VIPs"), "false" }, - { ("Chat", "Commands.Pause.permissions.Whitelist"), "specialguy" } - }); - CommandService.SetConfig(config); - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); + var ev = CaptureEvent(() => service.InvokePrivate("HandleSubscriptionMsg", null, args).Wait()); - var service = new TwitchService(null, config, storage); - service.Login = "teststreamer"; - - ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcaster, string userName, - string displayName) - { - UserType type = UserType.Viewer; - if (isMod) type = UserType.Moderator; - if (isBroadcaster) type = UserType.Broadcaster; - - var details = UserDetails.Subscriber; - if (isMod) details |= UserDetails.Moderator; - if (isVip) details |= UserDetails.Vip; - - return new ChatMessage( - botUsername: "", - userId: "123456789", - userName: userName, - displayName: displayName, - hexColor: "#ffffff", - emoteSet: new("", ""), - message: message, - userType: type, - channel: "teststreamer", - id: Guid.NewGuid().ToString(), - subscribedMonthCount: 4, - roomId: "098765432", - isMe: false, - isBroadcaster: isBroadcaster, - noisy: Noisy.NotSet, - rawIrcMessage: "", - emoteReplacedMessage: "", - badges: [], - cheerBadge: new CheerBadge(0), - bits: 0, - bitsInDollars: 0, - userDetail: new UserDetail(details) - ); + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchSub, ev.EventType); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("subscriber", ev.User); + Assert.Equal("2000", ev.Value); + await service.StopAsync(TestContext.Current.CancellationToken); + } - } + [Fact] + public async Task HandleChannelRaid_RaisesRaidEvent() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); - var chatMsg = MakeMessage("!pause", false, false, - true, "test", "Test"); - var args = new OnMessageReceivedArgs(chatMsg); + var args = new ChannelRaidArgs { + Metadata = new WebsocketEventSubMetadata { + MessageId = Guid.NewGuid().ToString(), + MessageTimestamp = DateTime.UtcNow + }, + Payload = new EventSubNotificationPayload { + Event = new ChannelRaid { + FromBroadcasterUserName = "raider", + Viewers = 42 + } + } + }; + var ev = CaptureEvent(() => service.InvokePrivate("HandleChannelRaid", null, args).Wait()); - var ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]) - ); + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchRaid, ev.EventType); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("raider", ev.User); + Assert.Equal("42", ev.Value); + await service.StopAsync(TestContext.Current.CancellationToken); + } - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.Command, ev.EventType); - Assert.Equal(SubathonCommandType.Pause, ev.Command); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("test", ev.User); + [Fact] + public async Task HandleHypeTrainBeginV2_RaisesStartEvent() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); + var args = new ChannelHypeTrainBeginV2Args { + Metadata = new WebsocketEventSubMetadata { + MessageId = Guid.NewGuid().ToString(), + MessageTimestamp = DateTime.UtcNow + }, + Payload = new EventSubNotificationPayload { + Event = new HypeTrainBeginV2 { + BroadcasterUserName = "broadcaster", + Level = 1 + } + } + }; - chatMsg = MakeMessage("!pause", false, false, - false, "specialguy", "specialguy"); - args = new OnMessageReceivedArgs(chatMsg); + var ev = CaptureEvent(() => service.InvokePrivate("HandleHypeTrainBeginV2", null, args).Wait()); - ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]) - ); + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("start", ev.Value); + Assert.Equal("broadcaster", ev.User); + Assert.Equal(1, ev.Amount); + await service.StopAsync(TestContext.Current.CancellationToken); + } - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.Command, ev.EventType); - Assert.Equal(SubathonCommandType.Pause, ev.Command); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("specialguy", ev.User); - - chatMsg = MakeMessage("!pause", false, true, - false, "testuser", "TestUser"); - args = new OnMessageReceivedArgs(chatMsg); - - ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]) - ); + [Fact] + public async Task HandleHypeTrainProgressV2_RaisesProgressEvent() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); + await service.InvokePrivate("HandleHypeTrainBeginV2", null, new ChannelHypeTrainBeginV2Args { + Metadata = new WebsocketEventSubMetadata { + MessageId = Guid.NewGuid().ToString(), + MessageTimestamp = DateTime.UtcNow + }, + Payload = new EventSubNotificationPayload { + Event = new HypeTrainBeginV2 { + BroadcasterUserName = "broadcaster", + Level = 1 + } + } + }); - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.Command, ev.EventType); - Assert.Equal(SubathonCommandType.Pause, ev.Command); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("testuser", ev.User); - - chatMsg = MakeMessage("!pause", true, false, - false, "testuser", "TestUser"); - args = new OnMessageReceivedArgs(chatMsg); - - ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]) - ); + var args = new ChannelHypeTrainProgressV2Args { + Metadata = new WebsocketEventSubMetadata { + MessageId = Guid.NewGuid().ToString(), + MessageTimestamp = DateTime.UtcNow + }, + Payload = new EventSubNotificationPayload { + Event = new HypeTrainProgressV2 { + BroadcasterUserName = "broadcaster", + Level = 2 + } + } + }; - Assert.Null(ev); + var ev = CaptureEvent(() => service.InvokePrivate("HandleHypeTrainProgressV2", null, args).Wait()); - chatMsg = MakeMessage("!resume", true, true, - false, "testuser", "TestUser"); - args = new OnMessageReceivedArgs(chatMsg); + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("progress", ev.Value); + Assert.Equal("broadcaster", ev.User); + Assert.Equal(2, ev.Amount); + await service.StopAsync(TestContext.Current.CancellationToken); + } - ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]) - ); + [Fact] + public async Task HandleHypeTrainEndV2_RaisesEndEvent() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); - Assert.Null(ev); - await service.StopAsync(TestContext.Current.CancellationToken); - } + var args = new ChannelHypeTrainEndV2Args { + Metadata = new WebsocketEventSubMetadata { + MessageId = Guid.NewGuid().ToString(), + MessageTimestamp = DateTime.UtcNow + }, + Payload = new EventSubNotificationPayload { + Event = new HypeTrainEndV2 { + BroadcasterUserName = "broadcaster", + Level = 5 + } + } + }; - [Fact] - public async Task HasTokenFile_ReturnsCorrectValue() - { - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); + var ev = CaptureEvent(() => service.InvokePrivate("HandleHypeTrainEndV2", null, args).Wait()); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - Assert.True(service.HasTokenFile()); - storage.Delete(StorageKeys.TwitchAccessToken); - Assert.False(service.HasTokenFile()); - await service.StopAsync(TestContext.Current.CancellationToken); - } + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("end", ev.Value); + Assert.Equal("broadcaster", ev.User); + Assert.Equal(5, ev.Amount); + await service.StopAsync(TestContext.Current.CancellationToken); + } - [Fact] - public async Task RevokeTokenFile_DeletesFileAndClearsAccessToken() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - service.RevokeTokenFile(); - Assert.False(storage.Exists(StorageKeys.TwitchAccessToken)); - await service.StopAsync(TestContext.Current.CancellationToken); - } + [Fact] + public async Task HandleCharityEvent_RaisesDonationEvent() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); - [Fact] - public async Task ValidateTokenAsync_ReturnsFalse_WhenTokenInvalid() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "badtoken", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); + var args = new ChannelCharityCampaignDonateArgs { + Metadata = new WebsocketEventSubMetadata { + MessageId = Guid.NewGuid().ToString(), + MessageTimestamp = DateTime.UtcNow + }, + Payload = new EventSubNotificationPayload { + Event = new ChannelCharityCampaignDonate { + UserName = "donor", + Amount = new CharityAmount { + Value = 2550, + DecimalPlaces = 2, + Currency = "CAD" + } + } + } + }; - bool result = await service.ValidateTokenAsync(); + var ev = CaptureEvent(() => service.InvokePrivate("HandleCharityEvent", null, args).Wait()); - Assert.False(result); - await service.StopAsync(TestContext.Current.CancellationToken); - } + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.TwitchCharityDonation, ev.EventType); + Assert.Equal(SubathonEventSource.Twitch, ev.Source); + Assert.Equal("donor", ev.User); + Assert.Equal("25.50", ev.Value); + Assert.Equal("CAD", ev.Currency); + await service.StopAsync(TestContext.Current.CancellationToken); + } - [Fact] - public async Task HandleSubGift_RaisesGiftSubEvent() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - - var meta = new WebsocketEventSubMetadata - { MessageId = Guid.NewGuid().ToString(), MessageTimestamp = DateTime.UtcNow }; - var args = new ChannelSubscriptionGiftArgs - { - Metadata = meta, - Payload = new() { Event = new() { Tier = "1000", Total = 3, UserName = "gifter" } } - }; + [Fact] + public async Task StopAsync_CanBeCalledTwice_Safely() { + // in case any listeners still exist + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); + await service.StartAsync(CancellationToken.None); + + await service.StopAsync(CancellationToken.None); + await service.StopAsync(CancellationToken.None); + } - var ev = CaptureEvent(() => - service.InvokePrivate("HandleSubGift", null, args).Wait() + [Fact] + public async Task HandleChatMessage_BlerpNotification() { + var config = MockConfig.MakeMockConfig(); + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, config, storage); + + service.Login = "teststreamer"; + + ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcaster, string userName, + string displayName) { + UserType type = UserType.Viewer; + if (isMod) type = UserType.Moderator; + if (isBroadcaster) type = UserType.Broadcaster; + + var details = UserDetails.Subscriber; + if (isMod) details |= UserDetails.Moderator; + if (isVip) details |= UserDetails.Vip; + + return new ChatMessage( + "", + "123456789", + userName, + displayName, + "#ffffff", + new EmoteSet("", ""), + message, + type, + "teststreamer", + Guid.NewGuid().ToString(), + 4, + "098765432", + false, + isBroadcaster, + Noisy.NotSet, + "", + "", + [], + new CheerBadge(0), + 0, + 0, + new UserDetail(details) ); - - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchGiftSub, ev.EventType); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal(3, ev.Amount); - Assert.Equal("gifter", ev.User); - await service.StopAsync(TestContext.Current.CancellationToken); } - [Fact] - public async Task HandleBitsUse_RaisesCheerEvent() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - - var meta = new WebsocketEventSubMetadata - { MessageId = Guid.NewGuid().ToString(), MessageTimestamp = DateTime.UtcNow }; - var args = new ChannelBitsUseArgs - { - Metadata = meta, - Payload = new() { Event = new() { Bits = 500, UserName = "cheerer", Type = "cheer" } } - }; + var chatMsg = MakeMessage("SomeGuy used 500 bits to play XYZ", false, false, + false, "blerp", "blerp"); + var args = new OnMessageReceivedArgs(chatMsg); - var ev = CaptureEvent(() => - service.InvokePrivate("HandleBitsUse", null, args).Wait() - ); - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchCheer, ev.EventType); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("cheerer", ev.User); - Assert.Equal("500", ev.Value); - await service.StopAsync(TestContext.Current.CancellationToken); - } + var ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args]) + ); - [Fact] - public async Task HandleChannelSubscribe_RaisesSubEvent() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - - var args = new ChannelSubscribeArgs - { - Metadata = new WebsocketEventSubMetadata - { - MessageId = Guid.NewGuid().ToString(), - MessageTimestamp = DateTime.UtcNow - }, - Payload = new() - { - Event = new() - { - Tier = "1000", - UserName = "subscriber", - IsGift = false - } - } - }; - - var ev = CaptureEvent(() => service.InvokePrivate("HandleChannelSubscribe", - null, args).Wait()); + Assert.NotNull(ev); + Assert.Equal(SubathonEventType.BlerpBits, ev.EventType); + Assert.Equal(SubathonEventSource.Blerp, ev.Source); + Assert.Equal("SomeGuy", ev.User); + await service.StopAsync(TestContext.Current.CancellationToken); + } - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchSub, ev.EventType); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("subscriber", ev.User); - Assert.Equal("1000", ev.Value); - await service.StopAsync(TestContext.Current.CancellationToken); + [Fact] + public async Task HandleChatMessage_BlerpNotificationWrongChat() { + var config = MockConfig.MakeMockConfig(); + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" + }); + var service = new TwitchService(null, config, storage); + + service.Login = "teststreamer2"; + + ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcaster, string userName, + string displayName) { + UserType type = UserType.Viewer; + if (isMod) type = UserType.Moderator; + if (isBroadcaster) type = UserType.Broadcaster; + + var details = UserDetails.Subscriber; + if (isMod) details |= UserDetails.Moderator; + if (isVip) details |= UserDetails.Vip; + + return new ChatMessage( + "", + "123456789", + userName, + displayName, + "#ffffff", + new EmoteSet("", ""), + message, + type, + "teststreamer", + Guid.NewGuid().ToString(), + 4, + "098765432", + false, + isBroadcaster, + Noisy.NotSet, + "", + "", + [], + new CheerBadge(0), + 0, + 0, + new UserDetail(details) + ); } + var chatMsg = MakeMessage("SomeGuy used 500 bits to play XYZ", false, false, + false, "blerp", "blerp"); + var args = new OnMessageReceivedArgs(chatMsg); - [Fact] - public async Task HandleSubscriptionMsg_RaisesSubEvent() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - - var args = new ChannelSubscriptionMessageArgs - { - Metadata = new WebsocketEventSubMetadata - { - MessageId = Guid.NewGuid().ToString(), - MessageTimestamp = DateTime.UtcNow - }, - Payload = new() - { - Event = new() - { - Tier = "2000", - UserName = "subscriber" - } - } - }; - var ev = CaptureEvent(() => service.InvokePrivate("HandleSubscriptionMsg", null, args).Wait()); + var ev = CaptureEvent(() => + service + .GetType() + .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args]) + ); - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchSub, ev.EventType); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("subscriber", ev.User); - Assert.Equal("2000", ev.Value); - await service.StopAsync(TestContext.Current.CancellationToken); - } + Assert.Null(ev); + await service.StopAsync(TestContext.Current.CancellationToken); + } - [Fact] - public async Task HandleChannelRaid_RaisesRaidEvent() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - - var args = new ChannelRaidArgs - { - Metadata = new WebsocketEventSubMetadata - { - MessageId = Guid.NewGuid().ToString(), - MessageTimestamp = DateTime.UtcNow - }, - Payload = new() - { - Event = new() - { - FromBroadcasterUserName = "raider", - Viewers = 42 - } - } - }; + [Fact] + public async Task StartOAuthFlow_WritesTokenFile() { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "" + }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); - var ev = CaptureEvent(() => service.InvokePrivate("HandleChannelRaid", null, args).Wait()); + service.OpenBrowser = _ => { }; - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchRaid, ev.EventType); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("raider", ev.User); - Assert.Equal("42", ev.Value); - await service.StopAsync(TestContext.Current.CancellationToken); - } + var fakeToken = "test_access_token_abc123"; - [Fact] - public async Task HandleHypeTrainBeginV2_RaisesStartEvent() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - - var args = new ChannelHypeTrainBeginV2Args - { - Metadata = new WebsocketEventSubMetadata - { - MessageId = Guid.NewGuid().ToString(), - MessageTimestamp = DateTime.UtcNow - }, - Payload = new() - { - Event = new() - { - BroadcasterUserName = "broadcaster", - Level = 1 - } - } + var callbackSim = Task.Run(async () => { + await Task.Delay(200); + Utils.PendingOAuthCallback = new OAuthCallback { + Provider = "twitch", + AccessToken = fakeToken }; + }, TestContext.Current.CancellationToken); + + var oauthMethod = typeof(TwitchService) + .GetMethod("StartOAuthFlowAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + + await Task.WhenAll( + callbackSim, + (Task)oauthMethod.Invoke(service, null)! + ); + Assert.True(service.HasTokenFile()); + Assert.True(storage.Exists(StorageKeys.TwitchAccessToken)); + service.RevokeTokenFile(); + Assert.False(service.HasTokenFile()); + await service.StopAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task EventSub_ConnectsAndFiresConnectionUpdate() { + await using var wsServer = new MockEventSubServer(); - var ev = CaptureEvent(() => service.InvokePrivate("HandleHypeTrainBeginV2", null, args).Wait()); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("start", ev.Value); - Assert.Equal("broadcaster", ev.User); - Assert.Equal(1, ev.Amount); - await service.StopAsync(TestContext.Current.CancellationToken); + void Handler(IntegrationConnection conn) { + if (conn.Service == "EventSub") tcs.TrySetResult(conn.Status); } - [Fact] - public async Task HandleHypeTrainProgressV2_RaisesProgressEvent() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - await service.InvokePrivate("HandleHypeTrainBeginV2", null, new ChannelHypeTrainBeginV2Args - { - Metadata = new WebsocketEventSubMetadata - { - MessageId = Guid.NewGuid().ToString(), - MessageTimestamp = DateTime.UtcNow - }, - Payload = new() - { - Event = new() - { - BroadcasterUserName = "broadcaster", - Level = 1 - } - } + IntegrationEvents.ConnectionUpdated += Handler; + try { + var storage = new InMemorySecureStorage(new Dictionary { + [StorageKeys.TwitchAccessToken] = "123456abcdef" }); + var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); + service.EventSubUrl = wsServer.Uri; - var args = new ChannelHypeTrainProgressV2Args - { - Metadata = new WebsocketEventSubMetadata - { - MessageId = Guid.NewGuid().ToString(), - MessageTimestamp = DateTime.UtcNow - }, - Payload = new() - { - Event = new() - { - BroadcasterUserName = "broadcaster", - Level = 2 - } + _ = Task.Run(async () => { + try { + await (Task)typeof(TwitchService) + .GetMethod("InitializeEventSubAsync", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, null)!; } - }; - - var ev = CaptureEvent(() => service.InvokePrivate("HandleHypeTrainProgressV2", null, args).Wait()); + catch (Exception ex) { + tcs.TrySetException(ex); + } + }, TestContext.Current.CancellationToken); - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("progress", ev.Value); - Assert.Equal("broadcaster", ev.User); - Assert.Equal(2, ev.Amount); - await service.StopAsync(TestContext.Current.CancellationToken); + var result = await Task.WhenAny(tcs.Task, + Task.Delay(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); + Assert.True(result == tcs.Task, "Timed out - EventSub ConnectionUpdated never fired"); + Assert.True(await tcs.Task, "EventSub connected=false, expected true"); + service.Dispose(); } + finally { + IntegrationEvents.ConnectionUpdated -= Handler; + Assert.True(Utils.GetConnection(SubathonEventSource.Twitch, "EventSub").Status); + } + } - [Fact] - public async Task HandleHypeTrainEndV2_RaisesEndEvent() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - - var args = new ChannelHypeTrainEndV2Args - { - Metadata = new WebsocketEventSubMetadata - { - MessageId = Guid.NewGuid().ToString(), - MessageTimestamp = DateTime.UtcNow - }, - Payload = new() - { - Event = new() - { - BroadcasterUserName = "broadcaster", - Level = 5 - } + public class MockEventSubServer : IAsyncDisposable { + private readonly WebApplication _app; + private readonly CancellationTokenSource _cts = new(); + private WebSocket? _currentSocket; + + public MockEventSubServer() { + int port = GetFreePort(); + Uri = new Uri($"ws://127.0.0.1:{port}/ws"); + var httpBase = $"http://127.0.0.1:{port}"; + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls(httpBase); + builder.Logging.ClearProviders(); + _app = builder.Build(); + _app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) }); + + _app.Map("/ws", async context => { + if (!context.WebSockets.IsWebSocketRequest) { + context.Response.StatusCode = 400; + return; } - }; - var ev = CaptureEvent(() => service.InvokePrivate("HandleHypeTrainEndV2", null, args).Wait()); + var socket = await context.WebSockets.AcceptWebSocketAsync(); + _currentSocket = socket; - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("end", ev.Value); - Assert.Equal("broadcaster", ev.User); - Assert.Equal(5, ev.Amount); - await service.StopAsync(TestContext.Current.CancellationToken); - } - - [Fact] - public async Task HandleCharityEvent_RaisesDonationEvent() - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - - var args = new ChannelCharityCampaignDonateArgs - { - Metadata = new WebsocketEventSubMetadata - { - MessageId = Guid.NewGuid().ToString(), - MessageTimestamp = DateTime.UtcNow - }, - Payload = new() - { - Event = new() - { - UserName = "donor", - Amount = new() - { - Value = 2550, - DecimalPlaces = 2, - Currency = "CAD" + var welcome = new { + metadata = new { + message_id = Guid.NewGuid().ToString(), + message_type = "session_welcome", + message_timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ") + }, + payload = new { + session = new { + id = SessionId, + status = "connected", + keepalive_timeout_seconds = 10, + reconnect_url = (string?)null, + connected_at = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ") } } - } - }; - - var ev = CaptureEvent(() => service.InvokePrivate("HandleCharityEvent", null, args).Wait()); + }; - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.TwitchCharityDonation, ev.EventType); - Assert.Equal(SubathonEventSource.Twitch, ev.Source); - Assert.Equal("donor", ev.User); - Assert.Equal("25.50", ev.Value); - Assert.Equal("CAD", ev.Currency); - await service.StopAsync(TestContext.Current.CancellationToken); - } + await SendRawAsync(socket, JsonSerializer.Serialize(welcome)); - [Fact] - public async Task StopAsync_CanBeCalledTwice_Safely() - { - // in case any listeners still exist - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", + var buffer = new byte[4096]; + while (socket.State == WebSocketState.Open && !_cts.Token.IsCancellationRequested) + try { + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + readCts.CancelAfter(TimeSpan.FromSeconds(9)); + var result = await socket.ReceiveAsync(buffer, readCts.Token); + if (result.MessageType == WebSocketMessageType.Close) break; + } + catch (OperationCanceledException) { + if (socket.State == WebSocketState.Open) { + var keepalive = new { + metadata = new { + message_id = Guid.NewGuid().ToString(), + message_type = "session_keepalive", + message_timestamp = + DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ") + }, + payload = new { } + }; + await SendRawAsync(socket, JsonSerializer.Serialize(keepalive)); + } + } + catch { + break; + } }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - await service.StartAsync(CancellationToken.None); - await service.StopAsync(CancellationToken.None); - await service.StopAsync(CancellationToken.None); + _app.StartAsync().GetAwaiter().GetResult(); } - [Fact] - public async Task HandleChatMessage_BlerpNotification() - { - var config =MockConfig.MakeMockConfig(); - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null, config, storage); - - service.Login = "teststreamer"; - - ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcaster, string userName, - string displayName) - { - TwitchLib.Client.Enums.UserType type = UserType.Viewer; - if (isMod) type = UserType.Moderator; - if (isBroadcaster) type = UserType.Broadcaster; - - var details = UserDetails.Subscriber; - if (isMod) details |= UserDetails.Moderator; - if (isVip) details |= UserDetails.Vip; - - return new ChatMessage( - botUsername: "", - userId: "123456789", - userName: userName, - displayName: displayName, - hexColor: "#ffffff", - emoteSet: new("", ""), - message: message, - userType: type, - channel: "teststreamer", - id: Guid.NewGuid().ToString(), - subscribedMonthCount: 4, - roomId: "098765432", - isMe: false, - isBroadcaster: isBroadcaster, - noisy: Noisy.NotSet, - rawIrcMessage: "", - emoteReplacedMessage: "", - badges: [], - cheerBadge: new CheerBadge(0), - bits: 0, - bitsInDollars: 0, - userDetail: new UserDetail(details) - ); - - } - - var chatMsg = MakeMessage("SomeGuy used 500 bits to play XYZ", false, false, - false, "blerp", "blerp"); - var args = new OnMessageReceivedArgs(chatMsg); + public string SessionId { get; } = Guid.NewGuid().ToString("N")[..16]; + public Uri Uri { get; } + public async ValueTask DisposeAsync() { + _cts.Cancel(); + if (_currentSocket?.State == WebSocketState.Open) + try { + await _currentSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + } + catch { + /**/ + } - var ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]) - ); - - Assert.NotNull(ev); - Assert.Equal(SubathonEventType.BlerpBits, ev.EventType); - Assert.Equal(SubathonEventSource.Blerp, ev.Source); - Assert.Equal("SomeGuy", ev.User); - await service.StopAsync(TestContext.Current.CancellationToken); + await _app.StopAsync(); + await _app.DisposeAsync(); + _cts.Dispose(); } - [Fact] - public async Task HandleChatMessage_BlerpNotificationWrongChat() - { - var config =MockConfig.MakeMockConfig(); - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null, config, storage); - - service.Login = "teststreamer2"; - - ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcaster, string userName, - string displayName) - { - TwitchLib.Client.Enums.UserType type = UserType.Viewer; - if (isMod) type = UserType.Moderator; - if (isBroadcaster) type = UserType.Broadcaster; - - var details = UserDetails.Subscriber; - if (isMod) details |= UserDetails.Moderator; - if (isVip) details |= UserDetails.Vip; - - return new ChatMessage( - botUsername: "", - userId: "123456789", - userName: userName, - displayName: displayName, - hexColor: "#ffffff", - emoteSet: new("", ""), - message: message, - userType: type, - channel: "teststreamer", - id: Guid.NewGuid().ToString(), - subscribedMonthCount: 4, - roomId: "098765432", - isMe: false, - isBroadcaster: isBroadcaster, - noisy: Noisy.NotSet, - rawIrcMessage: "", - emoteReplacedMessage: "", - badges: [], - cheerBadge: new CheerBadge(0), - bits: 0, - bitsInDollars: 0, - userDetail: new UserDetail(details) - ); - - } - - var chatMsg = MakeMessage("SomeGuy used 500 bits to play XYZ", false, false, - false, "blerp", "blerp"); - var args = new OnMessageReceivedArgs(chatMsg); - - - var ev = CaptureEvent(() => - service - .GetType() - .GetMethod("HandleMessageCmdReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]) - ); + public async Task SendNotificationAsync(string subscriptionType, string subscriptionVersion, + object eventPayload) { + if (_currentSocket?.State != WebSocketState.Open) return; + + var notification = new { + metadata = new { + message_id = Guid.NewGuid().ToString(), + message_type = "notification", + message_timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ"), + subscription_type = subscriptionType, + subscription_version = subscriptionVersion + }, + payload = new { + subscription = new { + id = Guid.NewGuid().ToString(), + type = subscriptionType, + version = subscriptionVersion, + status = "enabled", + condition = new { broadcaster_user_id = "123456" }, + transport = new { method = "websocket", session_id = SessionId }, + created_at = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.ffffffZ") + }, + @event = eventPayload + } + }; - Assert.Null(ev); - await service.StopAsync(TestContext.Current.CancellationToken); + await SendRawAsync(_currentSocket, JsonSerializer.Serialize(notification)); } - [Fact] - public async Task StartOAuthFlow_WritesTokenFile() - { - - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "", - }); - var service = new TwitchService(null, MockConfig.MakeMockConfig(), storage); - - service.OpenBrowser = _ => { }; - - var fakeToken = "test_access_token_abc123"; - - var callbackSim = Task.Run(async () => - { - await Task.Delay(200); - Utils.PendingOAuthCallback = new OAuthCallback - { - Provider = "twitch", - AccessToken = fakeToken - }; - }, TestContext.Current.CancellationToken); - - var oauthMethod = typeof(TwitchService) - .GetMethod("StartOAuthFlowAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; - - await Task.WhenAll( - callbackSim, - (Task)oauthMethod.Invoke(service, null)! - ); - Assert.True(service.HasTokenFile()); - Assert.True(storage.Exists(StorageKeys.TwitchAccessToken)); - service.RevokeTokenFile(); - Assert.False(service.HasTokenFile()); - await service.StopAsync(TestContext.Current.CancellationToken); + private static async Task SendRawAsync(WebSocket socket, string json) { + byte[] bytes = Encoding.UTF8.GetBytes(json); + await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); } - - [Fact] - public async Task EventSub_ConnectsAndFiresConnectionUpdate() - { - await using var wsServer = new MockEventSubServer(); - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - void Handler(IntegrationConnection conn) - { - if (conn.Service == "EventSub") tcs.TrySetResult(conn.Status); - } - - IntegrationEvents.ConnectionUpdated += Handler; - try - { - var storage = new InMemorySecureStorage(new() - { - [StorageKeys.TwitchAccessToken] = "123456abcdef", - }); - var service = new TwitchService(null,MockConfig.MakeMockConfig(), storage); - service.EventSubUrl = wsServer.Uri; - - _ = Task.Run(async () => - { - try - { - await (Task)typeof(TwitchService) - .GetMethod("InitializeEventSubAsync", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, null)!; - } - catch (Exception ex) - { - tcs.TrySetException(ex); - } - }, TestContext.Current.CancellationToken); - - var result = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); - Assert.True(result == tcs.Task, "Timed out - EventSub ConnectionUpdated never fired"); - Assert.True(await tcs.Task, "EventSub connected=false, expected true"); - service.Dispose(); - } - finally - { - IntegrationEvents.ConnectionUpdated -= Handler; - Assert.True(Utils.GetConnection(SubathonEventSource.Twitch, "EventSub").Status); - } + public static int GetFreePort() { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + int port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; } } } \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs index 1665443d..5e5f446b 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs @@ -1,1064 +1,987 @@ -using Moq; +using System.Reflection; using Microsoft.Extensions.Logging; -using SubathonManager.Integration; -using SubathonManager.Core.Events; +using Moq; using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; +using SubathonManager.Integration; using SubathonManager.Services; +using SubathonManager.Tests.Utility; using YTLiveChat.Contracts.Models; using YTLiveChat.Contracts.Services; -using System.Reflection; -using SubathonManager.Core.Objects; -using SubathonManager.Tests.Utility; +using YTLiveChat.Services; + // ReSharper disable NullableWarningSuppressionIsUsed -namespace SubathonManager.Tests.IntegrationUnitTests -{ - [Collection("GlobalState")] - public class YouTubeServiceTests - { - private static SubathonEvent? CaptureEvent(Action trigger) => - EventUtil.SubathonEventCapture.CaptureRequired(trigger); - - [Fact] - public void SimulateSuperChat_ShouldRaiseEvent() - { - - SubathonEvent? captured = CaptureEvent( () => YouTubeService.SimulateSuperChat("12.5", "USD")); - - Assert.NotNull(captured); - Assert.Equal("12.5", captured!.Value); - Assert.Equal("USD", captured.Currency); - Assert.Equal(SubathonEventSource.Simulated, captured.Source); - Assert.Equal(SubathonEventType.YouTubeSuperChat, captured.EventType); - } +namespace SubathonManager.Tests.IntegrationUnitTests; - [Fact] - public void SimulateMembership_ShouldRaiseEvent() - { - SubathonEvent? captured = CaptureEvent( () => - YouTubeService.SimulateMembership("Gold")); - - Assert.NotNull(captured); - Assert.Equal("Gold", captured!.Value); - Assert.Equal("member", captured.Currency); - Assert.Equal(SubathonEventSource.Simulated, captured.Source); - Assert.Equal(SubathonEventType.YouTubeMembership, captured.EventType); - } +[Collection("GlobalState")] +public class YouTubeServiceTests { + private static SubathonEvent? CaptureEvent(Action trigger) { + return EventUtil.SubathonEventCapture.CaptureRequired(trigger); + } - [Fact] - public void SimulateGiftMemberships_ShouldRaiseEvent() - { - - SubathonEvent? captured = CaptureEvent( () => - YouTubeService.SimulateGiftMemberships(3)); - - Assert.NotNull(captured); - Assert.Equal("member", captured!.Currency); - Assert.Equal(SubathonEventSource.Simulated, captured.Source); - Assert.Equal(SubathonEventType.YouTubeGiftMembership, captured.EventType); - Assert.Equal(3, captured.Amount); - } + [Fact] + public void SimulateSuperChat_ShouldRaiseEvent() { + SubathonEvent? captured = CaptureEvent(() => YouTubeService.SimulateSuperChat("12.5")); - [Fact] - public void OnInitialPageLoaded_ShouldSetRunningTrueAndRaiseEvent() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - bool eventRaised = false; - Action handler = (conn) => - { - if (conn.Source == SubathonEventSource.YouTube) - { - eventRaised = true; - Assert.True(conn.Status); - } - }; - IntegrationEvents.ConnectionUpdated += handler; + Assert.NotNull(captured); + Assert.Equal("12.5", captured!.Value); + Assert.Equal("USD", captured.Currency); + Assert.Equal(SubathonEventSource.Simulated, captured.Source); + Assert.Equal(SubathonEventType.YouTubeSuperChat, captured.EventType); + } - try - { - var method = typeof(YouTubeService) - .GetMethod("OnInitialPageLoaded", BindingFlags.NonPublic | BindingFlags.Instance); + [Fact] + public void SimulateMembership_ShouldRaiseEvent() { + SubathonEvent? captured = CaptureEvent(() => + YouTubeService.SimulateMembership("Gold")); - var args = new InitialPageLoadedEventArgs - { - LiveId = "TEST_LIVE_ID" - }; + Assert.NotNull(captured); + Assert.Equal("Gold", captured!.Value); + Assert.Equal("member", captured.Currency); + Assert.Equal(SubathonEventSource.Simulated, captured.Source); + Assert.Equal(SubathonEventType.YouTubeMembership, captured.EventType); + } - method?.Invoke(service, [null, args]); + [Fact] + public void SimulateGiftMemberships_ShouldRaiseEvent() { + SubathonEvent? captured = CaptureEvent(() => + YouTubeService.SimulateGiftMemberships(3)); - Assert.True(eventRaised); - Assert.True(service.Running); - } - finally - { - IntegrationEvents.ConnectionUpdated -= handler; + Assert.NotNull(captured); + Assert.Equal("member", captured!.Currency); + Assert.Equal(SubathonEventSource.Simulated, captured.Source); + Assert.Equal(SubathonEventType.YouTubeGiftMembership, captured.EventType); + Assert.Equal(3, captured.Amount); + } + + [Fact] + public void OnInitialPageLoaded_ShouldSetRunningTrueAndRaiseEvent() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + var eventRaised = false; + Action handler = conn => { + if (conn.Source == SubathonEventSource.YouTube) { + eventRaised = true; + Assert.True(conn.Status); } - } + }; + IntegrationEvents.ConnectionUpdated += handler; + try { + MethodInfo? method = typeof(YouTubeService) + .GetMethod("OnInitialPageLoaded", BindingFlags.NonPublic | BindingFlags.Instance); - [Fact] - public void OnErrorOccurred_ShouldSetRunningFalseAndRaiseError() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - bool eventRaised = false; - Action handler = (conn) => - { - if (conn.Source == SubathonEventSource.YouTube) - { - eventRaised = true; - Assert.False(conn.Status); - } + var args = new InitialPageLoadedEventArgs { + LiveId = "TEST_LIVE_ID" }; - IntegrationEvents.ConnectionUpdated += handler; + method?.Invoke(service, [null, args]); + + Assert.True(eventRaised); + Assert.True(service.Running); + } + finally { + IntegrationEvents.ConnectionUpdated -= handler; + } + } - try - { - var method = typeof(YouTubeService) - .GetMethod("OnErrorOccurred", BindingFlags.NonPublic | BindingFlags.Instance); - var errorArgs = new ErrorOccurredEventArgs(new Exception("Test error")); - method?.Invoke(service, [null, errorArgs]); + [Fact] + public void OnErrorOccurred_ShouldSetRunningFalseAndRaiseError() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - Assert.True(eventRaised); - Assert.False(service.Running); - } - finally - { - IntegrationEvents.ConnectionUpdated -= handler; + var eventRaised = false; + Action handler = conn => { + if (conn.Source == SubathonEventSource.YouTube) { + eventRaised = true; + Assert.False(conn.Status); } - } + }; - [Fact] - public void OnChatReceived_ShouldRaiseSuperChatEvent() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - var field = typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance); - field!.SetValue(service, "@TestChannel"); - - service.Running = true; - - var method = typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance); - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "TestUser", ChannelId = "TestChannelId" }, - Message = [], - Superchat = new Superchat - { - AmountString = "$5.00", AmountValue = (decimal)5.00, Currency = "CA$", - BodyBackgroundColor = "", - } - }; + IntegrationEvents.ConnectionUpdated += handler; + + try { + MethodInfo? method = typeof(YouTubeService) + .GetMethod("OnErrorOccurred", BindingFlags.NonPublic | BindingFlags.Instance); - var eventArgs = new ChatReceivedEventArgs { ChatItem = chatItem }; - - SubathonEvent? capturedEvent = CaptureEvent( () => method?.Invoke(service, [null, eventArgs])); + var errorArgs = new ErrorOccurredEventArgs(new Exception("Test error")); + method?.Invoke(service, [null, errorArgs]); - Assert.NotNull(capturedEvent); - Assert.Equal("TestUser", capturedEvent!.User); - Assert.Equal("CA$", capturedEvent.Currency); - Assert.Equal("5", capturedEvent.Value); - Assert.Equal(SubathonEventSource.YouTube, capturedEvent.Source); - Assert.Equal(SubathonEventType.YouTubeSuperChat, capturedEvent.EventType); + Assert.True(eventRaised); + Assert.False(service.Running); } + finally { + IntegrationEvents.ConnectionUpdated -= handler; + } + } + [Fact] + public void OnChatReceived_ShouldRaiseSuperChatEvent() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + FieldInfo? field = typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance); + field!.SetValue(service, "@TestChannel"); + + service.Running = true; + + MethodInfo? method = typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance); + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "TestUser", ChannelId = "TestChannelId" }, + Message = [], + Superchat = new Superchat { + AmountString = "$5.00", AmountValue = (decimal)5.00, Currency = "CA$", + BodyBackgroundColor = "" + } + }; - [Fact] - public void OnChatReceived_Membership_Gift_RaisesEvent() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - service.Running = true; - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Message = [], - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "MemberUser", ChannelId = "MemberChannelId" }, - MembershipDetails = new MembershipDetails - { - EventType = MembershipEventType.GiftPurchase, - GifterUsername = "Gifter", - GiftCount = 3 - } - }; - - var args = new ChatReceivedEventArgs { ChatItem = chatItem }; - - SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args])); + var eventArgs = new ChatReceivedEventArgs { ChatItem = chatItem }; - Assert.NotNull(captured); - Assert.Equal(SubathonEventSource.YouTube, captured!.Source); - Assert.Equal(SubathonEventType.YouTubeGiftMembership, captured!.EventType); - Assert.Equal("Gifter", captured.User); - Assert.Equal(3, captured.Amount); + SubathonEvent? capturedEvent = CaptureEvent(() => method?.Invoke(service, [null, eventArgs])); - } - - [Fact] - public void OnChatReceived_Membership_GiftRedemption_DoesNotRaiseEvent() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - bool eventRaised = false; - Action handler = e => eventRaised = true; - SubathonEvents.SubathonEventCreated += handler; - - service.Running = true; - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Message = [], - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "Gifter", ChannelId = "MemberChannelId" }, - MembershipDetails = new MembershipDetails - { - EventType = MembershipEventType.GiftRedemption - } - }; + Assert.NotNull(capturedEvent); + Assert.Equal("TestUser", capturedEvent!.User); + Assert.Equal("CA$", capturedEvent.Currency); + Assert.Equal("5", capturedEvent.Value); + Assert.Equal(SubathonEventSource.YouTube, capturedEvent.Source); + Assert.Equal(SubathonEventType.YouTubeSuperChat, capturedEvent.EventType); + } - var args = new ChatReceivedEventArgs { ChatItem = chatItem }; - typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]); - Assert.False(eventRaised); - } - - [Fact] - public void OnChatReceived_ChatCommand_InvokesCommandService() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - var configCs = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> - { - { ("Chat", "Commands.Pause"), "pause" }, - { ("Chat", "Commands.Pause.permissions.Mods"), "true" }, - { ("Chat", "Commands.Pause.permissions.VIPs"), "false" }, - { ("Chat", "Commands.Pause.permissions.Whitelist"), "specialguy" } - }); - CommandService.SetConfig(configCs); - - service.Running = true; - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - - MessagePart parts = new TextPart{Text="!pause"}; - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - IsModerator = true, - Author = new Author { Name = "User", ChannelId = "12345"}, - Message = [parts] - }; + [Fact] + public void OnChatReceived_Membership_Gift_RaisesEvent() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + service.Running = true; + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Message = [], + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "MemberUser", ChannelId = "MemberChannelId" }, + MembershipDetails = new MembershipDetails { + EventType = MembershipEventType.GiftPurchase, + GifterUsername = "Gifter", + GiftCount = 3 + } + }; - var args = new ChatReceivedEventArgs { ChatItem = chatItem }; - - SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args])); - - Assert.NotNull(captured); - Assert.Equal(SubathonEventSource.YouTube, captured!.Source); - Assert.Equal(SubathonEventType.Command, captured!.EventType); - Assert.Equal(SubathonCommandType.Pause, captured!.Command); - Assert.Equal("User", captured.User); - } - - [Fact] - public void OnChatStopped_ShouldSetRunningFalseAndRaiseEvent() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - bool eventRaised = false; - Action handler = (conn) => - { - if (conn.Source == SubathonEventSource.YouTube) - { - eventRaised = true; - Assert.False(conn.Status); - } - }; - IntegrationEvents.ConnectionUpdated += handler; + var args = new ChatReceivedEventArgs { ChatItem = chatItem }; - try - { - var method = typeof(YouTubeService) - .GetMethod("OnChatStopped", BindingFlags.NonPublic | BindingFlags.Instance); + SubathonEvent? captured = CaptureEvent(() => typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args])); - var eventArgs = new ChatStoppedEventArgs(); - method?.Invoke(service, [null, eventArgs]); + Assert.NotNull(captured); + Assert.Equal(SubathonEventSource.YouTube, captured!.Source); + Assert.Equal(SubathonEventType.YouTubeGiftMembership, captured!.EventType); + Assert.Equal("Gifter", captured.User); + Assert.Equal(3, captured.Amount); + } - Assert.False(service.Running); - Assert.True(eventRaised); - } - finally - { - IntegrationEvents.ConnectionUpdated -= handler; + [Fact] + public void OnChatReceived_Membership_GiftRedemption_DoesNotRaiseEvent() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + var eventRaised = false; + Action handler = e => eventRaised = true; + SubathonEvents.SubathonEventCreated += handler; + + service.Running = true; + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Message = [], + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "Gifter", ChannelId = "MemberChannelId" }, + MembershipDetails = new MembershipDetails { + EventType = MembershipEventType.GiftRedemption } - } + }; + + var args = new ChatReceivedEventArgs { ChatItem = chatItem }; + typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args]); - [Fact] - public async Task Start_ShouldReturnTrueAndSetHandle() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - config.Setup(c => c.Get("YouTube", "Handle", "")).Returns("@TestChannel"); - - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - bool eventRaised = false; - bool ranNone = false; - Action handler = (conn) => - { - if (conn.Source != SubathonEventSource.YouTube) return; - if (!ranNone) - { - Assert.Equal("None", conn.Name); - ranNone = true; - eventRaised = true; - return; - } - - Assert.True(ranNone); + Assert.False(eventRaised); + } + + [Fact] + public void OnChatReceived_ChatCommand_InvokesCommandService() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + IConfig configCs = MockConfig.MakeMockConfig(new Dictionary<(string, string), string> { + { ("Chat", "Commands.Pause"), "pause" }, + { ("Chat", "Commands.Pause.permissions.Mods"), "true" }, + { ("Chat", "Commands.Pause.permissions.VIPs"), "false" }, + { ("Chat", "Commands.Pause.permissions.Whitelist"), "specialguy" } + }); + CommandService.SetConfig(configCs); + + service.Running = true; + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + + MessagePart parts = new TextPart { Text = "!pause" }; + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + IsModerator = true, + Author = new Author { Name = "User", ChannelId = "12345" }, + Message = [parts] + }; + + var args = new ChatReceivedEventArgs { ChatItem = chatItem }; + + SubathonEvent? captured = CaptureEvent(() => typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args])); + + Assert.NotNull(captured); + Assert.Equal(SubathonEventSource.YouTube, captured!.Source); + Assert.Equal(SubathonEventType.Command, captured!.EventType); + Assert.Equal(SubathonCommandType.Pause, captured!.Command); + Assert.Equal("User", captured.User); + } + + [Fact] + public void OnChatStopped_ShouldSetRunningFalseAndRaiseEvent() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + var eventRaised = false; + Action handler = conn => { + if (conn.Source == SubathonEventSource.YouTube) { eventRaised = true; Assert.False(conn.Status); - Assert.Equal("@TestChannel", conn.Name); - Assert.Equal("@TestChannel", Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Name); - Assert.False(Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Status); - Assert.False(service.Running); - }; - IntegrationEvents.ConnectionUpdated += handler; - - try - { - //bool result = service.Start(null); - await service.StartAsync(CancellationToken.None); - Assert.False(service.Running); // happens during events - await Task.Delay(300, TestContext.Current.CancellationToken); - Assert.True(eventRaised); - Assert.Equal("@TestChannel", Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Name); - Assert.False(Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Status); - await service.StopAsync(CancellationToken.None); } - finally - { - IntegrationEvents.ConnectionUpdated -= handler; - } - } - - [Fact] - public void OnChatReceived_BlerpMessage() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - - config.Setup(c => c.GetBool("Extensions", "Blerp.Enabled", true)).Returns(true); - - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - service.Running = true; - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - - MessagePart parts = new TextPart{Text="SomeGuy used 500 beets to play FunnyHaHa"}; - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - IsModerator = true, - Author = new Author { Name = "blerp", ChannelId = "12345"}, - Message = [parts] - }; + }; + IntegrationEvents.ConnectionUpdated += handler; - var args = new ChatReceivedEventArgs { ChatItem = chatItem }; - - SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args])); - - Assert.NotNull(captured); - Assert.Equal(SubathonEventSource.Blerp, captured!.Source); - Assert.Equal(SubathonEventType.BlerpBeets, captured!.EventType); - Assert.Equal("SomeGuy", captured.User); - } - - [Fact] - public void OnChatReceived_BlerpMessage_Disabled() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - - config.Setup(c => c.GetBool("Extensions", "Blerp.Enabled", true)).Returns(false); - - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - service.Running = true; - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - - MessagePart parts = new TextPart{Text="SomeGuy used 500 beets to play FunnyHaHa"}; - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - IsModerator = true, - Author = new Author { Name = "blerp", ChannelId = "12345"}, - Message = [parts] - }; + try { + MethodInfo? method = typeof(YouTubeService) + .GetMethod("OnChatStopped", BindingFlags.NonPublic | BindingFlags.Instance); - var args = new ChatReceivedEventArgs { ChatItem = chatItem }; - - SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args])); - - Assert.Null(captured); + var eventArgs = new ChatStoppedEventArgs(); + method?.Invoke(service, [null, eventArgs]); + + Assert.False(service.Running); + Assert.True(eventRaised); } - - [Fact] - public void SimulateSuperChat_ShouldNotRaiseEvent_WhenValueInvalid() - { - bool raised = false; - Action handler = _ => raised = true; - SubathonEvents.SubathonEventCreated += handler; + finally { + IntegrationEvents.ConnectionUpdated -= handler; + } + } - YouTubeService.SimulateSuperChat("notanumber", "USD"); + [Fact] + public async Task Start_ShouldReturnTrueAndSetHandle() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + config.Setup(c => c.Get("YouTube", "Handle", "")).Returns("@TestChannel"); + + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + var eventRaised = false; + var ranNone = false; + Action handler = conn => { + if (conn.Source != SubathonEventSource.YouTube) return; + if (!ranNone) { + Assert.Equal("None", conn.Name); + ranNone = true; + eventRaised = true; + return; + } - Assert.False(raised); - } - - [Theory] - [InlineData("")] - [InlineData("@")] - [InlineData(" ")] - public void Start_ShouldReturnFalse_WhenHandleIsInvalid(string handle) - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - config.Setup(c => c.Get("YouTube", "Handle", "")).Returns(handle); - - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - bool result = service.Start(null); - - Assert.False(result); + Assert.True(ranNone); + eventRaised = true; + Assert.False(conn.Status); + Assert.Equal("@TestChannel", conn.Name); + Assert.Equal("@TestChannel", Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Name); + Assert.False(Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Status); Assert.False(service.Running); + }; + IntegrationEvents.ConnectionUpdated += handler; + + try { + //bool result = service.Start(null); + await service.StartAsync(CancellationToken.None); + Assert.False(service.Running); // happens during events + await Task.Delay(300, TestContext.Current.CancellationToken); + Assert.True(eventRaised); + Assert.Equal("@TestChannel", Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Name); + Assert.False(Utils.GetConnection(SubathonEventSource.YouTube, "Chat").Status); + await service.StopAsync(CancellationToken.None); } - - [Fact] - public void Start_ShouldPrependAt_WhenHandleMissingIt() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - config.Setup(c => c.Get("YouTube", "Handle", "")).Returns("TestChannel"); - - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - service.Start(null); - - var handle = typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .GetValue(service) as string; - - Assert.Equal("@TestChannel", handle); + finally { + IntegrationEvents.ConnectionUpdated -= handler; } - - [Fact] - public void OnChatReceived_ShouldReturnEarly_WhenHandleIsEmpty() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - bool raised = false; - Action handler = _ => raised = true; - SubathonEvents.SubathonEventCreated += handler; - service.Running = true; - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "TestUser", ChannelId = "id" }, - Message = [], - Superchat = new Superchat { AmountString = "$5.00", AmountValue = 5, Currency = "USD", - BodyBackgroundColor = "" } - }; + } - typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + [Fact] + public void OnChatReceived_BlerpMessage() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + + config.Setup(c => c.GetBool("Extensions", "Blerp.Enabled", true)).Returns(true); + + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + service.Running = true; + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + + MessagePart parts = new TextPart { Text = "SomeGuy used 500 beets to play FunnyHaHa" }; + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + IsModerator = true, + Author = new Author { Name = "blerp", ChannelId = "12345" }, + Message = [parts] + }; + + var args = new ChatReceivedEventArgs { ChatItem = chatItem }; + + SubathonEvent? captured = CaptureEvent(() => typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args])); + + Assert.NotNull(captured); + Assert.Equal(SubathonEventSource.Blerp, captured!.Source); + Assert.Equal(SubathonEventType.BlerpBeets, captured!.EventType); + Assert.Equal("SomeGuy", captured.User); + } - Assert.False(raised); - } + [Fact] + public void OnChatReceived_BlerpMessage_Disabled() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); - - [Fact] - public void OnChatReceived_ShouldRaiseConnectionUpdate_WhenNotRunning() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - - service.Running = false; // - - bool connectionEventRaised = false; - - Action handler = (conn) => - { - if (conn is { Source: SubathonEventSource.YouTube }) - { - connectionEventRaised = true; - } - }; - IntegrationEvents.ConnectionUpdated += handler; - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "TestUser", ChannelId = "id" }, - Message = [], - Superchat = new Superchat - { - AmountString = "$5.00", - AmountValue = 5, - Currency = "USD", - BodyBackgroundColor = "" - } - }; + config.Setup(c => c.GetBool("Extensions", "Blerp.Enabled", true)).Returns(false); - typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - Assert.True(connectionEventRaised); - Assert.True(service.Running); - IntegrationEvents.ConnectionUpdated -= handler; - } - - [Fact] - public void OnChatReceived_ShouldReturnEarly_WhenMessageIsTooOld() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - service.Running = true; - - bool raised = false; - Action handler = _ => raised = true; - SubathonEvents.SubathonEventCreated += handler; - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5), // older than 3 min - Author = new Author { Name = "OldUser", ChannelId = "id" }, - Message = [], - Superchat = new Superchat - { - AmountString = "$5.00", - AmountValue = 5, - Currency = "USD", - BodyBackgroundColor = "" - } - }; + service.Running = true; + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); - typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + MessagePart parts = new TextPart { Text = "SomeGuy used 500 beets to play FunnyHaHa" }; + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + IsModerator = true, + Author = new Author { Name = "blerp", ChannelId = "12345" }, + Message = [parts] + }; - Assert.False(raised); - } + var args = new ChatReceivedEventArgs { ChatItem = chatItem }; - [Fact] - public void OnChatReceived_SuperChat_UnknownCurrency_RaisesErrorEvent() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - service.Running = true; - - bool errorRaised = false; - Action errorHandler = (level, _, _, _) => - { - if (level == "WARN") errorRaised = true; - }; - ErrorMessageEvents.ErrorEventOccured += errorHandler; - - // empty currency string will resolve to "???" - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "TestUser", ChannelId = "id" }, - Message = [], - Superchat = new Superchat - { - AmountString = "5.00", - AmountValue = 5, - Currency = "", - BodyBackgroundColor = "" - } - }; + SubathonEvent? captured = CaptureEvent(() => typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, args])); - typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + Assert.Null(captured); + } - Assert.True(errorRaised); - ErrorMessageEvents.ErrorEventOccured -= errorHandler; - } + [Fact] + public void SimulateSuperChat_ShouldNotRaiseEvent_WhenValueInvalid() { + var raised = false; + Action handler = _ => raised = true; + SubathonEvents.SubathonEventCreated += handler; - [Fact] - public void OnChatReceived_SuperChat_USD_WithoutDollarSign_ParsesCurrency() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - service.Running = true; - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "TestUser", ChannelId = "id" }, - Message = [], - Superchat = new Superchat - { - AmountString = "CA$5.00", - AmountValue = 5, - Currency = "USD", - BodyBackgroundColor = "" - } - }; - - SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + YouTubeService.SimulateSuperChat("notanumber"); - Assert.NotNull(captured); - Assert.NotEqual("USD", captured!.Currency); - } - - [Theory] - [InlineData("superchat")] - [InlineData("membership")] - public void OnChatReceived_Ticker_DoesNotRaiseEvent(string itemType) - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - service.Running = true; - - bool raised = false; - Action handler = _ => raised = true; - SubathonEvents.SubathonEventCreated += handler; - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - IsTicker = true, - Author = new Author { Name = "TestUser", ChannelId = "id" }, - Message = [], - Superchat = itemType == "superchat" - ? new Superchat { AmountString = "$5.00", AmountValue = 5, Currency = "USD", BodyBackgroundColor = "" } - : null, - MembershipDetails = itemType == "membership" - ? new MembershipDetails { EventType = MembershipEventType.New, LevelName = "Gold" } - : null - }; + Assert.False(raised); + } - typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + [Theory] + [InlineData("")] + [InlineData("@")] + [InlineData(" ")] + public void Start_ShouldReturnFalse_WhenHandleIsInvalid(string handle) { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + config.Setup(c => c.Get("YouTube", "Handle", "")).Returns(handle); - Assert.False(raised); - } + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - [Theory] - [InlineData(true)] - [InlineData(false)] - public void OnChatReceived_Membership_IdHandling(bool isValidGuid) - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - service.Running = true; - - var knownGuid = Guid.NewGuid(); - string itemId = isValidGuid ? knownGuid.ToString() : "yt-not-a-guid"; - - var chatItem = new ChatItem - { - Id = itemId, - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "MemberUser", ChannelId = "id" }, - Message = [], - MembershipDetails = new MembershipDetails { EventType = MembershipEventType.New, LevelName = "Gold" } - }; + bool result = service.Start(null); - - SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + Assert.False(result); + Assert.False(service.Running); + } - Assert.NotNull(captured); - Assert.NotEqual(Guid.Empty, captured!.Id); - if (isValidGuid) - Assert.Equal(knownGuid, captured.Id); + [Fact] + public void Start_ShouldPrependAt_WhenHandleMissingIt() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + config.Setup(c => c.Get("YouTube", "Handle", "")).Returns("TestChannel"); - } + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + service.Start(null); - [Theory] - [InlineData(MembershipEventType.New, "Gold", "New Member", SubathonEventType.YouTubeMembership, "Gold")] - [InlineData(MembershipEventType.Milestone, "DEFAULT", "Special guy", SubathonEventType.YouTubeMembership, "DEFAULT")] - [InlineData(MembershipEventType.Unknown, "Gold", "Some subtext", SubathonEventType.YouTubeMembership, "Some subtext")] // not true case - [InlineData(MembershipEventType.Unknown, "Gold", "Welcome new member!", SubathonEventType.YouTubeMembership, "Gold")] - [InlineData(MembershipEventType.Unknown, "Upgraded membership to MyTier!!", "Upgraded membership to MyTier!!", SubathonEventType.YouTubeMembership, "MyTier!")] - public void OnChatReceived_Membership_RaisesYouTubeMembership( - MembershipEventType eventType, string levelName, string headerSubtext, SubathonEventType expectedEventType, string expectedLevel) - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - service.Running = true; - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "MemberUser", ChannelId = "id" }, - Message = [], - MembershipDetails = new MembershipDetails - { - EventType = eventType, - HeaderSubtext = headerSubtext, - LevelName = levelName, - MilestoneMonths = eventType == MembershipEventType.Milestone ? 6 : 0 - } - }; + var handle = typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(service) as string; - - SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + Assert.Equal("@TestChannel", handle); + } - Assert.NotNull(captured); - Assert.Equal(expectedEventType, captured!.EventType); - Assert.Equal(SubathonEventSource.YouTube, captured.Source); - Assert.Equal(expectedLevel, captured.Value); - Assert.Equal("MemberUser", captured.User); - } - - [Fact] - public void OnChatReceived_Membership_Milestone_TierIsMember_UsesHeaderSubtext() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - service.Running = true; - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "MemberUser", ChannelId = "id" }, - Message = [], - MembershipDetails = new MembershipDetails - { - EventType = MembershipEventType.Milestone, - HeaderSubtext = "Gold", - MilestoneMonths = 6, - LevelName = "Member" // fallback to HeaderSubtext - } - }; + [Fact] + public void OnChatReceived_ShouldReturnEarly_WhenHandleIsEmpty() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + var raised = false; + Action handler = _ => raised = true; + SubathonEvents.SubathonEventCreated += handler; + service.Running = true; + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "TestUser", ChannelId = "id" }, + Message = [], + Superchat = new Superchat { + AmountString = "$5.00", AmountValue = 5, Currency = "USD", + BodyBackgroundColor = "" + } + }; - - SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); - Assert.NotNull(captured); - Assert.Equal("Gold", captured!.Value); - } + Assert.False(raised); + } - [Fact] - public void OnChatReceived_Membership_GiftPurchase_NullGifterUsername_UsesAuthorName() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - service.Running = true; - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "AuthorFallback", ChannelId = "id" }, - Message = [], - MembershipDetails = new MembershipDetails - { - EventType = MembershipEventType.GiftPurchase, - GifterUsername = null, - GiftCount = 2 - } - }; - - SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + [Fact] + public void OnChatReceived_ShouldRaiseConnectionUpdate_WhenNotRunning() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + + service.Running = false; // + + var connectionEventRaised = false; + + Action handler = conn => { + if (conn is { Source: SubathonEventSource.YouTube }) connectionEventRaised = true; + }; + IntegrationEvents.ConnectionUpdated += handler; + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "TestUser", ChannelId = "id" }, + Message = [], + Superchat = new Superchat { + AmountString = "$5.00", + AmountValue = 5, + Currency = "USD", + BodyBackgroundColor = "" + } + }; - Assert.NotNull(captured); - Assert.Equal("AuthorFallback", captured!.User); - Assert.Equal(2, captured.Amount); - } - - - - [Theory] - [InlineData(" ", " ")] - [InlineData("member", "member")] - [InlineData("Member", "Member")] - [InlineData("MEMBER", "MEMBER")] - public void OnChatReceived_Membership_TierNormalizesToDefault(string levelName, string headerSubtext) - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - service.Running = true; - - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "MemberUser", ChannelId = "id" }, - Message = [], - MembershipDetails = new MembershipDetails - { - EventType = MembershipEventType.New, - HeaderSubtext = headerSubtext, - LevelName = levelName - } - }; + typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); - - SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + Assert.True(connectionEventRaised); + Assert.True(service.Running); + IntegrationEvents.ConnectionUpdated -= handler; + } - Assert.NotNull(captured); - Assert.Equal("DEFAULT", captured!.Value); - } - - [Fact] - public void TryReconnectLoop_ShouldNotStart_WhenHandleIsEmpty() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - var method = typeof(YouTubeService) - .GetMethod("TryReconnectLoop", BindingFlags.NonPublic | BindingFlags.Instance); - - var ex = Record.Exception(() => method!.Invoke(service, null)); - Assert.Null(ex); - } - - [Fact] - public void OnErrorOccurred_CanonicalLinkError_LogsOnceOnly() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); - - var method = typeof(YouTubeService) - .GetMethod("OnErrorOccurred", BindingFlags.NonPublic | BindingFlags.Instance); + [Fact] + public void OnChatReceived_ShouldReturnEarly_WhenMessageIsTooOld() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + service.Running = true; + + var raised = false; + Action handler = _ => raised = true; + SubathonEvents.SubathonEventCreated += handler; + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5), // older than 3 min + Author = new Author { Name = "OldUser", ChannelId = "id" }, + Message = [], + Superchat = new Superchat { + AmountString = "$5.00", + AmountValue = 5, + Currency = "USD", + BodyBackgroundColor = "" + } + }; - var errorArgs = new ErrorOccurredEventArgs(new Exception("canonical link not found")); + typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); - // logs only 1x - method!.Invoke(service, [null, errorArgs]); - method!.Invoke(service, [null, errorArgs]); + Assert.False(raised); + } - var counter = typeof(YouTubeService) - .GetField("_canonicalLinkErrorCount", BindingFlags.NonPublic | BindingFlags.Instance)! - .GetValue(service) as int?; + [Fact] + public void OnChatReceived_SuperChat_UnknownCurrency_RaisesErrorEvent() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + service.Running = true; + + var errorRaised = false; + Action errorHandler = (level, _, _, _) => { + if (level == "WARN") errorRaised = true; + }; + ErrorMessageEvents.ErrorEventOccured += errorHandler; + + // empty currency string will resolve to "???" + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "TestUser", ChannelId = "id" }, + Message = [], + Superchat = new Superchat { + AmountString = "5.00", + AmountValue = 5, + Currency = "", + BodyBackgroundColor = "" + } + }; - Assert.Equal(2, counter); - Assert.False(service.Running); + typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); - logger.Verify( - x => x.Log( - LogLevel.Warning, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("canonical link")), - null, - It.IsAny>()), - Times.Once); - } + Assert.True(errorRaised); + ErrorMessageEvents.ErrorEventOccured -= errorHandler; + } - [Fact] - public void OnErrorOccurred_InitialContinuationToken_LogsWarningWithMessage() - { - var logger = new Mock>(); - var chatLogger = new Mock>(); - var httpLogger = new Mock>(); - var config = new Mock(); - var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + [Fact] + public void OnChatReceived_SuperChat_USD_WithoutDollarSign_ParsesCurrency() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + service.Running = true; + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "TestUser", ChannelId = "id" }, + Message = [], + Superchat = new Superchat { + AmountString = "CA$5.00", + AmountValue = 5, + Currency = "USD", + BodyBackgroundColor = "" + } + }; - typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(service, "@test"); + SubathonEvent? captured = CaptureEvent(() => typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); - var method = typeof(YouTubeService) - .GetMethod("OnErrorOccurred", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(captured); + Assert.NotEqual("USD", captured!.Currency); + } - var errorArgs = new ErrorOccurredEventArgs(new Exception("Initial Continuation token not found")); - method!.Invoke(service, [null, errorArgs]); + [Theory] + [InlineData("superchat")] + [InlineData("membership")] + public void OnChatReceived_Ticker_DoesNotRaiseEvent(string itemType) { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + service.Running = true; + + var raised = false; + Action handler = _ => raised = true; + SubathonEvents.SubathonEventCreated += handler; + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + IsTicker = true, + Author = new Author { Name = "TestUser", ChannelId = "id" }, + Message = [], + Superchat = itemType == "superchat" + ? new Superchat { AmountString = "$5.00", AmountValue = 5, Currency = "USD", BodyBackgroundColor = "" } + : null, + MembershipDetails = itemType == "membership" + ? new MembershipDetails { EventType = MembershipEventType.New, LevelName = "Gold" } + : null + }; + + typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + + Assert.False(raised); + } - Assert.False(service.Running); - logger.Verify( - x => x.Log( - LogLevel.Warning, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Initial Continuation token not found")), - It.IsAny(), - It.IsAny>()), - Times.Once); - } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void OnChatReceived_Membership_IdHandling(bool isValidGuid) { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + service.Running = true; + + var knownGuid = Guid.NewGuid(); + string itemId = isValidGuid ? knownGuid.ToString() : "yt-not-a-guid"; + + var chatItem = new ChatItem { + Id = itemId, + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "MemberUser", ChannelId = "id" }, + Message = [], + MembershipDetails = new MembershipDetails { EventType = MembershipEventType.New, LevelName = "Gold" } + }; + + + SubathonEvent? captured = CaptureEvent(() => typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + + Assert.NotNull(captured); + Assert.NotEqual(Guid.Empty, captured!.Id); + if (isValidGuid) + Assert.Equal(knownGuid, captured.Id); + } + + [Theory] + [InlineData(MembershipEventType.New, "Gold", "New Member", SubathonEventType.YouTubeMembership, "Gold")] + [InlineData(MembershipEventType.Milestone, "DEFAULT", "Special guy", SubathonEventType.YouTubeMembership, + "DEFAULT")] + [InlineData(MembershipEventType.Unknown, "Gold", "Some subtext", SubathonEventType.YouTubeMembership, + "Some subtext")] // not true case + [InlineData(MembershipEventType.Unknown, "Gold", "Welcome new member!", SubathonEventType.YouTubeMembership, + "Gold")] + [InlineData(MembershipEventType.Unknown, "Upgraded membership to MyTier!!", "Upgraded membership to MyTier!!", + SubathonEventType.YouTubeMembership, "MyTier!")] + public void OnChatReceived_Membership_RaisesYouTubeMembership( + MembershipEventType eventType, string levelName, string headerSubtext, SubathonEventType expectedEventType, + string expectedLevel) { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + service.Running = true; + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "MemberUser", ChannelId = "id" }, + Message = [], + MembershipDetails = new MembershipDetails { + EventType = eventType, + HeaderSubtext = headerSubtext, + LevelName = levelName, + MilestoneMonths = eventType == MembershipEventType.Milestone ? 6 : 0 + } + }; + + + SubathonEvent? captured = CaptureEvent(() => typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + + Assert.NotNull(captured); + Assert.Equal(expectedEventType, captured!.EventType); + Assert.Equal(SubathonEventSource.YouTube, captured.Source); + Assert.Equal(expectedLevel, captured.Value); + Assert.Equal("MemberUser", captured.User); + } + + [Fact] + public void OnChatReceived_Membership_Milestone_TierIsMember_UsesHeaderSubtext() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + service.Running = true; + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "MemberUser", ChannelId = "id" }, + Message = [], + MembershipDetails = new MembershipDetails { + EventType = MembershipEventType.Milestone, + HeaderSubtext = "Gold", + MilestoneMonths = 6, + LevelName = "Member" // fallback to HeaderSubtext + } + }; + + + SubathonEvent? captured = CaptureEvent(() => typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + + Assert.NotNull(captured); + Assert.Equal("Gold", captured!.Value); + } + + [Fact] + public void OnChatReceived_Membership_GiftPurchase_NullGifterUsername_UsesAuthorName() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + service.Running = true; + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "AuthorFallback", ChannelId = "id" }, + Message = [], + MembershipDetails = new MembershipDetails { + EventType = MembershipEventType.GiftPurchase, + GifterUsername = null, + GiftCount = 2 + } + }; + + + SubathonEvent? captured = CaptureEvent(() => typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + + Assert.NotNull(captured); + Assert.Equal("AuthorFallback", captured!.User); + Assert.Equal(2, captured.Amount); + } + + + [Theory] + [InlineData(" ", " ")] + [InlineData("member", "member")] + [InlineData("Member", "Member")] + [InlineData("MEMBER", "MEMBER")] + public void OnChatReceived_Membership_TierNormalizesToDefault(string levelName, string headerSubtext) { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + service.Running = true; + + var chatItem = new ChatItem { + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "MemberUser", ChannelId = "id" }, + Message = [], + MembershipDetails = new MembershipDetails { + EventType = MembershipEventType.New, + HeaderSubtext = headerSubtext, + LevelName = levelName + } + }; + + + SubathonEvent? captured = CaptureEvent(() => typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); + + Assert.NotNull(captured); + Assert.Equal("DEFAULT", captured!.Value); + } + + [Fact] + public void TryReconnectLoop_ShouldNotStart_WhenHandleIsEmpty() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + MethodInfo? method = typeof(YouTubeService) + .GetMethod("TryReconnectLoop", BindingFlags.NonPublic | BindingFlags.Instance); + + Exception? ex = Record.Exception(() => method!.Invoke(service, null)); + Assert.Null(ex); + } + + [Fact] + public void OnErrorOccurred_CanonicalLinkError_LogsOnceOnly() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + + MethodInfo? method = typeof(YouTubeService) + .GetMethod("OnErrorOccurred", BindingFlags.NonPublic | BindingFlags.Instance); + + var errorArgs = new ErrorOccurredEventArgs(new Exception("canonical link not found")); + + // logs only 1x + method!.Invoke(service, [null, errorArgs]); + method!.Invoke(service, [null, errorArgs]); + + var counter = typeof(YouTubeService) + .GetField("_canonicalLinkErrorCount", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(service) as int?; + + Assert.Equal(2, counter); + Assert.False(service.Running); + + logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("canonical link")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void OnErrorOccurred_InitialContinuationToken_LogsWarningWithMessage() { + var logger = new Mock>(); + var chatLogger = new Mock>(); + var httpLogger = new Mock>(); + var config = new Mock(); + var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); + + typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(service, "@test"); + + MethodInfo? method = typeof(YouTubeService) + .GetMethod("OnErrorOccurred", BindingFlags.NonPublic | BindingFlags.Instance); + + var errorArgs = new ErrorOccurredEventArgs(new Exception("Initial Continuation token not found")); + method!.Invoke(service, [null, errorArgs]); + + Assert.False(service.Running); + logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Initial Continuation token not found")), + It.IsAny(), + It.IsAny>()), + Times.Once); } -} +} \ No newline at end of file diff --git a/SubathonManager.Tests/ReflectionExtensions.cs b/SubathonManager.Tests/ReflectionExtensions.cs index 5576cb64..d85c3b3b 100644 --- a/SubathonManager.Tests/ReflectionExtensions.cs +++ b/SubathonManager.Tests/ReflectionExtensions.cs @@ -1,35 +1,30 @@ using System.Reflection; -namespace SubathonManager.Tests -{ - public static class ReflectionExtensions - { - public static async Task InvokePrivate(this object obj, string methodName, params object?[] args) - { - if (obj == null) throw new ArgumentNullException(nameof(obj)); +namespace SubathonManager.Tests; - var type = obj.GetType(); - var method = type.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(m => m.Name == methodName && - m.GetParameters().Length == args.Length); +public static class ReflectionExtensions { + public static async Task InvokePrivate(this object obj, string methodName, params object?[] args) { + if (obj == null) throw new ArgumentNullException(nameof(obj)); - if (method == null) - throw new MissingMethodException($"No private method '{methodName}' found with {args.Length} parameter(s)."); + Type type = obj.GetType(); + MethodInfo? method = type.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(m => m.Name == methodName && + m.GetParameters().Length == args.Length); - var result = method.Invoke(obj, args); + if (method == null) + throw new MissingMethodException( + $"No private method '{methodName}' found with {args.Length} parameter(s)."); - if (result is Task task) - { - await task.ConfigureAwait(false); - var taskType = task.GetType(); - if (taskType.IsGenericType && taskType.GetGenericTypeDefinition() == typeof(Task<>)) - { - return taskType.GetProperty("Result")!.GetValue(task); - } - return null; - } + object? result = method.Invoke(obj, args); - return result; + if (result is Task task) { + await task.ConfigureAwait(false); + Type taskType = task.GetType(); + if (taskType.IsGenericType && taskType.GetGenericTypeDefinition() == typeof(Task<>)) + return taskType.GetProperty("Result")!.GetValue(task); + return null; } + + return result; } } \ No newline at end of file diff --git a/SubathonManager.Tests/SequentialCollectionDefinition.cs b/SubathonManager.Tests/SequentialCollectionDefinition.cs index 47fa772d..0ca51b29 100644 --- a/SubathonManager.Tests/SequentialCollectionDefinition.cs +++ b/SubathonManager.Tests/SequentialCollectionDefinition.cs @@ -1,25 +1,25 @@ namespace SubathonManager.Tests; [CollectionDefinition("Sequential", DisableParallelization = true)] -public class SequentialCollectionDefinition -{ +public class SequentialCollectionDefinition { } - [CollectionDefinition("SequentialParallel", DisableParallelization = true)] -public class SequentialParallelCollectionDefinition -{ +public class SequentialParallelCollectionDefinition { } - [CollectionDefinition("GlobalState", DisableParallelization = true)] -public class GlobalStateCollection { } +public class GlobalStateCollection { +} [CollectionDefinition("ServicesTests", DisableParallelization = true)] -public class ServicesTestsCollection { } +public class ServicesTestsCollection { +} [CollectionDefinition("CurrencyServiceTests", DisableParallelization = true)] -public class CurrencyServiceTestsCollection { } +public class CurrencyServiceTestsCollection { +} [CollectionDefinition("WorkingDirectory", DisableParallelization = true)] -public class WorkingDirectoryCollection { } +public class WorkingDirectoryCollection { +} \ No newline at end of file diff --git a/SubathonManager.Tests/ServerUnitTests/MockHttpContext.cs b/SubathonManager.Tests/ServerUnitTests/MockHttpContext.cs index e17c1822..70502f81 100644 --- a/SubathonManager.Tests/ServerUnitTests/MockHttpContext.cs +++ b/SubathonManager.Tests/ServerUnitTests/MockHttpContext.cs @@ -1,62 +1,58 @@ -using System.Text; -using System.Net.WebSockets; +using System.Net.WebSockets; +using System.Text; using SubathonManager.Server.Interfaces; namespace SubathonManager.Tests.ServerUnitTests; -public class MockHttpContext: IHttpContext -{ - public string Method { get; set; } = "GET"; - public string Path { get; set; } = "/"; - public string QueryString { get; set; } = ""; - public IReadOnlyDictionary Headers { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); - public Stream Body { get; set; } = new MemoryStream(); - public Encoding Encoding { get; set; } = Encoding.UTF8; - public bool IsWebSocket { get; set; } +public class MockHttpContext : IHttpContext { + private readonly MemoryStream _responseStream = new(); public int StatusCode { get; private set; } public string ResponseBody { get; private set; } = ""; - + public MockWebSocket Socket { get; } = new(); public int AcceptCalls { get; private set; } - - private readonly MemoryStream _responseStream = new(); public Stream ResponseBodyStream => _responseStream; - public Task ServeFile(string fullPath, string contentType) - { + public byte[]? ServedBytes { get; private set; } + public string Method { get; set; } = "GET"; + public string Path { get; set; } = "/"; + public string QueryString { get; set; } = ""; + + public IReadOnlyDictionary Headers { get; set; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public Stream Body { get; set; } = new MemoryStream(); + public Encoding Encoding { get; set; } = Encoding.UTF8; + public bool IsWebSocket { get; set; } + + public Task ServeFile(string fullPath, string contentType) { return Task.CompletedTask; } - public byte[]? ServedBytes { get; private set; } - - public Task ServeBytes(byte[] data, string contentType) - { + public Task ServeBytes(byte[] data, string contentType) { ServedBytes = data; StatusCode = 200; ResponseBodyStream.Write(data); return Task.CompletedTask; } - - public Task? AcceptWebSocketAsync(string? subProtocol = null) - { + + public Task? AcceptWebSocketAsync(string? subProtocol = null) { AcceptCalls++; return IsWebSocket - ? Task.FromResult(Socket) + ? Task.FromResult(Socket) : null; } - - public Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null) - { + + public Task WriteResponse(int statusCode, string body, bool addCors = false, string? contentType = null) { StatusCode = statusCode; ResponseBody = body; ResponseBodyStream.WriteAsync(Encoding.UTF8.GetBytes(body)); return Task.CompletedTask; } - - public string GetResponseText() - { + + public string GetResponseText() { _responseStream.Position = 0; return new StreamReader(_responseStream).ReadToEnd(); } diff --git a/SubathonManager.Tests/ServerUnitTests/MockWebSocket.cs b/SubathonManager.Tests/ServerUnitTests/MockWebSocket.cs index 602aae17..5f0deca6 100644 --- a/SubathonManager.Tests/ServerUnitTests/MockWebSocket.cs +++ b/SubathonManager.Tests/ServerUnitTests/MockWebSocket.cs @@ -1,9 +1,11 @@ using System.Net.WebSockets; +using System.Text; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Tests.ServerUnitTests; -public sealed class MockWebSocket : WebSocket -{ +public sealed class MockWebSocket : WebSocket { + private readonly Queue<(byte[] Data, WebSocketReceiveResult Result)> _receiveQueue = new(); public List> SentMessages { get; } = new(); public override WebSocketState State => WebSocketState.Open; @@ -11,12 +13,9 @@ public sealed class MockWebSocket : WebSocket public override WebSocketCloseStatus? CloseStatus => null; public override string? SubProtocol => null; public bool Disposed { get; private set; } - - private readonly Queue<(byte[] Data, WebSocketReceiveResult Result)> _receiveQueue = new(); - - public void EnqueueReceive(string message) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(message); + + public void EnqueueReceive(string message) { + byte[] bytes = Encoding.UTF8.GetBytes(message); _receiveQueue.Enqueue(( bytes, new WebSocketReceiveResult( @@ -27,58 +26,52 @@ public void EnqueueReceive(string message) )); } - public void EnqueueClose() - { + public void EnqueueClose() { _receiveQueue.Enqueue(( Array.Empty(), new WebSocketReceiveResult(0, WebSocketMessageType.Close, true) )); } - + public override Task CloseAsync( WebSocketCloseStatus closeStatus, string? statusDescription, - CancellationToken cancellationToken) - => Task.CompletedTask; + CancellationToken cancellationToken) { + return Task.CompletedTask; + } public override Task CloseOutputAsync( WebSocketCloseStatus closeStatus, string? statusDescription, - CancellationToken cancellationToken) - => Task.CompletedTask; + CancellationToken cancellationToken) { + return Task.CompletedTask; + } - public override void Abort() - { + public override void Abort() { } public override Task SendAsync( ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, - CancellationToken cancellationToken) - { + CancellationToken cancellationToken) { SentMessages.Add(buffer); return Task.CompletedTask; } public override Task ReceiveAsync( ArraySegment buffer, - CancellationToken cancellationToken) - { + CancellationToken cancellationToken) { if (_receiveQueue.Count == 0) - { return Task.FromResult( new WebSocketReceiveResult(0, WebSocketMessageType.Close, true) ); - } - var (data, result) = _receiveQueue.Dequeue(); + (byte[] data, WebSocketReceiveResult result) = _receiveQueue.Dequeue(); Array.Copy(data, 0, buffer.Array!, buffer.Offset, data.Length); return Task.FromResult(result); } - public override void Dispose() - { + public override void Dispose() { Disposed = true; } - } \ No newline at end of file diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs index c919abf0..37170a56 100644 --- a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs +++ b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs @@ -1,21 +1,21 @@ using System.Text; -using Moq; using IniParser.Model; -using SubathonManager.Core.Models; -using SubathonManager.Core.Enums; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.EntityFrameworkCore; +using Moq; +using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; -using SubathonManager.Server; +using SubathonManager.Core.Models; using SubathonManager.Data; +using SubathonManager.Server; +using SubathonManager.Server.Interfaces; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Tests.ServerUnitTests; [Collection("GlobalState")] -public class WebServerTests -{ - +public class WebServerTests { [Theory] [InlineData("test.html", "text/html")] [InlineData("test/image.png", "image/png")] @@ -46,65 +46,57 @@ public class WebServerTests [InlineData("test/data.txt", "text/plain")] [InlineData("test/data.csv", "text/csv")] [InlineData("test/data.xml", "application/xml")] - public void ContentType_IsCorrect(string file, string expected) - { + public void ContentType_IsCorrect(string file, string expected) { Assert.Equal(expected, WebServer.GetContentType(file)); } - - private static void SetupServices() - { + + private static void SetupServices() { var dbName = Guid.NewGuid().ToString(); var services = new ServiceCollection(); services.AddLogging(); - services.AddDbContextFactory(o => o.UseInMemoryDatabase(dbName)); - var mockConfig = MockConfig(new() - { + services.AddDbContextFactory(o => o.UseInMemoryDatabase(dbName)); + IConfig mockConfig = MockConfig(new Dictionary<(string, string), string> { { ("Server", "Port"), "14045" } }); services.AddSingleton(mockConfig); AppServices.Provider = services.BuildServiceProvider(); } - - private static WebServer CreateServer() - { + + private static WebServer CreateServer() { SetupServices(); var logger = AppServices.Provider.GetRequiredService>(); var factory = AppServices.Provider.GetRequiredService>(); - var mockConfig = MockConfig(new() - { + IConfig mockConfig = MockConfig(new Dictionary<(string, string), string> { { ("Server", "Port"), "14045" } }); var webserver = new WebServer(logger, mockConfig, factory); webserver.Initialize(); return webserver; } - - private static IConfig MockConfig(Dictionary<(string, string), string>? values = null) - { + + private static IConfig MockConfig(Dictionary<(string, string), string>? values = null) { var mock = new Mock(); - + mock.Setup(c => c.Get(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((string s, string k, string d) => - values != null && values.TryGetValue((s, k), out var v) ? v : d); - + values != null && values.TryGetValue((s, k), out string? v) ? v : d); + var kd0 = new KeyData("Port"); kd0.Value = "14045"; - mock.Setup(c => c.GetSection("Server")).Returns(() => - { + mock.Setup(c => c.GetSection("Server")).Returns(() => { var kdc = new KeyDataCollection(); kdc.AddKey(kd0); return kdc; }); return mock.Object; } - + [Fact] - public void MatchRoute_Matches_By_Method_And_Prefix() - { + public void MatchRoute_Matches_By_Method_And_Prefix() { SetupServices(); - var server = CreateServer(); - var handler = server.MatchRoute("GET", "/api/data/status"); + WebServer server = CreateServer(); + Func? handler = server.MatchRoute("GET", "/api/data/status"); Assert.NotNull(handler); AppServices.Provider = null!; @@ -112,51 +104,45 @@ public void MatchRoute_Matches_By_Method_And_Prefix() } [Fact] - public void MatchRoute_Returns_Null_For_Wrong_Method() - { - var server = CreateServer(); + public void MatchRoute_Returns_Null_For_Wrong_Method() { + WebServer server = CreateServer(); SetupServices(); - var handler = server.MatchRoute("POST", "/api/data/status"); + Func? handler = server.MatchRoute("POST", "/api/data/status"); Assert.Null(handler); AppServices.Provider = null!; } - + [Fact] - public void MatchRoute_Does_Not_Match_Unrelated_Prefix() - { - var server = CreateServer(); + public void MatchRoute_Does_Not_Match_Unrelated_Prefix() { + WebServer server = CreateServer(); SetupServices(); - var handler = server.MatchRoute("GET", "/api/data/status123"); + Func? handler = server.MatchRoute("GET", "/api/data/status123"); Assert.NotNull(handler); AppServices.Provider = null!; } - + [Fact] - public async Task HandleRequest_Executes_Route_Handler() - { - var ctx = new MockHttpContext - { + public async Task HandleRequest_Executes_Route_Handler() { + var ctx = new MockHttpContext { Method = "GET", Path = "/api/data/values" }; - var server = CreateServer(); + WebServer server = CreateServer(); SetupServices(); await server.InvokeHandleRequest(ctx); Assert.NotEqual(404, ctx.StatusCode); AppServices.Provider = null!; } - + [Fact] - public async Task HandleRequest_Returns_404_For_Unknown_Route() - { - var ctx = new MockHttpContext - { + public async Task HandleRequest_Returns_404_For_Unknown_Route() { + var ctx = new MockHttpContext { Method = "GET", Path = "/nope" }; - var server = CreateServer(); + WebServer server = CreateServer(); SetupServices(); await server.InvokeHandleRequest(ctx); Assert.Equal(404, ctx.StatusCode); @@ -164,30 +150,26 @@ public async Task HandleRequest_Returns_404_For_Unknown_Route() } [Fact] - public async Task Api_Unknown_Route_Returns_400() - { - var ctx = new MockHttpContext - { + public async Task Api_Unknown_Route_Returns_400() { + var ctx = new MockHttpContext { Method = "GET", Path = "/api/nope" }; - var server = CreateServer(); + WebServer server = CreateServer(); SetupServices(); await server.InvokeHandleRequest(ctx); Assert.Equal(400, ctx.StatusCode); AppServices.Provider = null!; } - + [Fact] - public async Task Commands_Endpoint_Returns_Catalog() - { - var ctx = new MockHttpContext - { + public async Task Commands_Endpoint_Returns_Catalog() { + var ctx = new MockHttpContext { Method = "GET", Path = "/api/data/commands" }; - var server = CreateServer(); + WebServer server = CreateServer(); SetupServices(); await server.InvokeHandleRequest(ctx); Assert.Equal(200, ctx.StatusCode); @@ -198,34 +180,30 @@ public async Task Commands_Endpoint_Returns_Catalog() } [Fact] - public async Task DataControl_Invalid_Body_Returns_400() - { - var ctx = new MockHttpContext - { + public async Task DataControl_Invalid_Body_Returns_400() { + var ctx = new MockHttpContext { Method = "POST", Path = "/api/data/control", Body = new MemoryStream(Encoding.UTF8.GetBytes("")) }; - var server = CreateServer(); + WebServer server = CreateServer(); SetupServices(); await server.InvokeHandleRequest(ctx); Assert.Equal(400, ctx.StatusCode); AppServices.Provider = null!; } - + [Fact] - public async Task DataControl_Invalid_Type_Returns_400() - { + public async Task DataControl_Invalid_Type_Returns_400() { var json = "{\"type\":\"NotARealType\"}"; - var ctx = new MockHttpContext - { + var ctx = new MockHttpContext { Method = "POST", Path = "/api/data/control", Body = new MemoryStream(Encoding.UTF8.GetBytes(json)) }; - var server = CreateServer(); + WebServer server = CreateServer(); SetupServices(); await server.InvokeHandleRequest(ctx); Assert.Equal(400, ctx.StatusCode); @@ -233,40 +211,36 @@ public async Task DataControl_Invalid_Type_Returns_400() } [Fact] - public void BuildDataSummary_Groups_Currency_Donations() - { - var events = new List - { + public void BuildDataSummary_Groups_Currency_Donations() { + var events = new List { new() { EventType = SubathonEventType.KoFiDonation, Currency = "USD", Value = "5.55" }, new() { EventType = SubathonEventType.StreamElementsDonation, Currency = "USD", Value = "3" } }; - var server = CreateServer(); + WebServer server = CreateServer(); SetupServices(); - var result = server.InvokeBuildDataSummary(events); + object result = server.InvokeBuildDataSummary(events); Assert.NotNull(result); AppServices.Provider = null!; } - + /////////////// Widget and Route tests - + [Fact] - public async Task HandleSelectAsync_Returns_200_With_Route_Info() - { - var server = CreateServer(); + public async Task HandleSelectAsync_Returns_200_With_Route_Info() { + WebServer server = CreateServer(); SetupServices(); - var route = new Route{ Name = "TestRoute" }; + var route = new Route { Name = "TestRoute" }; var widget = new Widget("Widget1", "test.html") { Route = route, RouteId = route.Id }; route.Widgets.Add(widget); - await using (var db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) - { + await using (AppDbContext + db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) { db.Routes.Add(route); db.Widgets.Add(widget); await db.SaveChangesAsync(TestContext.Current.CancellationToken); } - var ctx = new MockHttpContext - { + var ctx = new MockHttpContext { Method = "GET", Path = $"/api/select/{route.Id}" }; @@ -274,30 +248,28 @@ public async Task HandleSelectAsync_Returns_200_With_Route_Info() await server.HandleSelectAsync(ctx); Assert.Equal(200, ctx.StatusCode); - var body = ctx.GetResponseText(); + string body = ctx.GetResponseText(); Assert.Equal("OK", body); AppServices.Provider = null!; } - + [Fact] - public async Task HandleWidgetUpdateAsync_Updates_Widget_Position() - { - var server = CreateServer(); + public async Task HandleWidgetUpdateAsync_Updates_Widget_Position() { + WebServer server = CreateServer(); SetupServices(); - var route = new Route{ Name = "Route1" }; + var route = new Route { Name = "Route1" }; var widget = new Widget("Widget1", "test.html") { Route = route, RouteId = route.Id }; route.Widgets.Add(widget); - await using (var db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) - { + await using (AppDbContext + db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) { db.Routes.Add(route); db.Widgets.Add(widget); await db.SaveChangesAsync(TestContext.Current.CancellationToken); } - var updateJson = $"{{\"x\":162,\"y\":200}}"; - var ctx = new MockHttpContext - { + var updateJson = "{\"x\":162,\"y\":200}"; + var ctx = new MockHttpContext { Method = "POST", Path = $"/api/update-position/{widget.Id}", Body = new MemoryStream(Encoding.UTF8.GetBytes(updateJson)) @@ -306,35 +278,34 @@ public async Task HandleWidgetUpdateAsync_Updates_Widget_Position() await server.HandleWidgetUpdateAsync(ctx); Assert.Equal(200, ctx.StatusCode); - await using (var db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) - { - var updatedWidget = await db.Widgets.FindAsync([widget.Id], TestContext.Current.CancellationToken); + await using (AppDbContext + db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) { + Widget? updatedWidget = await db.Widgets.FindAsync([widget.Id], TestContext.Current.CancellationToken); Assert.Equal(162, updatedWidget!.X); Assert.Equal(200, updatedWidget.Y); } + AppServices.Provider = null!; } - - + + [Fact] - public async Task HandleWidgetUpdateAsync_Updates_Widget_Scale() - { - var server = CreateServer(); + public async Task HandleWidgetUpdateAsync_Updates_Widget_Scale() { + WebServer server = CreateServer(); SetupServices(); - var route = new Route{ Name = "Route1" }; + var route = new Route { Name = "Route1" }; var widget = new Widget("Widget1", "test.html") { Route = route, RouteId = route.Id }; route.Widgets.Add(widget); - await using (var db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) - { + await using (AppDbContext + db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) { db.Routes.Add(route); db.Widgets.Add(widget); await db.SaveChangesAsync(TestContext.Current.CancellationToken); } - var updateJson = $"{{\"scaleX\":2,\"scaleY\":2.5}}"; - var ctx = new MockHttpContext - { + var updateJson = "{\"scaleX\":2,\"scaleY\":2.5}"; + var ctx = new MockHttpContext { Method = "POST", Path = $"/api/update-size/{widget.Id}", Body = new MemoryStream(Encoding.UTF8.GetBytes(updateJson)) @@ -343,23 +314,22 @@ public async Task HandleWidgetUpdateAsync_Updates_Widget_Scale() await server.HandleWidgetUpdateAsync(ctx); Assert.Equal(200, ctx.StatusCode); - await using (var db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) - { - var updatedWidget = await db.Widgets.FindAsync([widget.Id], TestContext.Current.CancellationToken); + await using (AppDbContext + db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) { + Widget? updatedWidget = await db.Widgets.FindAsync([widget.Id], TestContext.Current.CancellationToken); Assert.Equal(2, updatedWidget!.ScaleX); Assert.Equal(2.5, updatedWidget.ScaleY); } + AppServices.Provider = null!; } - + [Fact] - public async Task HandleStatusRequestAsync_Returns_400_No_Subathon() - { - var server = CreateServer(); + public async Task HandleStatusRequestAsync_Returns_400_No_Subathon() { + WebServer server = CreateServer(); SetupServices(); - var ctx = new MockHttpContext - { + var ctx = new MockHttpContext { Method = "GET", Path = "/api/data/status" }; @@ -369,21 +339,19 @@ public async Task HandleStatusRequestAsync_Returns_400_No_Subathon() Assert.Equal(400, ctx.StatusCode); AppServices.Provider = null!; } - + [Fact] - public async Task HandleStatusRequestAsync_Returns_200_With_Expected_Content() - { - var server = CreateServer(); + public async Task HandleStatusRequestAsync_Returns_200_With_Expected_Content() { + WebServer server = CreateServer(); SetupServices(); - var ctx = new MockHttpContext - { + var ctx = new MockHttpContext { Method = "GET", Path = "/api/data/status" }; - - SubathonData subathon = new SubathonData(); - await using (var db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) - { + + var subathon = new SubathonData(); + await using (AppDbContext + db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) { db.SubathonDatas.Add(subathon); await db.SaveChangesAsync(TestContext.Current.CancellationToken); } @@ -391,25 +359,23 @@ public async Task HandleStatusRequestAsync_Returns_200_With_Expected_Content() await server.HandleStatusRequestAsync(ctx); Assert.Equal(200, ctx.StatusCode); - var text = ctx.GetResponseText(); + string text = ctx.GetResponseText(); Assert.Contains("millis_remaining", text, StringComparison.OrdinalIgnoreCase); AppServices.Provider = null!; } - + [Fact] - public async Task HandleAmountsRequestAsync_Returns_200() - { - var server = CreateServer(); + public async Task HandleAmountsRequestAsync_Returns_200() { + WebServer server = CreateServer(); SetupServices(); - SubathonData subathon = new SubathonData(); - await using (var db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) - { + var subathon = new SubathonData(); + await using (AppDbContext + db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) { db.SubathonDatas.Add(subathon); await db.SaveChangesAsync(TestContext.Current.CancellationToken); } - var ctx = new MockHttpContext - { + var ctx = new MockHttpContext { Method = "GET", Path = "/api/data/amounts" }; @@ -417,36 +383,33 @@ public async Task HandleAmountsRequestAsync_Returns_200() await server.HandleAmountsRequestAsync(ctx); Assert.Equal(200, ctx.StatusCode); - var text = ctx.GetResponseText(); + string text = ctx.GetResponseText(); Assert.NotNull(text); Assert.Contains("real", text, StringComparison.OrdinalIgnoreCase); Assert.Contains("simulated", text, StringComparison.OrdinalIgnoreCase); AppServices.Provider = null!; } - + [Fact] - public async Task HandleValuesPatchRequestAsync_AllPaths() - { - var server = CreateServer(); + public async Task HandleValuesPatchRequestAsync_AllPaths() { + WebServer server = CreateServer(); SetupServices(); - SubathonValue subathonValue = new SubathonValue - { + var subathonValue = new SubathonValue { Meta = "1000", EventType = SubathonEventType.TwitchSub, Points = 1, Seconds = 60 }; - - await using (var db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) - { + + await using (AppDbContext + db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) { db.SubathonValues.Add(subathonValue); await db.SaveChangesAsync(TestContext.Current.CancellationToken); } var patchJson = "[{\"EventType\":\"TwitchSub\", \"Source\":\"Twitch\", \"Seconds\": 20, \"Meta\": \"1000\"}]"; - var ctx = new MockHttpContext - { + var ctx = new MockHttpContext { Method = "PATCH", Path = "/api/data/values", Body = new MemoryStream(Encoding.UTF8.GetBytes(patchJson)) @@ -456,67 +419,62 @@ public async Task HandleValuesPatchRequestAsync_AllPaths() Assert.Equal(200, ctx.StatusCode); - await using (var db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) - { - var updatedVal = await db.SubathonValues.Where( - x => x.EventType == SubathonEventType.TwitchSub && x.Meta == "1000").FirstOrDefaultAsync(cancellationToken: TestContext.Current.CancellationToken); + await using (AppDbContext + db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken)) { + SubathonValue? updatedVal = await db.SubathonValues + .Where(x => x.EventType == SubathonEventType.TwitchSub && x.Meta == "1000") + .FirstOrDefaultAsync(TestContext.Current.CancellationToken); Assert.Equal(20, updatedVal!.Seconds); } - - ctx = new MockHttpContext - { + + ctx = new MockHttpContext { Method = "PATCH", Path = "/api/data/values", Body = new MemoryStream(Encoding.UTF8.GetBytes(patchJson)) }; await server.HandleValuesPatchRequestAsync(ctx); Assert.Equal(201, ctx.StatusCode); - + patchJson = "[{\"EventType\":\"FakeSub\", \"Source\":\"BadData\", \"Seconds\": 20, \"Meta\": \"\"}]"; - ctx = new MockHttpContext - { + ctx = new MockHttpContext { Method = "PATCH", Path = "/api/data/values", Body = new MemoryStream(Encoding.UTF8.GetBytes(patchJson)) }; await server.HandleValuesPatchRequestAsync(ctx); Assert.Equal(400, ctx.StatusCode); - + AppServices.Provider = null!; } - + [Fact] - public async Task HandleWidgetRequestAsync_Returns_Widget_Html_With_Overrides() - { - var server = CreateServer(); + public async Task HandleWidgetRequestAsync_Returns_Widget_Html_With_Overrides() { + WebServer server = CreateServer(); SetupServices(); - var route = new Route{ Name = "TestRoute" }; - + var route = new Route { Name = "TestRoute" }; + string tempHtml = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".html"); - await File.WriteAllTextAsync(tempHtml, "", TestContext.Current.CancellationToken); - - var widget = new Widget("Widget1", tempHtml) - { - Route = route, + await File.WriteAllTextAsync(tempHtml, "", + TestContext.Current.CancellationToken); + + var widget = new Widget("Widget1", tempHtml) { + Route = route, RouteId = route.Id, - CssVariables = new List - { + CssVariables = new List { new() { Name = "color-primary", Value = "red", WidgetId = Guid.NewGuid() } }, - JsVariables = new List - { + JsVariables = new List { new() { Name = "testVar", Value = "42", Type = WidgetVariableType.Int, WidgetId = Guid.NewGuid() } } }; route.Widgets.Add(widget); - await using var db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken); + await using AppDbContext db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken); db.Routes.Add(route); db.Widgets.Add(widget); await db.SaveChangesAsync(TestContext.Current.CancellationToken); - var ctx = new MockHttpContext - { + var ctx = new MockHttpContext { Method = "GET", Path = $"/widget/{widget.Id}" }; @@ -524,7 +482,7 @@ public async Task HandleWidgetRequestAsync_Returns_Widget_Html_With_Overrides() await server.HandleWidgetRequest(ctx); Assert.Equal(200, ctx.StatusCode); - var body = ctx.GetResponseText(); + string body = ctx.GetResponseText(); Assert.Contains(" - - - + + + - - + + - - + + - - - - - @@ -314,59 +414,76 @@ - - + - - + - + - + - + - - - + + + - - - - - - - - - - + + - - + + - - + + - - + + - - + + - - + + - + - - @@ -508,13 +659,13 @@ - + - + - + @@ -523,4 +674,4 @@ - + \ No newline at end of file diff --git a/SubathonManager.UI/EditRouteWindow.axaml.cs b/SubathonManager.UI/EditRouteWindow.axaml.cs index 5818532a..00fbb6e4 100644 --- a/SubathonManager.UI/EditRouteWindow.axaml.cs +++ b/SubathonManager.UI/EditRouteWindow.axaml.cs @@ -7,6 +7,7 @@ using Avalonia.Interactivity; using Avalonia.Media.Imaging; using Avalonia.Platform; +using Avalonia.Platform.Storage; using Avalonia.Threading; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -19,68 +20,62 @@ using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.Data.Widgets; -using SubathonManager.UI.Views; using SubathonManager.UI.Services; +using SubathonManager.UI.UiUtils; +using SubathonManager.UI.Views; namespace SubathonManager.UI; -public partial class EditRouteWindow : Window -{ +public partial class EditRouteWindow : Window { public static readonly StyledProperty ObsConnectedProperty = AvaloniaProperty.Register(nameof(ObsConnected)); - public bool ObsConnected - { - get => GetValue(ObsConnectedProperty); - set => SetValue(ObsConnectedProperty, value); - } - - public readonly Guid EditorRouteId; - private Route? _route; - private readonly ObservableCollection _widgets = new(); - private Widget? _selectedWidget; + private readonly Timer? _cssLivePreviewTimer; private readonly ObservableCollection _editingCssVars = []; private readonly ObservableCollection _editingJsVars = []; - private readonly Dictionary _widgetCardBorders = new(); - private readonly Dictionary> _unsavedCssVars = new(); - private readonly Dictionary> _unsavedJsVars = new(); private readonly HashSet _erroredWidgets = new(); private readonly IDbContextFactory _factory; private readonly ILogger? _logger = AppServices.Provider.GetService>(); - private string _lastFolder = string.Empty; - private bool _loadedWebView; - private string _editUrl = string.Empty; + private readonly Dictionary> _unsavedCssVars = new(); + private readonly Dictionary> _unsavedJsVars = new(); + private readonly Dictionary _widgetCardBorders = new(); + private readonly ObservableCollection _widgets = new(); + + public readonly Guid EditorRouteId; private double _currentScale = 1; - private bool _webViewLightBg = true; - private readonly Timer? _cssLivePreviewTimer; + private string _editUrl = string.Empty; private bool _hasPendingCssChanges; private bool _hasPendingJsChanges; + private string _lastFolder = string.Empty; + private bool _loadedWebView; + private Route? _route; + private Widget? _selectedWidget; private int _suppressCount; + private bool _webViewLightBg = true; - [GeneratedRegex(@"^-?[\d.]+")] - private static partial Regex IsNumberRegex(); + private string? _webViewRequirementUrl; - public EditRouteWindow() : this(Guid.Empty) { } + public EditRouteWindow() : this(Guid.Empty) { + } - public EditRouteWindow(Guid routeId) - { - Environment.SetEnvironmentVariable("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "--autoplay-policy=no-user-gesture-required"); + public EditRouteWindow(Guid routeId) { + Environment.SetEnvironmentVariable("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", + "--autoplay-policy=no-user-gesture-required"); _factory = AppServices.Provider.GetRequiredService>(); InitializeComponent(); - UiUtils.WindowIcons.Apply(this); + WindowIcons.Apply(this); EditorRouteId = routeId; WidgetsList.ItemsSource = _widgets; BrowserEditorButton.IsVisible = OperatingSystem.IsLinux(); WebViewWarningButton.IsVisible = OperatingSystem.IsLinux(); - UiUtils.UiHelpers.EnableClickAwayUnfocus(this); + UiHelpers.EnableClickAwayUnfocus(this); LoadPreviewBgPreference(); TryEnableEditorFileDrop(); - PreviewWebView.EnvironmentRequested += (_, e) => - { + PreviewWebView.EnvironmentRequested += (_, e) => { if (e is WindowsWebView2EnvironmentRequestedEventArgs win) win.AdditionalBrowserArguments = "--autoplay-policy=no-user-gesture-required"; - if (e is LinuxWpeWebViewEnvironmentRequestedEventArgs linux) + if (e is LinuxWpeWebViewEnvironmentRequestedEventArgs linux) linux.PreferWebKitGtkInstead = true; // if (e is GtkWebViewEnvironmentRequestedEventArgs gtkArgs) // { @@ -90,23 +85,20 @@ public EditRouteWindow(Guid routeId) // } }; - PreviewWebView.AdapterCreated += (_, _) => - { + PreviewWebView.AdapterCreated += (_, _) => { PreviewWebView.IsVisible = false; PreviewWebView.IsVisible = true; }; PreviewWebView.NavigationCompleted += (_, _) => - Dispatcher.UIThread.Post(() => - { + Dispatcher.UIThread.Post(() => { ApplyWebViewZoom(_currentScale); ApplyWebViewBackground(_webViewLightBg); }); - - UiUtils.EnterKeyCommit.Attach(RouteSettingsPanel, () => SaveRouteButton_Click(this, new RoutedEventArgs())); - UiUtils.EnterKeyCommit.Attach(WidgetEditorColumn, () => - { + + EnterKeyCommit.Attach(RouteSettingsPanel, () => SaveRouteButton_Click(this, new RoutedEventArgs())); + EnterKeyCommit.Attach(WidgetEditorColumn, () => { if (_selectedWidget == null) return; SaveWidgetButton_Click(this, new RoutedEventArgs()); }); @@ -114,24 +106,27 @@ public EditRouteWindow(Guid routeId) Loaded += EditRouteWindow_Loaded; ObsConnected = ServiceManager.OBS.Connected; IntegrationEvents.ConnectionUpdated += OnObsConnectionUpdated; - Closed += (_, _) => - { + Closed += (_, _) => { IntegrationEvents.ConnectionUpdated -= OnObsConnectionUpdated; WidgetEvents.WidgetActionRequested -= OnWidgetActionRequested; }; - _cssLivePreviewTimer = new Timer(_ => - { + _cssLivePreviewTimer = new Timer(_ => { if (_selectedWidget == null) return; OverlayEvents.RaiseWidgetVarsUpdated(_selectedWidget.Id, _editingCssVars, []); }, null, Timeout.Infinite, Timeout.Infinite); } - private async void EditRouteWindow_Loaded(object? sender, RoutedEventArgs e) - { - try - { + public bool ObsConnected { + get => GetValue(ObsConnectedProperty); + set => SetValue(ObsConnectedProperty, value); + } + [GeneratedRegex(@"^-?[\d.]+")] + private static partial Regex IsNumberRegex(); + + private async void EditRouteWindow_Loaded(object? sender, RoutedEventArgs e) { + try { // if (OperatingSystem.IsLinux()) // { // _loadedWebView = false; @@ -142,12 +137,11 @@ private async void EditRouteWindow_Loaded(object? sender, RoutedEventArgs e) // } // else // { - _loadedWebView = true; - await LoadRouteAsync(); + _loadedWebView = true; + await LoadRouteAsync(); // } } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to load overlay editor. The embedded WebView is not available"); _loadedWebView = false; PreviewWebView.IsVisible = false; @@ -155,8 +149,7 @@ private async void EditRouteWindow_Loaded(object? sender, RoutedEventArgs e) ConfigureFallbackForPlatform(); await LoadRouteAsync(); } - finally - { + finally { WidgetEvents.WidgetPositionUpdated += OnWidgetPositionUpdated; WidgetEvents.WidgetScaleUpdated += OnWidgetScaleUpdated; WidgetEvents.WidgetSizeUpdated += OnWidgetSizeUpdated; @@ -165,11 +158,9 @@ private async void EditRouteWindow_Loaded(object? sender, RoutedEventArgs e) } } - private void OnWidgetPositionUpdated(Widget updatedWidget) - { + private void OnWidgetPositionUpdated(Widget updatedWidget) { if (_selectedWidget == null || _selectedWidget.Id != updatedWidget.Id) return; - Dispatcher.UIThread.Post(() => - { + Dispatcher.UIThread.Post(() => { _selectedWidget.X = updatedWidget.X; _selectedWidget.Y = updatedWidget.Y; if (WidgetXBox.Text != $"{updatedWidget.X}") WidgetXBox.Text = $"{updatedWidget.X}"; @@ -177,11 +168,9 @@ private void OnWidgetPositionUpdated(Widget updatedWidget) }); } - private void OnWidgetScaleUpdated(Widget updatedWidget) - { + private void OnWidgetScaleUpdated(Widget updatedWidget) { if (_selectedWidget == null || _selectedWidget.Id != updatedWidget.Id) return; - Dispatcher.UIThread.Post(() => - { + Dispatcher.UIThread.Post(() => { _selectedWidget.ScaleX = updatedWidget.ScaleX; _selectedWidget.ScaleY = updatedWidget.ScaleY; if (WidgetScaleXBox.Text != $"{updatedWidget.ScaleX}") WidgetScaleXBox.Text = $"{updatedWidget.ScaleX}"; @@ -189,11 +178,9 @@ private void OnWidgetScaleUpdated(Widget updatedWidget) }); } - private void OnWidgetSizeUpdated(Widget updatedWidget) - { + private void OnWidgetSizeUpdated(Widget updatedWidget) { if (_selectedWidget == null || _selectedWidget.Id != updatedWidget.Id) return; - Dispatcher.UIThread.Post(() => - { + Dispatcher.UIThread.Post(() => { _selectedWidget.X = updatedWidget.X; _selectedWidget.Y = updatedWidget.Y; _selectedWidget.Width = updatedWidget.Width; @@ -205,19 +192,17 @@ private void OnWidgetSizeUpdated(Widget updatedWidget) }); } - private async Task LoadRouteAsync() - { + private async Task LoadRouteAsync() { WidgetPackPaths.InvalidateVersionCache(); await WidgetCatalog.LoadIndexAsync(_factory); - await using var db = await _factory.CreateDbContextAsync(); + await using AppDbContext db = await _factory.CreateDbContextAsync(); _route = await db.Routes .Include(r => r.Widgets).ThenInclude(w => w.CssVariables) .Include(r => r.Widgets).ThenInclude(w => w.JsVariables) .FirstOrDefaultAsync(r => r.Id == EditorRouteId); - if (_route == null) - { + if (_route == null) { Close(); return; } @@ -228,29 +213,27 @@ private async Task LoadRouteAsync() _widgets.Clear(); _erroredWidgets.Clear(); - var sorted = _route.Widgets.OrderByDescending(w => w.Z).ToList(); + List sorted = _route.Widgets.OrderByDescending(w => w.Z).ToList(); int index = sorted.Count; - bool hasUpdatedZ = false; + var hasUpdatedZ = false; var widgetHelper = new WidgetEntityHelper(_factory, null); - foreach (var w in sorted) - { - if (w.Z != index) - { + foreach (Widget w in sorted) { + if (w.Z != index) { hasUpdatedZ = true; w.Z = index; } + index -= 1; - if (!WidgetFiles.Current.Exists(w.HtmlPath)) - { + if (!WidgetFiles.Current.Exists(w.HtmlPath)) { _erroredWidgets.Add(w.Id); _logger?.LogWarning("Widget {Name} ({Id}) file not found: {Path}", w.Name, w.Id, w.HtmlPath); } - else if (w.Type == WidgetType.Html) - { + else if (w.Type == WidgetType.Html) { widgetHelper.SyncCssVariables(w); widgetHelper.SyncJsVariables(w); } + await db.Entry(w).ReloadAsync(); await db.Entry(w).Collection(x => x.CssVariables).LoadAsync(); await db.Entry(w).Collection(x => x.JsVariables).LoadAsync(); @@ -259,42 +242,39 @@ private async Task LoadRouteAsync() if (hasUpdatedZ) await db.SaveChangesAsync(); - try - { + try { var config = AppServices.Provider.GetRequiredService(); _editUrl = _route.GetRouteUrl(config, true); if (_loadedWebView) PreviewWebView.Source = new Uri(_editUrl); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "WebView failed to load: {Message}", ex.Message); } + UpdateWebViewScale(); } - private Widget? GetWidgetFromSender(object? sender) - => sender is Control { DataContext: Widget wi } ? wi : null; + private Widget? GetWidgetFromSender(object? sender) { + return sender is Control { DataContext: Widget wi } ? wi : null; + } - private void SelectWidgetFromEvent(Guid widgetId) - { - Dispatcher.UIThread.Post(async void () => - { + private void SelectWidgetFromEvent(Guid widgetId) { + Dispatcher.UIThread.Post(async void () => { if (widgetId == _selectedWidget?.Id && WidgetEditPanel.IsVisible) return; StashCurrentWidgetEdits(); - await using var db = await _factory.CreateDbContextAsync(); - var widget = await db.Widgets.Include(wX => wX.JsVariables) + await using AppDbContext db = await _factory.CreateDbContextAsync(); + Widget? widget = await db.Widgets.Include(wX => wX.JsVariables) .Include(wX => wX.CssVariables).FirstOrDefaultAsync(wX => wX.Id == widgetId); PopulateWidgetEditor(widget); }); } - private void PopulateWidgetEditor(Widget? widget) - { + private void PopulateWidgetEditor(Widget? widget) { CssVarsList.ItemsSource = null; JsVarsList.ItemsSource = null; JsVarFontList.ItemsSource = null; @@ -304,8 +284,7 @@ private void PopulateWidgetEditor(Widget? widget) _editingJsVars.Clear(); UpdateSaveButtonBorder(SaveButtonBorder, false); _hasPendingCssChanges = false; - if (widget == null) - { + if (widget == null) { WidgetEditPanel.IsVisible = false; EmptyEditorPanel.IsVisible = true; _selectedWidget = null; @@ -314,14 +293,13 @@ private void PopulateWidgetEditor(Widget? widget) if (_erroredWidgets.Contains(widget.Id)) return; - if (!widget.Type.IsAsset()) - { + if (!widget.Type.IsAsset()) { var widgetHelper = new WidgetEntityHelper(_factory, null); widgetHelper.SyncCssVariables(widget); widgetHelper.SyncJsVariables(widget); } - using var db = _factory.CreateDbContext(); + using AppDbContext db = _factory.CreateDbContext(); _selectedWidget = db.Widgets.Include(wX => wX.CssVariables) .Include(wX => wX.JsVariables) .FirstOrDefault(wX => wX.Id == widget.Id); @@ -330,7 +308,7 @@ private void PopulateWidgetEditor(Widget? widget) WidgetEditPanel.IsVisible = true; EmptyEditorPanel.IsVisible = false; - + _suppressCount++; bool isAsset = widget.Type.IsAsset(); @@ -352,28 +330,26 @@ private void PopulateWidgetEditor(Widget? widget) if (WidgetScaleXBox.Text != $"{widget.ScaleX}") WidgetScaleXBox.Text = $"{widget.ScaleX}"; if (WidgetScaleYBox.Text != $"{widget.ScaleY}") WidgetScaleYBox.Text = $"{widget.ScaleY}"; - if (string.IsNullOrWhiteSpace(widget.DocsUrl) || isAsset) - { + if (string.IsNullOrWhiteSpace(widget.DocsUrl) || isAsset) { DocsLinkBtn.IsVisible = false; WidgetNameBox.Width = 355; } - else - { + else { DocsLinkBtn.IsVisible = true; WidgetNameBox.Width = 315; } - foreach (var vr in widget.CssVariables) _editingCssVars.Add(vr); + foreach (CssVariable vr in widget.CssVariables) _editingCssVars.Add(vr); CssVarsList.ItemsSource = _editingCssVars; - if (_unsavedCssVars.TryGetValue(widget.Id, out var stashed)) - { - foreach (var stashedVar in stashed) - { - var live = _editingCssVars.FirstOrDefault(vr => vr.Name == stashedVar.Name); + if (_unsavedCssVars.TryGetValue(widget.Id, out List? stashed)) { + foreach (CssVariable stashedVar in stashed) { + CssVariable? live = _editingCssVars.FirstOrDefault(vr => vr.Name == stashedVar.Name); if (live != null) live.Value = stashedVar.Value; } + _hasPendingCssChanges = true; } + SubscribeCssVarChanges(); PopulateJsVars(); bool hasStash = _unsavedCssVars.ContainsKey(widget.Id) || _unsavedJsVars.ContainsKey(widget.Id); @@ -381,116 +357,102 @@ private void PopulateWidgetEditor(Widget? widget) _hasPendingJsChanges = _unsavedJsVars.ContainsKey(widget.Id); bool realDirty = hasStash || _hasPendingCssChanges || _hasPendingJsChanges; - Dispatcher.UIThread.Post(() => - { + Dispatcher.UIThread.Post(() => { _suppressCount--; - UiUtils.UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, realDirty); + UiHelpers.UpdateButtonPendingBorder(SaveButtonBorder, realDirty); }, DispatcherPriority.Background); } - private void PopulateJsVars() - { + private void PopulateJsVars() { _hasPendingJsChanges = false; if (_selectedWidget == null) return; _editingJsVars.Clear(); JsVarsList.ItemsSource = _selectedWidget.JsVariables.Where(x => !x.Type.IsFontVariable()).ToList(); JsVarFontList.ItemsSource = _selectedWidget.JsVariables.Where(x => x.Type.IsFontVariable()).ToList(); - foreach (var vr in _selectedWidget.JsVariables) _editingJsVars.Add(vr); - bool hasJsStash = _unsavedJsVars.TryGetValue(_selectedWidget.Id, out var stashedJs); + foreach (JsVariable vr in _selectedWidget.JsVariables) _editingJsVars.Add(vr); + bool hasJsStash = _unsavedJsVars.TryGetValue(_selectedWidget.Id, out List? stashedJs); if (hasJsStash) - { - foreach (var stashedVar in stashedJs!) - { - var live = _editingJsVars.FirstOrDefault(vr => vr.Name == stashedVar.Name); + foreach (JsVariable stashedVar in stashedJs!) { + JsVariable? live = _editingJsVars.FirstOrDefault(vr => vr.Name == stashedVar.Name); if (live != null) live.Value = stashedVar.Value; } - } _hasPendingJsChanges = hasJsStash; } - private async Task SelectFileVarPathDialog(WidgetVariableType type) - { - try - { - if (type == WidgetVariableType.FolderPath) - { - var folders = await StorageProvider.OpenFolderPickerAsync(new global::Avalonia.Platform.Storage.FolderPickerOpenOptions - { - Title = "Select Folder", - AllowMultiple = false - }); + private async Task SelectFileVarPathDialog(WidgetVariableType type) { + try { + if (type == WidgetVariableType.FolderPath) { + IReadOnlyList folders = await StorageProvider.OpenFolderPickerAsync( + new FolderPickerOpenOptions { + Title = "Select Folder", + AllowMultiple = false + }); return folders.FirstOrDefault()?.Path.LocalPath ?? string.Empty; } - var patterns = FileVarPatterns(type); + string[] patterns = FileVarPatterns(type); - var files = await StorageProvider.OpenFilePickerAsync(new global::Avalonia.Platform.Storage.FilePickerOpenOptions - { + IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Select file", AllowMultiple = false, - FileTypeFilter = new[] { new global::Avalonia.Platform.Storage.FilePickerFileType(type.ToString()) { Patterns = patterns } } + FileTypeFilter = new[] { new FilePickerFileType(type.ToString()) { Patterns = patterns } } }); return files.FirstOrDefault()?.Path.LocalPath ?? string.Empty; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to parse filepath"); return string.Empty; } } - private static string[] FileVarPatterns(WidgetVariableType type) => type switch - { - WidgetVariableType.ImageFile => ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.avif", "*.bmp", "*.svg", "*.ico"], - WidgetVariableType.SoundFile => ["*.wav", "*.mp3", "*.ogg", "*.oga", "*.opus", "*.m4a"], - WidgetVariableType.VideoFile => ["*.mp4", "*.m4v", "*.webm", "*.ogm", "*.mkv", "*.mov"], - _ => ["*.*"] - }; + private static string[] FileVarPatterns(WidgetVariableType type) { + return type switch { + WidgetVariableType.ImageFile => + ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.avif", "*.bmp", "*.svg", "*.ico"], + WidgetVariableType.SoundFile => ["*.wav", "*.mp3", "*.ogg", "*.oga", "*.opus", "*.m4a"], + WidgetVariableType.VideoFile => ["*.mp4", "*.m4v", "*.webm", "*.ogm", "*.mkv", "*.mov"], + _ => ["*.*"] + }; + } - private static bool FileVarAccepts(WidgetVariableType type, string path) - { + private static bool FileVarAccepts(WidgetVariableType type, string path) { if (type == WidgetVariableType.FolderPath) return Directory.Exists(path); if (!File.Exists(path)) return false; - var patterns = FileVarPatterns(type); + string[] patterns = FileVarPatterns(type); if (patterns.Contains("*.*")) return true; string ext = Path.GetExtension(path); return patterns.Any(p => string.Equals(p[1..], ext, StringComparison.OrdinalIgnoreCase)); } - private static IEnumerable GetAllCheckBoxes(Panel panel) - { - foreach (var child in panel.Children) - { + private static IEnumerable GetAllCheckBoxes(Panel panel) { + foreach (Control? child in panel.Children) if (child is CheckBox cb) yield return cb; else if (child is Expander { Content: Panel innerPanel }) - foreach (var nested in GetAllCheckBoxes(innerPanel)) + foreach (CheckBox nested in GetAllCheckBoxes(innerPanel)) yield return nested; else if (child is Panel childPanel) - foreach (var nested in GetAllCheckBoxes(childPanel)) + foreach (CheckBox nested in GetAllCheckBoxes(childPanel)) yield return nested; - } } - private void UpdateEventListValues(JsVariable variable, Panel container) - { - var selected = GetAllCheckBoxes(container) + private void UpdateEventListValues(JsVariable variable, Panel container) { + List selected = GetAllCheckBoxes(container) .Where(c => c.IsChecked == true) .Select(c => ((TextBlock)c.Content!).Tag) .ToList(); variable.Value = string.Join(',', selected); } - private async Task SwapWidgetZAsync(Widget a, Widget b) - { - await using var db = await _factory.CreateDbContextAsync(); - var wa = await db.Widgets.Include(w => w.CssVariables) + private async Task SwapWidgetZAsync(Widget a, Widget b) { + await using AppDbContext db = await _factory.CreateDbContextAsync(); + Widget? wa = await db.Widgets.Include(w => w.CssVariables) .Include(w => w.JsVariables).FirstOrDefaultAsync(w => w.Id == a.Id); - var wb = await db.Widgets.Include(w => w.CssVariables) + Widget? wb = await db.Widgets.Include(w => w.CssVariables) .Include(w => w.JsVariables).FirstOrDefaultAsync(w => w.Id == b.Id); if (wa == null || wb == null) return; @@ -503,37 +465,34 @@ private async Task SwapWidgetZAsync(Widget a, Widget b) RefreshWebView(); } - private async Task RefreshWidgetZIndicesAsync() - { - await using var db = await _factory.CreateDbContextAsync(); + private async Task RefreshWidgetZIndicesAsync() { + await using AppDbContext db = await _factory.CreateDbContextAsync(); int start = _widgets.Count; - for (int i = 0; i < _widgets.Count; i++) - { - var wi = _widgets[i]; - var w = await db.Widgets.FirstOrDefaultAsync(x => x.Id == wi.Id); + for (var i = 0; i < _widgets.Count; i++) { + Widget wi = _widgets[i]; + Widget? w = await db.Widgets.FirstOrDefaultAsync(x => x.Id == wi.Id); w?.Z = start - i; } + await db.SaveChangesAsync(); await LoadRouteAsync(); } - private void RefreshWebView() - { + private void RefreshWebView() { if (!_loadedWebView || string.IsNullOrEmpty(_editUrl)) return; - try - { - var sep = _editUrl.Contains('?') ? "&" : "?"; + try { + string sep = _editUrl.Contains('?') ? "&" : "?"; PreviewWebView.Source = new Uri($"{_editUrl}{sep}_ts={DateTime.Now.Ticks}"); } - catch { /**/ } + catch { + /**/ + } } - - private async void BrowseWidgetsButton_Click(object? sender, RoutedEventArgs e) - { + + private async void BrowseWidgetsButton_Click(object? sender, RoutedEventArgs e) { if (_route == null) return; - var dialog = new WidgetBrowserDialog(async entry => - { + var dialog = new WidgetBrowserDialog(async entry => { string file = WidgetCatalog.ToAbsolutePath(entry.PackPath); return File.Exists(file) && await AddWidgetPackInPlaceAsync(file); }); @@ -541,27 +500,24 @@ private async void BrowseWidgetsButton_Click(object? sender, RoutedEventArgs e) await dialog.ShowDialog(this); } - public async Task AddWidgetPackInPlaceAsync(string packFile) - { + public async Task AddWidgetPackInPlaceAsync(string packFile) { if (_route == null) return false; - try - { - var mounted = WidgetPackInstaller.MountInPlace(packFile); - if (mounted == null) - { + try { + WidgetPackInstaller.InstalledPack? mounted = WidgetPackInstaller.MountInPlace(packFile); + if (mounted == null) { _logger?.LogError("Could not read widget package {Path}", packFile); return false; } - await using var db = await _factory.CreateDbContextAsync(); + await using AppDbContext db = await _factory.CreateDbContextAsync(); var helper = new WidgetEntityHelper(_factory, null); - var manifest = mounted.Manifest; + WidgetPackManifest manifest = mounted.Manifest; await ImportSingleWidgetAsync(mounted.HtmlPath, db, helper, manifest.Name, manifest.ScaleX, manifest.ScaleY); - foreach (var existing in _widgets.ToList()) + foreach (Widget existing in _widgets.ToList()) ReseatWidgetCard(existing); await RefreshWidgetZIndicesAsync(); @@ -570,27 +526,25 @@ await ImportSingleWidgetAsync(mounted.HtmlPath, db, helper, return true; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to add widget package in place {Path}", packFile); return false; } } - public async Task AddWidgetPackAsync(string packPath) - { + public async Task AddWidgetPackAsync(string packPath) { if (_route == null) return false; - try - { - await using var db = await _factory.CreateDbContextAsync(); + try { + await using AppDbContext db = await _factory.CreateDbContextAsync(); var helper = new WidgetEntityHelper(_factory, null); - bool added = packPath.EndsWith(WidgetCollectionInstaller.CollectionExtension, StringComparison.OrdinalIgnoreCase) + bool added = packPath.EndsWith(WidgetCollectionInstaller.CollectionExtension, + StringComparison.OrdinalIgnoreCase) ? await ImportWidgetCollectionAsync(packPath, db, helper) : await ImportWidgetPackAsync(packPath, db, helper); - foreach (var existing in _widgets.ToList()) + foreach (Widget existing in _widgets.ToList()) ReseatWidgetCard(existing); await RefreshWidgetZIndicesAsync(); @@ -599,18 +553,15 @@ public async Task AddWidgetPackAsync(string packPath) return added; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to add widget package {Path}", packPath); return false; } } - private async Task ImportWidgetCollectionAsync(string smwcPath, AppDbContext db, WidgetEntityHelper helper) - { - var collection = WidgetCollectionInstaller.InstallAll(smwcPath); - if (collection == null) - { + private async Task ImportWidgetCollectionAsync(string smwcPath, AppDbContext db, WidgetEntityHelper helper) { + WidgetCollectionInstaller.InstalledCollection? collection = WidgetCollectionInstaller.InstallAll(smwcPath); + if (collection == null) { _logger?.LogError("Could not read widget collection {Path}", smwcPath); return false; } @@ -618,36 +569,31 @@ private async Task ImportWidgetCollectionAsync(string smwcPath, AppDbConte if (collection.Failed > 0) _logger?.LogWarning("Skipped {Count} unreadable package(s) in {Path}", collection.Failed, smwcPath); - foreach (var pack in collection.Packs) - { - var manifest = pack.Manifest; + foreach (WidgetPackInstaller.InstalledPack pack in collection.Packs) { + WidgetPackManifest manifest = pack.Manifest; await ImportSingleWidgetAsync(pack.HtmlPath, db, helper, manifest.Name, manifest.ScaleX, manifest.ScaleY); } return collection.Packs.Count > 0; } - private async Task ImportWidgetPackAsync(string smwPath, AppDbContext db, WidgetEntityHelper helper) - { - var installed = WidgetPackInstaller.Install(smwPath); - if (installed == null) - { + private async Task ImportWidgetPackAsync(string smwPath, AppDbContext db, WidgetEntityHelper helper) { + WidgetPackInstaller.InstalledPack? installed = WidgetPackInstaller.Install(smwPath); + if (installed == null) { _logger?.LogError("Could not read widget package {Path}", smwPath); return false; } - var manifest = installed.Manifest; + WidgetPackManifest manifest = installed.Manifest; await ImportSingleWidgetAsync(installed.HtmlPath, db, helper, manifest.Name, manifest.ScaleX, manifest.ScaleY); return true; } private async Task ImportSingleWidgetAsync(string path, AppDbContext db, WidgetEntityHelper helper, - string? displayName = null, float scaleX = 1f, float scaleY = 1f) - { - var widgetType = WidgetTypeHelper.DetectFromPath(path); + string? displayName = null, float scaleX = 1f, float scaleY = 1f) { + WidgetType widgetType = WidgetTypeHelper.DetectFromPath(path); var newWidget = new Widget( - string.IsNullOrWhiteSpace(displayName) ? Path.GetFileNameWithoutExtension(path) : displayName, path) - { + string.IsNullOrWhiteSpace(displayName) ? Path.GetFileNameWithoutExtension(path) : displayName, path) { Type = widgetType, RouteId = _route!.Id, X = 0, @@ -657,9 +603,8 @@ private async Task ImportSingleWidgetAsync(string path, AppDbContext db, WidgetE Z = _widgets.Count > 0 ? _widgets.Max(x => x.Z) + 1 : 1 }; - if (widgetType == WidgetType.Html) - { - var metadata = await helper.ExtractWidgetMetadata(path); + if (widgetType == WidgetType.Html) { + WidgetMeta metadata = await helper.ExtractWidgetMetadata(path); newWidget.Width = metadata.Width > 0 ? metadata.Width : 400; newWidget.Height = metadata.Height > 0 ? metadata.Height : 400; newWidget.DocsUrl = metadata.Url; @@ -669,22 +614,19 @@ private async Task ImportSingleWidgetAsync(string path, AppDbContext db, WidgetE (List jsVars, _, _) = helper.LoadNewJsVariables(newWidget, metadata); - var allVarTypes = jsVars.Select(j => j.Type).Distinct().ToList(); - var missingFontTypes = WidgetVariableTypeHelper.FontVariables.ToList().Where(x => !allVarTypes.Contains(x)).ToList(); - foreach (var fontVar in missingFontTypes) - { - jsVars.Add(new JsVariable - { + List allVarTypes = jsVars.Select(j => j.Type).Distinct().ToList(); + List missingFontTypes = WidgetVariableTypeHelper.FontVariables.ToList() + .Where(x => !allVarTypes.Contains(x)).ToList(); + foreach (WidgetVariableType fontVar in missingFontTypes) + jsVars.Add(new JsVariable { WidgetId = newWidget.Id, Type = fontVar, Name = $"{fontVar}s", Description = $"Custom font names to include from {fontVar}s, comma separated", Value = string.Empty }); - } - if (jsVars.Count > 0) - { + if (jsVars.Count > 0) { newWidget.JsVariables = jsVars; db.JsVariables.AddRange(jsVars); } @@ -695,21 +637,19 @@ private async Task ImportSingleWidgetAsync(string path, AppDbContext db, WidgetE db.CssVariables.AddRange(newWidget.CssVariables); await db.SaveChangesAsync(); } - else - { - if (widgetType == WidgetType.Video) - { + else { + if (widgetType == WidgetType.Video) { newWidget.Width = 1280; newWidget.Height = 720; } - else - { - var (naturalW, naturalH) = DetectImageDimensions(path); + else { + (int naturalW, int naturalH) = DetectImageDimensions(path); const int anchorHeight = 400; float factor = naturalH > 0 ? anchorHeight / (float)naturalH : 1f; newWidget.Width = (int)Math.Round(naturalW * factor); newWidget.Height = anchorHeight; } + db.Widgets.Add(newWidget); await db.SaveChangesAsync(); } @@ -718,26 +658,19 @@ private async Task ImportSingleWidgetAsync(string path, AppDbContext db, WidgetE _widgets.Insert(0, newWidget); } - private static (int width, int height) DetectImageDimensions(string path) - { - try - { + private static (int width, int height) DetectImageDimensions(string path) { + try { using var bmp = new Bitmap(path); return (bmp.PixelSize.Width > 0 ? bmp.PixelSize.Width : 400, - bmp.PixelSize.Height > 0 ? bmp.PixelSize.Height : 400); + bmp.PixelSize.Height > 0 ? bmp.PixelSize.Height : 400); } - catch - { + catch { return (400, 400); } } - private string? _webViewRequirementUrl; - - private void ConfigureFallbackForPlatform() - { - if (OperatingSystem.IsWindows()) - { + private void ConfigureFallbackForPlatform() { + if (OperatingSystem.IsWindows()) { WebViewFallbackText.Text = "The embedded WebView2 runtime isn't installed on your system."; WebViewRequirementHint.Text = "Install the Microsoft Edge WebView2 Runtime, then reopen this editor."; _webViewRequirementUrl = "https://developer.microsoft.com/microsoft-edge/webview2/"; @@ -745,9 +678,9 @@ private void ConfigureFallbackForPlatform() WebViewRequirementHint.IsVisible = true; InstallWebViewButton.IsVisible = true; } - else if (OperatingSystem.IsLinux()) - { - WebViewFallbackText.Text = "The embedded WebView needs WebKitGTK or WPEWebKit, but may still not be supported."; + else if (OperatingSystem.IsLinux()) { + WebViewFallbackText.Text = + "The embedded WebView needs WebKitGTK or WPEWebKit, but may still not be supported."; WebViewRequirementHint.Text = "Use \"Browser Editor\" button " + "to edit this overlay in your web browser."; @@ -756,26 +689,24 @@ private void ConfigureFallbackForPlatform() WebViewRequirementHint.IsVisible = true; InstallWebViewButton.IsVisible = true; } - else - { + else { WebViewFallbackText.Text = "The embedded WebView couldn't be initialized on this system."; WebViewRequirementHint.IsVisible = false; InstallWebViewButton.IsVisible = false; } } - private void OpenWebViewRequirement_Click(object? sender, RoutedEventArgs e) - { + private void OpenWebViewRequirement_Click(object? sender, RoutedEventArgs e) { if (string.IsNullOrWhiteSpace(_webViewRequirementUrl)) return; - try - { + try { Process.Start(new ProcessStartInfo { FileName = _webViewRequirementUrl, UseShellExecute = true }); } - catch { /**/ } + catch { + /**/ + } } - private void UpdateWebViewScale() - { + private void UpdateWebViewScale() { if (PreviewWebView == null || _route == null || WebViewContainer == null) return; if (WebViewContainer.Bounds.Width <= 0 || WebViewContainer.Bounds.Height <= 0) return; @@ -790,62 +721,56 @@ private void UpdateWebViewScale() ApplyWebViewZoom(scale); } - private async void ApplyWebViewZoom(double scale) - { + private async void ApplyWebViewZoom(double scale) { if (!_loadedWebView) return; - try - { + try { var s = scale.ToString(CultureInfo.InvariantCulture); await PreviewWebView.InvokeScript( $"(function(){{var z={s};if(window.__setPreviewZoom){{" + $"document.documentElement.style.zoom='';window.__setPreviewZoom(z);}}" + $"else{{document.documentElement.style.zoom=z;window.__previewZoom=z;}}}})();"); } - catch { /**/ } + catch { + /**/ + } } - private async void ApplyWebViewBackground(bool light) - { + private async void ApplyWebViewBackground(bool light) { if (!_loadedWebView) return; - var color = light ? "#ffffff" : "#1e1e1e"; - try - { + string color = light ? "#ffffff" : "#1e1e1e"; + try { await PreviewWebView.InvokeScript($"document.documentElement.style.background='{color}';"); } - catch { /**/ } + catch { + /**/ + } } - private void WebViewBgToggle_Click(object? sender, RoutedEventArgs e) - { + private void WebViewBgToggle_Click(object? sender, RoutedEventArgs e) { _webViewLightBg = WebViewBgToggle.IsChecked != true; ApplyWebViewBgToggle(); _ = StateValueHelper.SetAsync(_factory, StateKeys.EditorPreviewLightBg, _webViewLightBg); } - private void LoadPreviewBgPreference() - { - try - { - using var db = _factory.CreateDbContext(); + private void LoadPreviewBgPreference() { + try { + using AppDbContext db = _factory.CreateDbContext(); _webViewLightBg = StateValueHelper.Get(db, StateKeys.EditorPreviewLightBg, true); ApplyWebViewBgToggle(); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogWarning(ex, "Could not read the editor preview background preference"); } } - private void ApplyWebViewBgToggle() - { + private void ApplyWebViewBgToggle() { WebViewBgToggle.IsChecked = !_webViewLightBg; WebViewBgToggle.Content = _webViewLightBg ? "Light" : "Dark"; ApplyWebViewBackground(_webViewLightBg); } - protected override void OnClosed(EventArgs e) - { + protected override void OnClosed(EventArgs e) { WidgetEvents.WidgetPositionUpdated -= OnWidgetPositionUpdated; WidgetEvents.WidgetScaleUpdated -= OnWidgetScaleUpdated; WidgetEvents.SelectEditorWidget -= SelectWidgetFromEvent; @@ -853,31 +778,31 @@ protected override void OnClosed(EventArgs e) Loaded -= EditRouteWindow_Loaded; if (_loadedWebView) - { - try { PreviewWebView.Source = new Uri("about:blank"); } - catch { /**/ } - } + try { + PreviewWebView.Source = new Uri("about:blank"); + } + catch { + /**/ + } _cssLivePreviewTimer?.Dispose(); UnsubscribeCssVarChanges(); base.OnClosed(e); } - private void UpdateSaveButtonBorder(Border border, bool hasPendingChanges) - => Dispatcher.UIThread.Post(() => UiUtils.UiHelpers.UpdateButtonPendingBorder(border, hasPendingChanges)); + private void UpdateSaveButtonBorder(Border border, bool hasPendingChanges) { + Dispatcher.UIThread.Post(() => UiHelpers.UpdateButtonPendingBorder(border, hasPendingChanges)); + } - private void OnObsConnectionUpdated(IntegrationConnection? connection) - { + private void OnObsConnectionUpdated(IntegrationConnection? connection) { if (connection is not { Source: SubathonEventSource.OBS, Service: "OBS" }) return; Dispatcher.UIThread.Post(() => ObsConnected = connection.Status); } - private async void AddToObs_Click(object? sender, RoutedEventArgs e) - { + private async void AddToObs_Click(object? sender, RoutedEventArgs e) { if (_route == null) return; - try - { - var scenes = ServiceManager.OBS.GetScenes(); + try { + List scenes = ServiceManager.OBS.GetScenes(); string currentScene = ServiceManager.OBS.GetCurrentScene(); var config = AppServices.Provider.GetRequiredService(); string url = _route.GetRouteUrl(config); @@ -885,9 +810,8 @@ private async void AddToObs_Click(object? sender, RoutedEventArgs e) var dialog = new ObsAddSourceDialog(_route, url, scenes, currentScene); await dialog.ShowDialog(this); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "[OBS] Failed to open add source dialog for overlay {Name}", _route.Name); } } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/MainWindow.Cap.cs b/SubathonManager.UI/MainWindow.Cap.cs index 5e47ee24..dd0470a9 100644 --- a/SubathonManager.UI/MainWindow.Cap.cs +++ b/SubathonManager.UI/MainWindow.Cap.cs @@ -2,34 +2,31 @@ using Avalonia.Interactivity; using Microsoft.EntityFrameworkCore; using SubathonManager.Core.Events; +using SubathonManager.Core.Models; +using SubathonManager.Data; namespace SubathonManager.UI; -public partial class MainWindow -{ - private void CapBtn_Click(object? sender, RoutedEventArgs e) - { - if (CapPopup.IsOpen) - { +public partial class MainWindow { + private void CapBtn_Click(object? sender, RoutedEventArgs e) { + if (CapPopup.IsOpen) { CapPopup.IsOpen = false; return; } - using var db = _factory.CreateDbContext(); - var subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); + using AppDbContext db = _factory.CreateDbContext(); + SubathonData? subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); if (subathon == null) return; - if (subathon.CapDateTime.HasValue) - { - var cap = subathon.CapDateTime.Value; + if (subathon.CapDateTime.HasValue) { + DateTime cap = subathon.CapDateTime.Value; CapDatePicker.SelectedDate = new DateTimeOffset(cap.Date); CapHourInput.Text = cap.Hour.ToString("D2"); CapMinuteInput.Text = cap.Minute.ToString("D2"); CapCurrentLabel.Text = $"Cap: {cap:MMM d, h:mm tt}"; CapIcon.Glyph = "Flag20"; } - else - { + else { CapDatePicker.SelectedDate = new DateTimeOffset(DateTime.Today); CapHourInput.Text = DateTime.Now.AddHours(1).Hour.ToString("D2"); CapMinuteInput.Text = "00"; @@ -40,38 +37,35 @@ private void CapBtn_Click(object? sender, RoutedEventArgs e) CapPopup.IsOpen = true; } - private void CapDatePicker_SelectedDateChanged(object? sender, DatePickerSelectedValueChangedEventArgs e) - => ValidateCapInput(); + private void CapDatePicker_SelectedDateChanged(object? sender, DatePickerSelectedValueChangedEventArgs e) { + ValidateCapInput(); + } - private void CapTime_TextChanged(object? sender, TextChangedEventArgs e) - => ValidateCapInput(); + private void CapTime_TextChanged(object? sender, TextChangedEventArgs e) { + ValidateCapInput(); + } - private bool ValidateCapInput() - { - if (CapDatePicker.SelectedDate == null) - { + private bool ValidateCapInput() { + if (CapDatePicker.SelectedDate == null) { CapValidationMsg.Text = "Please select a date."; SetCapBtn.IsEnabled = false; return false; } - if (!int.TryParse(CapHourInput.Text, out int hour) || hour < 0 || hour > 23) - { + if (!int.TryParse(CapHourInput.Text, out int hour) || hour < 0 || hour > 23) { CapValidationMsg.Text = "Hour must be 0–23."; SetCapBtn.IsEnabled = false; return false; } - if (!int.TryParse(CapMinuteInput.Text, out int minute) || minute < 0 || minute > 59) - { + if (!int.TryParse(CapMinuteInput.Text, out int minute) || minute < 0 || minute > 59) { CapValidationMsg.Text = "Minute must be 0–59."; SetCapBtn.IsEnabled = false; return false; } - var picked = CapDatePicker.SelectedDate.Value.Date.AddHours(hour).AddMinutes(minute); - if (picked <= DateTime.Now) - { + DateTime picked = CapDatePicker.SelectedDate.Value.Date.AddHours(hour).AddMinutes(minute); + if (picked <= DateTime.Now) { CapValidationMsg.Text = "Cap must be in the future."; SetCapBtn.IsEnabled = false; return false; @@ -82,16 +76,15 @@ private bool ValidateCapInput() return true; } - private void SetCap_Click(object? sender, RoutedEventArgs e) - { + private void SetCap_Click(object? sender, RoutedEventArgs e) { if (!ValidateCapInput()) return; int hour = int.Parse(CapHourInput.Text!); int minute = int.Parse(CapMinuteInput.Text!); - var capDateTime = CapDatePicker.SelectedDate!.Value.Date.AddHours(hour).AddMinutes(minute); + DateTime capDateTime = CapDatePicker.SelectedDate!.Value.Date.AddHours(hour).AddMinutes(minute); - using var db = _factory.CreateDbContext(); - var subathon = db.SubathonDatas.FirstOrDefault(s => s.IsActive); + using AppDbContext db = _factory.CreateDbContext(); + SubathonData? subathon = db.SubathonDatas.FirstOrDefault(s => s.IsActive); if (subathon == null) return; subathon.CapDateTime = capDateTime; @@ -101,7 +94,7 @@ private void SetCap_Click(object? sender, RoutedEventArgs e) CapIcon.Glyph = "Flag20"; CapPopup.IsOpen = false; - var snapshot = db.SubathonDatas + SubathonData? snapshot = db.SubathonDatas .Where(x => x.Id == subathon.Id && x.IsActive) .Include(x => x.Multiplier) .AsNoTracking() @@ -110,10 +103,9 @@ private void SetCap_Click(object? sender, RoutedEventArgs e) SubathonEvents.RaiseSubathonDataUpdate(snapshot, DateTime.Now); } - private void ClearCap_Click(object? sender, RoutedEventArgs e) - { - using var db = _factory.CreateDbContext(); - var subathon = db.SubathonDatas.FirstOrDefault(s => s.IsActive); + private void ClearCap_Click(object? sender, RoutedEventArgs e) { + using AppDbContext db = _factory.CreateDbContext(); + SubathonData? subathon = db.SubathonDatas.FirstOrDefault(s => s.IsActive); if (subathon == null) return; subathon.CapDateTime = null; @@ -123,4 +115,4 @@ private void ClearCap_Click(object? sender, RoutedEventArgs e) CapIcon.Glyph = "FlagOff20"; CapPopup.IsOpen = false; } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/MainWindow.ConnectionStatus.cs b/SubathonManager.UI/MainWindow.ConnectionStatus.cs index 9aec2458..f211eab6 100644 --- a/SubathonManager.UI/MainWindow.ConnectionStatus.cs +++ b/SubathonManager.UI/MainWindow.ConnectionStatus.cs @@ -7,12 +7,12 @@ using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Events; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; namespace SubathonManager.UI; -public partial class MainWindow -{ +public partial class MainWindow { private static readonly IBrush StatusUpBrush = new SolidColorBrush(Color.FromRgb(0x3F, 0xB9, 0x50)); private static readonly IBrush StatusDownBrush = new SolidColorBrush(Color.FromRgb(0xE8, 0x4A, 0x4A)); private static readonly IBrush StatusMixedBrush = new SolidColorBrush(Color.FromRgb(0xE8, 0xA3, 0x3D)); @@ -20,23 +20,21 @@ public partial class MainWindow private MenuFlyout? _statusFlyout; - private void InitConnectionStatus() - { + private void InitConnectionStatus() { IntegrationEvents.ConnectionUpdated += OnConnectionStatusChanged; Closed += (_, _) => IntegrationEvents.ConnectionUpdated -= OnConnectionStatusChanged; UpdateConnectionStatusDot(); } - private void OnConnectionStatusChanged(IntegrationConnection _) - => Dispatcher.UIThread.Post(() => - { + private void OnConnectionStatusChanged(IntegrationConnection _) { + Dispatcher.UIThread.Post(() => { UpdateConnectionStatusDot(); RefreshOpenConnectionStatusMenu(); }); + } - private void UpdateConnectionStatusDot() - { - var counted = Utils.GetAllConnections().Where(c => !IsAggregateExempt(c)).ToList(); + private void UpdateConnectionStatusDot() { + List counted = Utils.GetAllConnections().Where(c => !IsAggregateExempt(c)).ToList(); ConnectionStatusDot.Fill = GetAggregateBrush(counted); int up = counted.Count(c => c.Status); ToolTip.SetTip(ConnectionStatusBtn, counted.Count == 0 @@ -44,55 +42,53 @@ private void UpdateConnectionStatusDot() : $"Connection Status: {up}/{counted.Count} connected"); } - private static bool IsAggregateExempt(IntegrationConnection c) => - !c.Status && (!c.Configured - || c is { Source: SubathonEventSource.KoFi, Service: "Socket" } - or { Source: SubathonEventSource.OBS, Service: "HelperScript" }); + private static bool IsAggregateExempt(IntegrationConnection c) { + return !c.Status && (!c.Configured + || c is { Source: SubathonEventSource.KoFi, Service: "Socket" } + or { Source: SubathonEventSource.OBS, Service: "HelperScript" }); + } - private static IBrush LeafBrush(IntegrationConnection? c) => - c is not { Status: true } + private static IBrush LeafBrush(IntegrationConnection? c) { + return c is not { Status: true } ? c is { Configured: true } ? StatusDownBrush : StatusNoneBrush : StatusUpBrush; + } - private static IBrush GetAggregateBrush(IEnumerable connections) - { - var counted = connections.Where(c => !IsAggregateExempt(c)).ToList(); + private static IBrush GetAggregateBrush(IEnumerable connections) { + List counted = connections.Where(c => !IsAggregateExempt(c)).ToList(); if (counted.Count == 0) return StatusNoneBrush; int up = counted.Count(c => c.Status); if (up == counted.Count) return StatusUpBrush; return up == 0 ? StatusDownBrush : StatusMixedBrush; } - private void ConnectionStatusBtn_Click(object? sender, RoutedEventArgs e) - { + private void ConnectionStatusBtn_Click(object? sender, RoutedEventArgs e) { var flyout = new MenuFlyout { Placement = PlacementMode.Bottom }; FillConnectionStatusMenu(flyout.Items); - flyout.Closed += (_, _) => { if (ReferenceEquals(_statusFlyout, flyout)) _statusFlyout = null; }; + flyout.Closed += (_, _) => { + if (ReferenceEquals(_statusFlyout, flyout)) _statusFlyout = null; + }; _statusFlyout = flyout; flyout.ShowAt(ConnectionStatusBtn); } - private void RefreshOpenConnectionStatusMenu() - { + private void RefreshOpenConnectionStatusMenu() { if (_statusFlyout is not { } flyout) return; var fresh = new MenuFlyout(); FillConnectionStatusMenu(fresh.Items); SyncMenuItems(flyout.Items, fresh.Items); } - private static void SyncMenuItems(ItemCollection target, ItemCollection source) - { - if (target.Count != source.Count) - { - var items = source.Cast().ToList(); + private static void SyncMenuItems(ItemCollection target, ItemCollection source) { + if (target.Count != source.Count) { + List items = source.Cast().ToList(); source.Clear(); target.Clear(); - foreach (var item in items) target.Add(item); + foreach (object item in items) target.Add(item); return; } - for (int i = 0; i < target.Count; i++) - { + for (var i = 0; i < target.Count; i++) { if (target[i] is not MenuItem tgt || source[i] is not MenuItem src) continue; if (src.Header is string headerText) tgt.Header = headerText; @@ -103,96 +99,90 @@ private static void SyncMenuItems(ItemCollection target, ItemCollection source) } } - private void FillConnectionStatusMenu(ItemCollection items) - { + private void FillConnectionStatusMenu(ItemCollection items) { items.Clear(); - var connections = Utils.GetAllConnections().ToList(); - if (connections.Count == 0) - { + List connections = Utils.GetAllConnections().ToList(); + if (connections.Count == 0) { items.Add(new MenuItem { Header = "No integrations active", IsEnabled = false }); return; } - var bySource = connections + List> bySource = connections .GroupBy(c => GetEffectiveSource(c.Source)) .OrderBy(g => SubathonEventSourceHelper.GetSourceOrder(g.Key)) .ToList(); - var byGroup = bySource - .GroupBy(g => g.Key.GetGroup()) - .OrderBy(gg => gg.Min(s => SubathonEventSourceHelper.GetSourceOrder(s.Key))); + IOrderedEnumerable>> + byGroup = bySource + .GroupBy(g => g.Key.GetGroup()) + .OrderBy(gg => gg.Min(s => SubathonEventSourceHelper.GetSourceOrder(s.Key))); - foreach (var group in byGroup) - { - var groupItem = new MenuItem - { + foreach (IGrouping> group in + byGroup) { + var groupItem = new MenuItem { Icon = MakeStatusDot(GetAggregateBrush(group.SelectMany(s => s))), StaysOpenOnClick = true }; SetParentHeader(groupItem, group.First().Key.GetGroupLabel()); - foreach (var source in group) + foreach (IGrouping source in group) groupItem.Items.Add(BuildSourceItem(source.Key, source.ToList())); items.Add(groupItem); } } - private MenuItem BuildSourceItem(SubathonEventSource source, List connections) - { - switch (source) - { + private MenuItem BuildSourceItem(SubathonEventSource source, List connections) { + switch (source) { case SubathonEventSource.KoFi: return BuildKoFiItem(connections); case SubathonEventSource.PallyGG: return BuildPallyItem(connections); case SubathonEventSource.DevTunnels: return BuildDevTunnelsItem(connections); case SubathonEventSource.OBS: return BuildObsItem(connections); } - var sourceGroup = source.GetGroup(); + SubathonSourceGroup sourceGroup = source.GetGroup(); if (sourceGroup is SubathonSourceGroup.StreamExtension or SubathonSourceGroup.ExternalSoftware || source is SubathonEventSource.FourthWall or SubathonEventSource.Throne - or SubathonEventSource.MakeShip) - { + or SubathonEventSource.MakeShip) { bool up = connections.Any(c => c.Status); - IBrush brush = up ? StatusUpBrush + IBrush brush = up + ? StatusUpBrush : sourceGroup == SubathonSourceGroup.ExternalSoftware || connections.Any(c => c.Configured) ? StatusDownBrush : StatusNoneBrush; return MakeLeafItem(source.GetDescription(), brush, null, source); } - var sourceItem = new MenuItem - { + var sourceItem = new MenuItem { Icon = MakeStatusDot(GetAggregateBrush(connections)), StaysOpenOnClick = true }; SetParentHeader(sourceItem, source.GetDescription()); - var ordered = connections + IOrderedEnumerable ordered = connections .OrderBy(c => c.Service == c.Source.ToString() ? 0 : 1) .ThenBy(c => c.Service, StringComparer.OrdinalIgnoreCase); - foreach (var connection in ordered) - { + foreach (IntegrationConnection connection in ordered) { string label = connection.Service == connection.Source.ToString() ? "Account" : connection.Service; string? navDetail = null; if (connection.Source == SubathonEventSource.GoAffPro - && GoAffProStoreRegistry.TryGetByInternalName(connection.Service, out var store)) - { + && GoAffProStoreRegistry.TryGetByInternalName(connection.Service, out GoAffProStore? store)) { label = store.StoreName; navDetail = store.InternalName; } + string detail = ShowableDetail(connection); if (detail.Length > 0) label += $": {detail}"; sourceItem.Items.Add(MakeLeafItem(label, LeafBrush(connection), null, source, navDetail)); } + return sourceItem; } - private MenuItem BuildKoFiItem(List connections) - { - var tunnel = connections.FirstOrDefault(c => c.Source == SubathonEventSource.KoFiTunnel); - var socket = connections.FirstOrDefault(c => c is { Source: SubathonEventSource.KoFi, Service: "Socket" }); + private MenuItem BuildKoFiItem(List connections) { + IntegrationConnection? tunnel = connections.FirstOrDefault(c => c.Source == SubathonEventSource.KoFiTunnel); + IntegrationConnection? socket = + connections.FirstOrDefault(c => c is { Source: SubathonEventSource.KoFi, Service: "Socket" }); bool up = tunnel?.Status == true || socket?.Status == true; - var item = new MenuItem - { + var item = new MenuItem { Icon = MakeStatusDot(up ? StatusUpBrush : LeafBrush(tunnel)), StaysOpenOnClick = true }; @@ -200,19 +190,17 @@ private MenuItem BuildKoFiItem(List connections) item.Items.Add(MakeLeafItem("Webhook Tunnel", LeafBrush(tunnel), null, SubathonEventSource.KoFi)); bool socketUp = socket?.Status == true; - var socketItem = MakeLeafItem("Socket (Legacy)", + MenuItem socketItem = MakeLeafItem("Socket (Legacy)", socketUp ? StatusUpBrush : StatusNoneBrush, null, SubathonEventSource.KoFi); socketItem.IsEnabled = socketUp; item.Items.Add(socketItem); return item; } - private MenuItem BuildPallyItem(List connections) - { - var socket = connections.FirstOrDefault(c => c.Service == "Socket") ?? connections.First(); - var brush = LeafBrush(socket); - var item = new MenuItem - { + private MenuItem BuildPallyItem(List connections) { + IntegrationConnection socket = connections.FirstOrDefault(c => c.Service == "Socket") ?? connections.First(); + IBrush brush = LeafBrush(socket); + var item = new MenuItem { Icon = MakeStatusDot(brush), StaysOpenOnClick = true }; @@ -222,44 +210,40 @@ private MenuItem BuildPallyItem(List connections) return item; } - private MenuItem BuildDevTunnelsItem(List connections) - { - var item = new MenuItem - { + private MenuItem BuildDevTunnelsItem(List connections) { + var item = new MenuItem { Icon = MakeStatusDot(GetAggregateBrush(connections)), StaysOpenOnClick = true }; SetParentHeader(item, SubathonEventSource.DevTunnels.GetDescription()); - var cli = connections.FirstOrDefault(c => c.Service == "Cli"); - var login = connections.FirstOrDefault(c => c.Service == "Login"); - var tunnel = connections.FirstOrDefault(c => c.Service == "Tunnel"); + IntegrationConnection? cli = connections.FirstOrDefault(c => c.Service == "Cli"); + IntegrationConnection? login = connections.FirstOrDefault(c => c.Service == "Login"); + IntegrationConnection? tunnel = connections.FirstOrDefault(c => c.Service == "Tunnel"); - if (cli != null) - { + if (cli != null) { string label = cli.Status && !string.IsNullOrWhiteSpace(cli.Name) ? $"CLI: v{cli.Name}" : "CLI"; item.Items.Add(MakeLeafItem(label, LeafBrush(cli), cli.Status ? null : "Not Installed", SubathonEventSource.DevTunnels)); } - if (login != null) - { + + if (login != null) { string label = login.Status && !string.IsNullOrWhiteSpace(login.Detail) ? $"Login: {login.Detail}" : "Login"; item.Items.Add(MakeLeafItem(label, LeafBrush(login), null, SubathonEventSource.DevTunnels)); } + if (tunnel != null) item.Items.Add(MakeLeafItem("Tunnel", LeafBrush(tunnel), null, SubathonEventSource.DevTunnels)); return item; } - private MenuItem BuildObsItem(List connections) - { - var webSocket = connections.FirstOrDefault(c => c.Service == "OBS"); - var helper = connections.FirstOrDefault(c => c.Service == "HelperScript"); + private MenuItem BuildObsItem(List connections) { + IntegrationConnection? webSocket = connections.FirstOrDefault(c => c.Service == "OBS"); + IntegrationConnection? helper = connections.FirstOrDefault(c => c.Service == "HelperScript"); - var item = new MenuItem - { + var item = new MenuItem { Icon = MakeStatusDot(GetAggregateBrush(connections)), StaysOpenOnClick = true }; @@ -278,26 +262,22 @@ private MenuItem BuildObsItem(List connections) return item; } - private static string ShowableDetail(IntegrationConnection connection) - { + private static string ShowableDetail(IntegrationConnection connection) { if (string.IsNullOrWhiteSpace(connection.Name)) return ""; if (string.Equals(connection.Name, "None", StringComparison.OrdinalIgnoreCase)) return ""; if (connection.Name.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return ""; return Truncate(connection.Name, 36); } - private static SubathonEventSource GetEffectiveSource(SubathonEventSource source) - { - var trueSource = EnumMetaCache.Get(source)?.TrueSource - ?? SubathonEventSource.Unknown; + private static SubathonEventSource GetEffectiveSource(SubathonEventSource source) { + SubathonEventSource trueSource = EnumMetaCache.Get(source)?.TrueSource + ?? SubathonEventSource.Unknown; return trueSource == SubathonEventSource.Unknown ? source : trueSource; } private MenuItem MakeLeafItem(string header, IBrush brush, string? toolTip, - SubathonEventSource? navigateTo = null, string? navigateDetail = null) - { - var item = new MenuItem - { + SubathonEventSource? navigateTo = null, string? navigateDetail = null) { + var item = new MenuItem { Header = header, Icon = MakeStatusDot(brush), StaysOpenOnClick = navigateTo == null @@ -308,24 +288,27 @@ private MenuItem MakeLeafItem(string header, IBrush brush, string? toolTip, return item; } - private void NavigateToSourceSettings(SubathonEventSource source, string? detail = null) - { + private void NavigateToSourceSettings(SubathonEventSource source, string? detail = null) { _statusFlyout?.Hide(); MainWindowTabs.SelectedItem = SettingsTabItem; SettingsEvents.RaiseHotLinkToSourceRequest(source, detail); } - private static void SetParentHeader(MenuItem item, string text) => item.Header = text; + private static void SetParentHeader(MenuItem item, string text) { + item.Header = text; + } - private static Ellipse MakeStatusDot(IBrush? brush) => new() - { - Width = 9, - Height = 9, - Fill = brush, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - }; + private static Ellipse MakeStatusDot(IBrush? brush) { + return new Ellipse { + Width = 9, + Height = 9, + Fill = brush, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + } - private static string Truncate(string value, int max) - => value.Length <= max ? value : value[..(max - 1)] + "..."; -} + private static string Truncate(string value, int max) { + return value.Length <= max ? value : value[..(max - 1)] + "..."; + } +} \ No newline at end of file diff --git a/SubathonManager.UI/MainWindow.Multiplier.cs b/SubathonManager.UI/MainWindow.Multiplier.cs index 8b3e05e1..538bdf72 100644 --- a/SubathonManager.UI/MainWindow.Multiplier.cs +++ b/SubathonManager.UI/MainWindow.Multiplier.cs @@ -6,22 +6,19 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Models; +using SubathonManager.Data; namespace SubathonManager.UI; -public partial class MainWindow -{ - private void TogglePowerMultiplier_Click(object? sender, RoutedEventArgs e) - { - using var db = _factory.CreateDbContext(); - var subathon = db.SubathonDatas.Include(s => s.Multiplier).FirstOrDefault(s => s.IsActive); +public partial class MainWindow { + private void TogglePowerMultiplier_Click(object? sender, RoutedEventArgs e) { + using AppDbContext db = _factory.CreateDbContext(); + SubathonData? subathon = db.SubathonDatas.Include(s => s.Multiplier).FirstOrDefault(s => s.IsActive); if (subathon == null) return; bool isMultiplierActive = subathon.Multiplier.Multiplier > 1 || subathon.Multiplier.Multiplier < 1; - if (isMultiplierActive) - { - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + if (isMultiplierActive) { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Source = SubathonEventSource.Command, SubathonId = subathon.Id, User = "SYSTEM", @@ -34,10 +31,8 @@ private void TogglePowerMultiplier_Click(object? sender, RoutedEventArgs e) bool applyTime = ApplyTimeCb.IsChecked ?? false; bool applyPts = ApplyPtsCb.IsChecked ?? false; - if (!applyTime && !applyPts) - { - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + if (!applyTime && !applyPts) { + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Source = SubathonEventSource.Command, SubathonId = subathon.Id, User = "SYSTEM", @@ -50,12 +45,11 @@ private void TogglePowerMultiplier_Click(object? sender, RoutedEventArgs e) } TimeSpan duration = Utils.ParseDurationString(MultiplierDurationInput.Text ?? ""); - if (!double.TryParse(MultiplierAmtInput.Text, out var parsedAmt) || parsedAmt <= 0) return; + if (!double.TryParse(MultiplierAmtInput.Text, out double parsedAmt) || parsedAmt <= 0) return; string durationStr = duration == TimeSpan.Zero ? "x" : ((int)duration.TotalSeconds).ToString(); - string dataStr = $"{parsedAmt}|{durationStr}s|{applyPts}|{applyTime}"; - SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent - { + var dataStr = $"{parsedAmt}|{durationStr}s|{applyPts}|{applyTime}"; + SubathonEvents.RaiseSubathonEventCreated(new SubathonEvent { Source = SubathonEventSource.Command, SubathonId = subathon.Id, User = "SYSTEM", @@ -66,24 +60,23 @@ private void TogglePowerMultiplier_Click(object? sender, RoutedEventArgs e) }); } - private void UpdateMultiplierUi(SubathonData subathon, DateTime time) - { + private void UpdateMultiplierUi(SubathonData subathon, DateTime time) { bool isMultiplierSet = subathon.Multiplier.Multiplier < 1 || subathon.Multiplier.Multiplier > 1; TimeSpan? newDuration = subathon.Multiplier.Duration == null || subathon.Multiplier.Started == null ? null - : (subathon.Multiplier.Started + subathon.Multiplier.Duration) - DateTime.Now; + : subathon.Multiplier.Started + subathon.Multiplier.Duration - DateTime.Now; if (newDuration != null) newDuration = TimeSpan.FromSeconds(Math.Floor(newDuration.Value.TotalSeconds)); - Dispatcher.UIThread.Post(() => - { - var glyph = isMultiplierSet ? "Prohibited16" : "Play16"; + Dispatcher.UIThread.Post(() => { + string glyph = isMultiplierSet ? "Prohibited16" : "Play16"; if (MultiplierIcon.Glyph != glyph) MultiplierIcon.Glyph = glyph; ToolTip.SetTip(ToggleMultiplierBtn, isMultiplierSet ? "Stop Multiplier" : "Start Multiplier"); - string valsText = $"Time x{(subathon.Multiplier.ApplyToSeconds ? subathon.Multiplier.Multiplier : 1)}\tPoints x{(subathon.Multiplier.ApplyToPoints ? subathon.Multiplier.Multiplier : 1)}"; + var valsText = + $"Time x{(subathon.Multiplier.ApplyToSeconds ? subathon.Multiplier.Multiplier : 1)}\tPoints x{(subathon.Multiplier.ApplyToPoints ? subathon.Multiplier.Multiplier : 1)}"; if (MultiplierVals.Text != valsText) MultiplierVals.Text = valsText; string remainingText = !isMultiplierSet || newDuration == null ? "" : $"Remaining: {newDuration}"; @@ -93,4 +86,4 @@ private void UpdateMultiplierUi(SubathonData subathon, DateTime time) MultiplierRemainingTime.Text = "HypeTrain"; }); } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/MainWindow.Obs.cs b/SubathonManager.UI/MainWindow.Obs.cs index ba636015..9003bc45 100644 --- a/SubathonManager.UI/MainWindow.Obs.cs +++ b/SubathonManager.UI/MainWindow.Obs.cs @@ -1,4 +1,6 @@ using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; using Avalonia.Threading; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -6,44 +8,38 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; -using SubathonManager.Core.Objects; using SubathonManager.Core.Models; -using SubathonManager.UI.Views; +using SubathonManager.Core.Objects; using SubathonManager.UI.Services; +using SubathonManager.UI.Views; namespace SubathonManager.UI; -public partial class MainWindow -{ +public partial class MainWindow { public static readonly StyledProperty ObsConnectedProperty = AvaloniaProperty.Register(nameof(ObsConnected)); - public bool ObsConnected - { + public bool ObsConnected { get => GetValue(ObsConnectedProperty); set => SetValue(ObsConnectedProperty, value); } - private void InitObsIntegration() - { + private void InitObsIntegration() { IntegrationEvents.ConnectionUpdated += OnObsConnectionUpdated; Closed += (_, _) => IntegrationEvents.ConnectionUpdated -= OnObsConnectionUpdated; ObsConnected = ServiceManager.OBS.Connected; } - private void OnObsConnectionUpdated(IntegrationConnection? connection) - { + private void OnObsConnectionUpdated(IntegrationConnection? connection) { if (connection is not { Source: SubathonEventSource.OBS, Service: "OBS" }) return; Dispatcher.UIThread.Post(() => ObsConnected = connection.Status); } - private async void AddToObs_Click(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e) - { - if (sender is not global::Avalonia.Controls.Control { DataContext: Route route }) return; + private async void AddToObs_Click(object? sender, RoutedEventArgs e) { + if (sender is not Control { DataContext: Route route }) return; - try - { - var scenes = ServiceManager.OBS.GetScenes(); + try { + List scenes = ServiceManager.OBS.GetScenes(); string currentScene = ServiceManager.OBS.GetCurrentScene(); var config = AppServices.Provider.GetRequiredService(); string url = route.GetRouteUrl(config); @@ -51,9 +47,8 @@ private async void AddToObs_Click(object? sender, global::Avalonia.Interactivity var dialog = new ObsAddSourceDialog(route, url, scenes, currentScene); await dialog.ShowDialog(this); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "[OBS] Failed to open add source dialog for route {Name}", route.Name); } } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/MainWindow.Routes.cs b/SubathonManager.UI/MainWindow.Routes.cs index 46bf301f..d6c5cad1 100644 --- a/SubathonManager.UI/MainWindow.Routes.cs +++ b/SubathonManager.UI/MainWindow.Routes.cs @@ -19,100 +19,97 @@ namespace SubathonManager.UI; -public partial class MainWindow -{ +public partial class MainWindow { // ReSharper disable once InconsistentNaming internal readonly ILogger? _logger = AppServices.Provider.GetService>(); + private EditRouteWindow? _editWindow; + public ObservableCollection Overlays { get; } = new(); - private void InitOverlays() - { + private void InitOverlays() { InitObsIntegration(); OverlaysList.ItemsSource = Overlays; LoadRoutes(); } - private void LoadRoutes() - { + private void LoadRoutes() { Overlays.Clear(); - using var db = _factory.CreateDbContext(); - var routes = db.Routes.OrderByDescending(r => r.UpdatedTimestamp).ToList(); + using AppDbContext db = _factory.CreateDbContext(); + List routes = db.Routes.OrderByDescending(r => r.UpdatedTimestamp).ToList(); - foreach (var route in routes) + foreach (Route route in routes) Overlays.Add(route); } private void OpenMarketplace_Click(object? sender, RoutedEventArgs e) { - Process.Start(new ProcessStartInfo - { + Process.Start(new ProcessStartInfo { FileName = "https://docs.subathonmanager.app/latest/browse/", UseShellExecute = true }); } + private void OpenBrowsePresets_Click(object? sender, RoutedEventArgs e) { - Process.Start(new ProcessStartInfo - { + Process.Start(new ProcessStartInfo { FileName = "https://docs.subathonmanager.app/latest/widgets/presets/Presets/", UseShellExecute = true }); } - private async void BrowseWidgets_Click(object? sender, RoutedEventArgs e) - { - try - { + private async void BrowseWidgets_Click(object? sender, RoutedEventArgs e) { + try { await new WidgetBrowserDialog().ShowDialog(this); } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to open the widget browser"); } } - private void OpenPresets_Click(object? sender, RoutedEventArgs e) => OpenRelativeFolder("presets"); - private void OpenImports_Click(object? sender, RoutedEventArgs e) => OpenRelativeFolder("imports"); - private void OpenExports_Click(object? sender, RoutedEventArgs e) => OpenRelativeFolder("exports"); - private void OpenResources_Click(object? sender, RoutedEventArgs e) => OpenRelativeFolder("resources"); + private void OpenPresets_Click(object? sender, RoutedEventArgs e) { + OpenRelativeFolder("presets"); + } + + private void OpenImports_Click(object? sender, RoutedEventArgs e) { + OpenRelativeFolder("imports"); + } + + private void OpenExports_Click(object? sender, RoutedEventArgs e) { + OpenRelativeFolder("exports"); + } + + private void OpenResources_Click(object? sender, RoutedEventArgs e) { + OpenRelativeFolder("resources"); + } - private void OpenRelativeFolder(string name) - { + private void OpenRelativeFolder(string name) { string path = Path.GetFullPath($"./{name}"); - try - { + try { if (!Directory.Exists(path)) Directory.CreateDirectory(path); if (!UiHelpers.OpenFolder(path)) _logger?.LogWarning("Unable to locate {Name} folder: {Path}", name, path); } - catch - { + catch { _logger?.LogWarning("Unable to locate {Name} folder: {Path}", name, path); } } - private async void CopyRouteUrl_Click(object? sender, RoutedEventArgs e) - { - try - { - if (sender is Control { DataContext: Route route }) - { + private async void CopyRouteUrl_Click(object? sender, RoutedEventArgs e) { + try { + if (sender is Control { DataContext: Route route }) { var config = AppServices.Provider.GetRequiredService(); - await UiUtils.UiHelpers.TrySetClipboardTextAsync(route.GetRouteUrl(config)); + await UiHelpers.TrySetClipboardTextAsync(route.GetRouteUrl(config)); } } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to copy overlay URL"); } } - private void AddRoute_Click(object? sender, RoutedEventArgs e) - { - using var db = _factory.CreateDbContext(); - var newRoute = new Route - { + private void AddRoute_Click(object? sender, RoutedEventArgs e) { + using AppDbContext db = _factory.CreateDbContext(); + var newRoute = new Route { Name = $"New Overlay {Overlays.Count + 1}", Width = 1920, Height = 1080 @@ -123,14 +120,11 @@ private void AddRoute_Click(object? sender, RoutedEventArgs e) Overlays.Insert(0, newRoute); } - private void DeleteRoute_Click(object? sender, RoutedEventArgs e) - { - if (sender is Control { DataContext: Route route }) - { - using var db = _factory.CreateDbContext(); - var found = db.Routes.FirstOrDefault(r => r.Id == route.Id); - if (found != null) - { + private void DeleteRoute_Click(object? sender, RoutedEventArgs e) { + if (sender is Control { DataContext: Route route }) { + using AppDbContext db = _factory.CreateDbContext(); + Route? found = db.Routes.FirstOrDefault(r => r.Id == route.Id); + if (found != null) { db.Routes.Remove(found); db.SaveChanges(); } @@ -139,18 +133,15 @@ private void DeleteRoute_Click(object? sender, RoutedEventArgs e) } } - private void DuplicateRoute_Click(object? sender, RoutedEventArgs e) - { - if (sender is Control { DataContext: Route route }) - { - using var db = _factory.CreateDbContext(); - var dbRoute = db.Routes.Include(r => r.Widgets) + private void DuplicateRoute_Click(object? sender, RoutedEventArgs e) { + if (sender is Control { DataContext: Route route }) { + using AppDbContext db = _factory.CreateDbContext(); + Route? dbRoute = db.Routes.Include(r => r.Widgets) .ThenInclude(w => w.CssVariables).Include(r => r.Widgets) .ThenInclude(w => w.JsVariables).FirstOrDefault(r => r.Id == route.Id); if (dbRoute == null) return; - var clone = new Route - { + var clone = new Route { Name = $"{dbRoute.Name} (Copy)", Width = dbRoute.Width, Height = dbRoute.Height @@ -158,67 +149,60 @@ private void DuplicateRoute_Click(object? sender, RoutedEventArgs e) db.Routes.Add(clone); - foreach (var widget in dbRoute.Widgets.ToArray()) - { - var cloneWidget = widget.Clone(clone.Id, widget.Name, widget.Z); + foreach (Widget widget in dbRoute.Widgets.ToArray()) { + Widget cloneWidget = widget.Clone(clone.Id, widget.Name, widget.Z); db.Widgets.Add(cloneWidget); db.CssVariables.AddRange(cloneWidget.CssVariables); db.JsVariables.AddRange(cloneWidget.JsVariables); } + db.SaveChanges(); Overlays.Insert(0, clone); } } - private void RouteCard_DoubleTapped(object? sender, TappedEventArgs e) - { + private void RouteCard_DoubleTapped(object? sender, TappedEventArgs e) { if (sender is Control { DataContext: Route route }) OpenRouteEditor(route); } - private void EditRoute_Click(object? sender, RoutedEventArgs e) - { + private void EditRoute_Click(object? sender, RoutedEventArgs e) { if (sender is Control { DataContext: Route route }) OpenRouteEditor(route); } - private void ExportRoute_Click(object? sender, RoutedEventArgs e) - { + private void ExportRoute_Click(object? sender, RoutedEventArgs e) { if (sender is not Control { DataContext: Route route }) return; var dialog = new ExportOverlayDialog(route); _ = dialog.ShowDialog(this); } - private async void ImportRoute_Click(object? sender, RoutedEventArgs e) - { - var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions - { + private async void ImportRoute_Click(object? sender, RoutedEventArgs e) { + IReadOnlyList files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Import Overlay", AllowMultiple = false, - FileTypeFilter = new[] - { + FileTypeFilter = new[] { new FilePickerFileType("Subathon Manager Overlay (*.smo)") { Patterns = new[] { "*.smo" } } } }); - var file = files.FirstOrDefault(); + IStorageFile? file = files.FirstOrDefault(); if (file == null) return; await ImportRouteFromFile(file.Path.LocalPath); } - private async Task ImportRouteFromFile(string filePath) - { - if (filePath.StartsWith("http")) - { + private async Task ImportRouteFromFile(string filePath) { + if (filePath.StartsWith("http")) { using var client = new HttpClient(); - using var response = await client.GetAsync(filePath); + using HttpResponseMessage response = await client.GetAsync(filePath); response.EnsureSuccessStatusCode(); string fileName = response.Content.Headers.ContentDisposition?.FileNameStar ?? response.Content.Headers.ContentDisposition?.FileName?.Trim('"') - ?? Uri.UnescapeDataString(Path.GetFileNameWithoutExtension(new Uri(filePath).AbsolutePath)); + ?? Uri.UnescapeDataString( + Path.GetFileNameWithoutExtension(new Uri(filePath).AbsolutePath)); if (string.IsNullOrWhiteSpace(fileName)) fileName = "imported_overlay"; if (!fileName.EndsWith(".smo", StringComparison.OrdinalIgnoreCase)) fileName += ".smo"; @@ -228,11 +212,9 @@ private async Task ImportRouteFromFile(string filePath) filePath = tempFile; } - try - { - var installed = OverlayPackInstaller.Install(filePath); - if (installed == null) - { + try { + OverlayPackInstaller.InstalledOverlay? installed = OverlayPackInstaller.Install(filePath); + if (installed == null) { _logger?.LogError("Import failed: could not read overlay archive {Path}", filePath); return false; } @@ -240,26 +222,24 @@ private async Task ImportRouteFromFile(string filePath) WidgetPackPaths.InvalidateResolveCache(); WidgetPackPaths.InvalidateVersionCache(); - var manifest = installed.Manifest; - var result = await OverlayPorter.ImportExtractedRouteAsync( + OverlayManifest manifest = installed.Manifest; + OverlayPorter.ImportResult result = await OverlayPorter.ImportExtractedRouteAsync( installed.UnpackDir, _factory, manifest.Name, OverlayPackPaths.RouteName(manifest.Name, manifest.Version)); - if (result.Failed) - { + if (result.Failed) { _logger?.LogError("Import failed: {Reason}", result.FailReason); return false; } - if (!result.HasAnythingNew) - { + if (!result.HasAnythingNew) { _logger?.LogInformation("Import: everything already exists, nothing to add"); return false; } - await using var db = await _factory.CreateDbContextAsync(); + await using AppDbContext db = await _factory.CreateDbContextAsync(); if (result is { RouteIsNew: true, Route: not null }) db.Routes.Add(result.Route); @@ -273,72 +253,56 @@ private async Task ImportRouteFromFile(string filePath) if (result.NewJsVariables.Count > 0) db.JsVariables.AddRange(result.NewJsVariables); - foreach (var repointed in result.RepointedWidgets) - { - var tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == repointed.Id); + foreach (Widget repointed in result.RepointedWidgets) { + Widget? tracked = await db.Widgets.FirstOrDefaultAsync(w => w.Id == repointed.Id); tracked?.HtmlPath = repointed.HtmlPath; } - if (result is { MergedRouteId: { } mergedId, MergedRouteName: { } mergedName }) - { - var existing = await db.Routes.FirstOrDefaultAsync(r => r.Id == mergedId); + if (result is { MergedRouteId: { } mergedId, MergedRouteName: { } mergedName }) { + Route? existing = await db.Routes.FirstOrDefaultAsync(r => r.Id == mergedId); existing?.Name = mergedName; } await db.SaveChangesAsync(); - if (result.RepointedWidgets.Count > 0) - { + if (result.RepointedWidgets.Count > 0) { var helper = new WidgetEntityHelper(_factory, null); - foreach (var repointed in result.RepointedWidgets) - { + foreach (Widget repointed in result.RepointedWidgets) { helper.SyncCssVariables(repointed); helper.SyncJsVariables(repointed); } } + await Dispatcher.UIThread.InvokeAsync(LoadRoutes); return true; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Import of overlay failed"); return false; } } - public void CloseEditor() - { - if (_editWindow != null) - { - try - { - _editWindow.Close(); - } finally {/**/} - } + public void CloseEditor() { + if (_editWindow != null) _editWindow.Close(); } - private EditRouteWindow? _editWindow; - - internal void OpenRouteEditor(Route route) - { - if (_editWindow != null) - { - if (_editWindow.EditorRouteId == route.Id) - { + internal void OpenRouteEditor(Route route) { + if (_editWindow != null) { + if (_editWindow.EditorRouteId == route.Id) { _editWindow.Activate(); return; } + _editWindow.Close(); } _editWindow = new EditRouteWindow(route.Id); - _editWindow.Closed += (_, _) => - { + _editWindow.Closed += (_, _) => { LoadRoutes(); _editWindow = null; }; - + _editWindow.Show(); _editWindow.Activate(); } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/MainWindow.Stats.cs b/SubathonManager.UI/MainWindow.Stats.cs index 8703ca66..69c39668 100644 --- a/SubathonManager.UI/MainWindow.Stats.cs +++ b/SubathonManager.UI/MainWindow.Stats.cs @@ -1,39 +1,35 @@ using Avalonia.Input; using Microsoft.EntityFrameworkCore; using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; +using SubathonManager.Data; namespace SubathonManager.UI; -public partial class MainWindow -{ - private void TimerHoverArea_PointerEntered(object? sender, PointerEventArgs e) - { +public partial class MainWindow { + private void TimerHoverArea_PointerEntered(object? sender, PointerEventArgs e) { RefreshStatsPanel(); StatsPopup.IsOpen = true; } - private void TimerHoverArea_PointerExited(object? sender, PointerEventArgs e) - { + private void TimerHoverArea_PointerExited(object? sender, PointerEventArgs e) { StatsPopup.IsOpen = false; } - private void RefreshStatsPanel() - { - using var db = _factory.CreateDbContext(); - var subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); + private void RefreshStatsPanel() { + using AppDbContext db = _factory.CreateDbContext(); + SubathonData? subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); if (subathon == null) return; TimeSpan elapsed = TimeSpan.FromMilliseconds(subathon.MillisecondsElapsed); TimeSpan totalAccumulated = TimeSpan.FromMilliseconds(subathon.MillisecondsCumulative); StatsElapsedTime.Text = FormatTimeSpan(elapsed); - if (subathon.IsSubathonReversed()) - { + if (subathon.IsSubathonReversed()) { StatsTotalAccumulated.IsVisible = false; StatsAccu.IsVisible = false; } - else - { + else { StatsTotalAccumulated.IsVisible = true; StatsAccu.IsVisible = true; StatsTotalAccumulated.Text = FormatTimeSpan(totalAccumulated); @@ -45,12 +41,11 @@ private void RefreshStatsPanel() StatsTotalEvents.Text = $"{eventCount:N0}"; } - private static string FormatTimeSpan(TimeSpan ts) - { + private static string FormatTimeSpan(TimeSpan ts) { if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d {ts.Hours}h {ts.Minutes}m"; if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h {ts.Minutes}m {ts.Seconds}s"; return $"{ts.Minutes}m {ts.Seconds}s"; } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/MainWindow.SubathonTimer.cs b/SubathonManager.UI/MainWindow.SubathonTimer.cs index bb74a9f1..7074859f 100644 --- a/SubathonManager.UI/MainWindow.SubathonTimer.cs +++ b/SubathonManager.UI/MainWindow.SubathonTimer.cs @@ -1,3 +1,4 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Layout; @@ -12,66 +13,60 @@ using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Data; -using SubathonManager.UI.UiUtils; using SubathonManager.UI.Services; +using SubathonManager.UI.UiUtils; namespace SubathonManager.UI; -public partial class MainWindow -{ +public partial class MainWindow { // ReSharper disable once InconsistentNaming internal readonly IDbContextFactory _factory = AppServices.Provider.GetRequiredService>(); private DateTime? _lastUpdatedTimerAt; - private void InitHome() - { + private void InitHome() { SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; Closed += (_, _) => SubathonEvents.SubathonDataUpdate -= OnSubathonDataUpdate; - var currencies = ServiceManager.Events.ValidEventCurrencies().OrderBy(x => x).ToList(); + List currencies = ServiceManager.Events.ValidEventCurrencies().OrderBy(x => x).ToList(); AdjustCurrencyBox.ItemsSource = currencies; var config = AppServices.Provider.GetRequiredService(); AdjustCurrencyBox.Text = config.Get("Currency", "Primary", "USD")?.Trim().ToUpperInvariant() ?? "USD"; - AdjustCurrencyBox.GotFocus += (_, _) => - { - if ((AdjustCurrencyBox.ItemsSource as IEnumerable)?.Count() is null or <= 1) - { - var current = AdjustCurrencyBox.Text; + AdjustCurrencyBox.GotFocus += (_, _) => { + if ((AdjustCurrencyBox.ItemsSource as IEnumerable)?.Count() is null or <= 1) { + string? current = AdjustCurrencyBox.Text; AdjustCurrencyBox.ItemsSource = ServiceManager.Events.ValidEventCurrencies().OrderBy(x => x).ToList(); AdjustCurrencyBox.Text = current; } + AdjustCurrencyBox.IsDropDownOpen = true; }; } - private void OnSubathonDataUpdate(SubathonData subathon, DateTime time) - { + private void OnSubathonDataUpdate(SubathonData subathon, DateTime time) { UpdateTimerValue(subathon, time); UpdateMultiplierUi(subathon, time); } - private void UpdateTimerValue(SubathonData subathon, DateTime time) - { + private void UpdateTimerValue(SubathonData subathon, DateTime time) { if (_lastUpdatedTimerAt != null && time <= _lastUpdatedTimerAt) return; - Dispatcher.UIThread.Post(() => - { + Dispatcher.UIThread.Post(() => { double moneySum = subathon.GetRoundedMoneySumWithCents(); TimerValue.Text = subathon.TimeRemainingRounded().ToString(); _lastUpdatedTimerAt = time; - var pauseGlyph = subathon.IsPaused ? "Play16" : "Pause16"; + string pauseGlyph = subathon.IsPaused ? "Play16" : "Pause16"; if (PauseIcon.Glyph != pauseGlyph) PauseIcon.Glyph = pauseGlyph; - var lockGlyph = subathon.IsLocked ? "LockOpen16" : "LockClosed16"; + string lockGlyph = subathon.IsLocked ? "LockOpen16" : "LockClosed16"; if (LockIcon.Glyph != lockGlyph) LockIcon.Glyph = lockGlyph; if (PointsValue.Text != $"{subathon.Points:N0} Pts") PointsValue.Text = $"{subathon.Points:N0} Pts"; - var moneyText = $"{subathon.Currency} {moneySum:N2}".Trim(); + string moneyText = $"{subathon.Currency} {moneySum:N2}".Trim(); if (MoneyValue.Text != moneyText) MoneyValue.Text = moneyText; if (LockStatus.IsVisible != subathon.IsLocked) LockStatus.IsVisible = subathon.IsLocked; @@ -79,50 +74,47 @@ private void UpdateTimerValue(SubathonData subathon, DateTime time) ToolTip.SetTip(TogglePauseTimerBtn, subathon.IsPaused ? "Resume" : "Pause"); ToolTip.SetTip(ToggleLockTimerBtn, subathon.IsLocked ? "Unlock" : "Lock"); - var capGlyph = subathon.CapDateTime.HasValue && subathon.CapDateTime > DateTime.Now - ? "Flag20" : "FlagOff20"; + string capGlyph = subathon.CapDateTime.HasValue && subathon.CapDateTime > DateTime.Now + ? "Flag20" + : "FlagOff20"; if (CapIcon.Glyph != capGlyph) CapIcon.Glyph = capGlyph; if (subathon.IsCapInEffect()) CapIcon.Foreground = Brushes.Orange; else - CapIcon.SetDynamicResource(FluentIcons.Avalonia.SymbolIcon.ForegroundProperty, "TextFillColorPrimaryBrush"); + CapIcon.SetDynamicResource(ForegroundProperty, "TextFillColorPrimaryBrush"); }); } - private async void StartNewSubathon_Click(object? sender, RoutedEventArgs e) - { - var panel = new StackPanel { Orientation = Orientation.Vertical, HorizontalAlignment = HorizontalAlignment.Center }; - panel.Children.Add(new TextBlock - { + private async void StartNewSubathon_Click(object? sender, RoutedEventArgs e) { + var panel = new StackPanel + { Orientation = Orientation.Vertical, HorizontalAlignment = HorizontalAlignment.Center }; + panel.Children.Add(new TextBlock { TextWrapping = TextWrapping.Wrap, Width = 320, - Margin = new global::Avalonia.Thickness(4, 4, 4, 12), + Margin = new Thickness(4, 4, 4, 12), Text = "Confirm deleting the current subathon (time, points, events) and starting a new one?" }); - var initialTimeBox = new TextBox { Text = "8h", Width = 140, Margin = new global::Avalonia.Thickness(2, 4, 0, 0) }; + var initialTimeBox = new TextBox { Text = "8h", Width = 140, Margin = new Thickness(2, 4, 0, 0) }; var row = new StackPanel { Orientation = Orientation.Horizontal }; - row.Children.Add(new TextBlock - { + row.Children.Add(new TextBlock { Text = "Initial Time:", VerticalAlignment = VerticalAlignment.Center, - Margin = new global::Avalonia.Thickness(16, 4, 8, 0) + Margin = new Thickness(16, 4, 8, 0) }); row.Children.Add(initialTimeBox); panel.Children.Add(row); - var reverseCb = new CheckBox - { + var reverseCb = new CheckBox { Content = "Reverse Subathon?", IsChecked = false, - Margin = new global::Avalonia.Thickness(4, 12, 4, 8) + Margin = new Thickness(4, 12, 4, 8) }; ToolTip.SetTip(reverseCb, "When enabled, time will tick up, and events reduce the timer"); panel.Children.Add(reverseCb); - var dialog = new FAContentDialog - { + var dialog = new FAContentDialog { Title = "Start New Subathon", PrimaryButtonText = "Confirm", CloseButtonText = "Cancel", @@ -131,7 +123,7 @@ private async void StartNewSubathon_Click(object? sender, RoutedEventArgs e) if (await dialog.ShowAsync() != FAContentDialogResult.Primary) return; await Task.Run(async () => AppDbContext.DisableAllTimers(await _factory.CreateDbContextAsync())); - await using var db = await _factory.CreateDbContextAsync(); + await using AppDbContext db = await _factory.CreateDbContextAsync(); var subathon = new SubathonData(); TimeSpan initial = Utils.ParseDurationString(initialTimeBox.Text); @@ -150,21 +142,24 @@ private async void StartNewSubathon_Click(object? sender, RoutedEventArgs e) SubathonEvents.RaiseSubathonEventsDeleted([]); } - private void AddTime_Click(object? sender, RoutedEventArgs e) => AdjustSubathonTimeBy(1); - private void SubtractTime_Click(object? sender, RoutedEventArgs e) => AdjustSubathonTimeBy(-1); + private void AddTime_Click(object? sender, RoutedEventArgs e) { + AdjustSubathonTimeBy(1); + } - private void SetTime_Click(object? sender, RoutedEventArgs e) - { + private void SubtractTime_Click(object? sender, RoutedEventArgs e) { + AdjustSubathonTimeBy(-1); + } + + private void SetTime_Click(object? sender, RoutedEventArgs e) { TimeSpan timeToSet = Utils.ParseDurationString(AdjustSubathonTime.Text ?? ""); if (timeToSet <= TimeSpan.Zero) return; - using var db = _factory.CreateDbContext(); - var subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); + using AppDbContext db = _factory.CreateDbContext(); + SubathonData? subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); if (subathon == null) return; string rawText = (AdjustSubathonTime.Text ?? "").Replace(" ", ""); - RaiseCommand(new SubathonEvent - { + RaiseCommand(new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Value = $"{SubathonCommandType.SetTime} {rawText}", SecondsValue = timeToSet.TotalSeconds, @@ -176,15 +171,13 @@ private void SetTime_Click(object? sender, RoutedEventArgs e) }); } - private void AdjustSubathonTimeBy(int direction) - { + private void AdjustSubathonTimeBy(int direction) { TimeSpan timeToAdjust = Utils.ParseDurationString(AdjustSubathonTime.Text ?? ""); if (timeToAdjust == TimeSpan.Zero) return; string rawText = (AdjustSubathonTime.Text ?? "").Replace(" ", ""); SubathonCommandType cmd = direction > 0 ? SubathonCommandType.AddTime : SubathonCommandType.SubtractTime; - RaiseCommand(new SubathonEvent - { + RaiseCommand(new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = cmd, Value = $"{cmd} {rawText}", @@ -196,15 +189,18 @@ private void AdjustSubathonTimeBy(int direction) }); } - private void AddMoney_Click(object? sender, RoutedEventArgs e) => AdjustSubathonMoneyBy(1); - private void SubtractMoney_Click(object? sender, RoutedEventArgs e) => AdjustSubathonMoneyBy(-1); + private void AddMoney_Click(object? sender, RoutedEventArgs e) { + AdjustSubathonMoneyBy(1); + } - private void AdjustSubathonMoneyBy(int direction) - { - if (!double.TryParse(AdjustSubathonMoney.Text, out var parsedVal) || parsedVal <= 0) return; + private void SubtractMoney_Click(object? sender, RoutedEventArgs e) { + AdjustSubathonMoneyBy(-1); + } + + private void AdjustSubathonMoneyBy(int direction) { + if (!double.TryParse(AdjustSubathonMoney.Text, out double parsedVal) || parsedVal <= 0) return; SubathonCommandType cmd = direction > 0 ? SubathonCommandType.AddMoney : SubathonCommandType.SubtractMoney; - RaiseCommand(new SubathonEvent - { + RaiseCommand(new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = cmd, Value = AdjustSubathonMoney.Text ?? "", @@ -217,19 +213,22 @@ private void AdjustSubathonMoneyBy(int direction) }); } - private void AddPoints_Click(object? sender, RoutedEventArgs e) => AdjustSubathonPointsBy(1); - private void SubtractPoints_Click(object? sender, RoutedEventArgs e) => AdjustSubathonPointsBy(-1); + private void AddPoints_Click(object? sender, RoutedEventArgs e) { + AdjustSubathonPointsBy(1); + } + + private void SubtractPoints_Click(object? sender, RoutedEventArgs e) { + AdjustSubathonPointsBy(-1); + } - private void SetPoints_Click(object? sender, RoutedEventArgs e) - { - if (!int.TryParse(AdjustSubathonPoints.Text, out var parsedInt) || parsedInt < 0) return; + private void SetPoints_Click(object? sender, RoutedEventArgs e) { + if (!int.TryParse(AdjustSubathonPoints.Text, out int parsedInt) || parsedInt < 0) return; - using var db = _factory.CreateDbContext(); - var subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); + using AppDbContext db = _factory.CreateDbContext(); + SubathonData? subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); if (subathon == null) return; - RaiseCommand(new SubathonEvent - { + RaiseCommand(new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Value = $"{SubathonCommandType.SetPoints} {AdjustSubathonPoints.Text}", Command = SubathonCommandType.SetPoints, @@ -241,12 +240,10 @@ private void SetPoints_Click(object? sender, RoutedEventArgs e) }); } - private void AdjustSubathonPointsBy(int direction) - { - if (!int.TryParse(AdjustSubathonPoints.Text, out var parsedInt) || parsedInt <= 0) return; + private void AdjustSubathonPointsBy(int direction) { + if (!int.TryParse(AdjustSubathonPoints.Text, out int parsedInt) || parsedInt <= 0) return; SubathonCommandType cmd = direction > 0 ? SubathonCommandType.AddPoints : SubathonCommandType.SubtractPoints; - RaiseCommand(new SubathonEvent - { + RaiseCommand(new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = cmd, Value = $"{cmd} {AdjustSubathonPoints.Text}", @@ -258,14 +255,12 @@ private void AdjustSubathonPointsBy(int direction) }); } - private void TogglePauseSubathon_Click(object? sender, RoutedEventArgs e) - { - using var db = _factory.CreateDbContext(); - var subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); + private void TogglePauseSubathon_Click(object? sender, RoutedEventArgs e) { + using AppDbContext db = _factory.CreateDbContext(); + SubathonData? subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); if (subathon == null) return; SubathonCommandType cmd = subathon.IsPaused ? SubathonCommandType.Resume : SubathonCommandType.Pause; - RaiseCommand(new SubathonEvent - { + RaiseCommand(new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = cmd, Value = $"{cmd}", @@ -277,14 +272,12 @@ private void TogglePauseSubathon_Click(object? sender, RoutedEventArgs e) }); } - private void ToggleLockSubathon_Click(object? sender, RoutedEventArgs e) - { - using var db = _factory.CreateDbContext(); - var subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); + private void ToggleLockSubathon_Click(object? sender, RoutedEventArgs e) { + using AppDbContext db = _factory.CreateDbContext(); + SubathonData? subathon = db.SubathonDatas.AsNoTracking().FirstOrDefault(s => s.IsActive); if (subathon == null) return; SubathonCommandType cmd = subathon.IsLocked ? SubathonCommandType.Unlock : SubathonCommandType.Lock; - RaiseCommand(new SubathonEvent - { + RaiseCommand(new SubathonEvent { EventTimestamp = DateTime.Now - TimeSpan.FromSeconds(1), Command = cmd, Value = $"{cmd}", @@ -296,12 +289,12 @@ private void ToggleLockSubathon_Click(object? sender, RoutedEventArgs e) }); } - private void RaiseCommand(SubathonEvent subathonEvent) - { + private void RaiseCommand(SubathonEvent subathonEvent) { _lastUpdatedTimerAt = null; SubathonEvents.RaiseSubathonEventCreated(subathonEvent); } - private void SendRefreshRequest_Click(object? sender, RoutedEventArgs e) - => OverlayEvents.RaiseOverlayRefreshAllRequested(); -} + private void SendRefreshRequest_Click(object? sender, RoutedEventArgs e) { + OverlayEvents.RaiseOverlayRefreshAllRequested(); + } +} \ No newline at end of file diff --git a/SubathonManager.UI/MainWindow.Telemetry.cs b/SubathonManager.UI/MainWindow.Telemetry.cs index f2a75ce0..2424c099 100644 --- a/SubathonManager.UI/MainWindow.Telemetry.cs +++ b/SubathonManager.UI/MainWindow.Telemetry.cs @@ -9,48 +9,44 @@ namespace SubathonManager.UI; -public partial class MainWindow -{ - private async Task MaybeShowTelemetryPromptAsync() - { - try - { +public partial class MainWindow { + private async Task MaybeShowTelemetryPromptAsync() { + try { var config = AppServices.Provider.GetRequiredService(); - var installId = config.Get("Telemetry", "InstallId", ""); + string? installId = config.Get("Telemetry", "InstallId"); if (!string.IsNullOrWhiteSpace(installId)) return; var panel = new StackPanel { Orientation = Orientation.Vertical, Width = 340 }; - panel.Children.Add(new TextBlock - { + panel.Children.Add(new TextBlock { TextWrapping = TextWrapping.Wrap, Margin = new Thickness(4, 4, 4, 12), Text = "Would you like to send anonymous usage data to help guide development?\n\n" + "Only information on which integrations are active is collected - no usernames, keys, or personal information of any kind." }); - var checkBox = new CheckBox - { + var checkBox = new CheckBox { Content = "Enable anonymous data collection", IsChecked = true, Margin = new Thickness(4, 0, 4, 4) }; panel.Children.Add(checkBox); - var dialog = new FAContentDialog - { + var dialog = new FAContentDialog { Title = "Help Improve Subathon Manager", PrimaryButtonText = "Confirm", CloseButtonText = "No Thanks", Content = panel }; - var result = await dialog.ShowAsync(); + FAContentDialogResult result = await dialog.ShowAsync(); bool enabled = result == FAContentDialogResult.Primary && (checkBox.IsChecked ?? false); config.SetBool("Telemetry", "Enabled", enabled); config.Set("Telemetry", "InstallId", Guid.NewGuid().ToString()); config.Save(); } - catch { /**/ } + catch { + /**/ + } } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/MainWindow.axaml b/SubathonManager.UI/MainWindow.axaml index c01ca651..f27842d7 100644 --- a/SubathonManager.UI/MainWindow.axaml +++ b/SubathonManager.UI/MainWindow.axaml @@ -23,29 +23,29 @@ - + - + Text="Use the Overlays tab to manage your overlays and widgets, the Goals tab to setup your goals, and Settings to configure everything else!" /> + - + - + - + - + + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + Margin="0,0,0,10" /> - + Foreground="{DynamicResource TextFillColorSecondaryBrush}" /> + + Foreground="{DynamicResource TextFillColorSecondaryBrush}" /> - - - + + + TextChanged="CapTime_TextChanged" /> - + - - - + + VerticalAlignment="Center" FontSize="44" Margin="0,6,16,4" /> - - - - - - - - - + + + + + + + + @@ -131,84 +159,123 @@ - - - - - - + - + - - + + - - - - + + + - - + + - + - - + + - - - - - + + - - - - - + - + @@ -219,41 +286,42 @@ - + - + @@ -268,35 +336,44 @@ Background="{DynamicResource UiBrushControlBackground}" DoubleTapped="RouteCard_DoubleTapped"> - - + + + VerticalAlignment="Top" HorizontalAlignment="Right" + Margin="10,0,0,0"> - @@ -307,40 +384,40 @@ + Margin="6,4,2,4" VerticalAlignment="Stretch" /> + Text="Create and manage your overlays in the list to the left. Copy the URL to a browser source in OBS and set its resolution to match. Double click or press the edit button to configure your overlay." /> @@ -353,42 +430,42 @@ + IsVisible="{Binding ObsConnected, RelativeSource={RelativeSource AncestorType=Window}}" /> - + - + - + - + @@ -397,15 +474,15 @@ Background="Transparent" BorderThickness="0" Cursor="Hand" Padding="6,0" VerticalAlignment="Center"> + Foreground="{DynamicResource TextFillColorSecondaryBrush}" /> - + \ No newline at end of file diff --git a/SubathonManager.UI/MainWindow.axaml.cs b/SubathonManager.UI/MainWindow.axaml.cs index 9a01f2d9..e3c45a2c 100644 --- a/SubathonManager.UI/MainWindow.axaml.cs +++ b/SubathonManager.UI/MainWindow.axaml.cs @@ -5,20 +5,18 @@ using Microsoft.Extensions.Logging; using SubathonManager.Core; using SubathonManager.Data.Widgets; -using SubathonManager.UI.UiUtils; using SubathonManager.UI.Services; +using SubathonManager.UI.UiUtils; using ActivationKind = SubathonManager.UI.Platform.ActivationKind; namespace SubathonManager.UI; -public partial class MainWindow : Window -{ +public partial class MainWindow : Window { private readonly string _fullVersion = ServiceManager.AppVersion ?? string.Empty; - public MainWindow() - { + public MainWindow() { InitializeComponent(); - UiUtils.WindowIcons.Apply(this); + WindowIcons.Apply(this); VersionText.Text = _fullVersion.Length > 8 ? _fullVersion[..8] + "‥" : _fullVersion; ToolTip.SetTip(VersionLabelBtn, _fullVersion); @@ -29,24 +27,20 @@ public MainWindow() InitHome(); InitOverlays(); - Loaded += async (_, _) => - { + Loaded += async (_, _) => { await MaybeShowTelemetryPromptAsync(); await ImportPendingOverlayAsync(); await CollectPendingWidgetPackAsync(); }; } - private async void CopyVersion_Click(object? sender, RoutedEventArgs e) - { + private async void CopyVersion_Click(object? sender, RoutedEventArgs e) { if (string.IsNullOrEmpty(_fullVersion)) return; await UiHelpers.TrySetClipboardTextAsync(_fullVersion); } - - public void HandlePendingActivation(ActivationKind kind) - { - switch (kind) - { + + public void HandlePendingActivation(ActivationKind kind) { + switch (kind) { case ActivationKind.SmoFile: _ = ImportPendingOverlayAsync(); break; @@ -56,9 +50,8 @@ public void HandlePendingActivation(ActivationKind kind) } } - private async Task ImportPendingOverlayAsync() - { - var path = Utils.PendingOverlayImportPath; + private async Task ImportPendingOverlayAsync() { + string? path = Utils.PendingOverlayImportPath; if (string.IsNullOrWhiteSpace(path)) return; MainWindowTabs.SelectedItem = OverlayTabItem; @@ -66,23 +59,20 @@ private async Task ImportPendingOverlayAsync() await ImportRouteFromFile(path); Utils.PendingOverlayImportPath = null; } - - private async Task CollectPendingWidgetPackAsync() - { - var path = Utils.PendingWidgetPackImportPath; + + private async Task CollectPendingWidgetPackAsync() { + string? path = Utils.PendingWidgetPackImportPath; Utils.PendingWidgetPackImportPath = null; if (string.IsNullOrWhiteSpace(path)) return; - if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - var downloaded = await DownloadWidgetPackAsync(path); + if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { + string? downloaded = await DownloadWidgetPackAsync(path); if (downloaded == null) return; path = downloaded; } - var editor = FindOpenOverlayEditor(); - if (editor != null) - { + EditRouteWindow? editor = FindOpenOverlayEditor(); + if (editor != null) { editor.Activate(); _ = editor.AddWidgetPackAsync(path); return; @@ -92,7 +82,7 @@ private async Task CollectPendingWidgetPackAsync() StringComparison.OrdinalIgnoreCase); string? installed = isCollection - ? (WidgetCollectionInstaller.InstallAll(path) != null ? path : null) + ? WidgetCollectionInstaller.InstallAll(path) != null ? path : null : WidgetPackInstaller.DropIntoImports(path); if (installed == null) @@ -101,12 +91,10 @@ private async Task CollectPendingWidgetPackAsync() _logger?.LogDebug("Collected widget package {Path}", installed); } - private async Task DownloadWidgetPackAsync(string url) - { - try - { + private async Task DownloadWidgetPackAsync(string url) { + try { using var client = new HttpClient(); - using var response = await client.GetAsync(url); + using HttpResponseMessage response = await client.GetAsync(url); response.EnsureSuccessStatusCode(); string fileName = response.Content.Headers.ContentDisposition?.FileNameStar @@ -114,7 +102,7 @@ private async Task CollectPendingWidgetPackAsync() ?? Uri.UnescapeDataString(Path.GetFileName(new Uri(url).AbsolutePath)); if (string.IsNullOrWhiteSpace(fileName)) fileName = "imported_widget"; - foreach (var invalid in Path.GetInvalidFileNameChars()) + foreach (char invalid in Path.GetInvalidFileNameChars()) fileName = fileName.Replace(invalid, '_'); if (!fileName.EndsWith(WidgetPackPaths.PackExtension, StringComparison.OrdinalIgnoreCase) && @@ -126,19 +114,17 @@ private async Task CollectPendingWidgetPackAsync() _logger?.LogDebug("Downloaded widget package {Url} to {Path}", url, tempFile); return tempFile; } - catch (Exception ex) - { + catch (Exception ex) { _logger?.LogError(ex, "Failed to download widget package {Url}", url); return null; } } - private static EditRouteWindow? FindOpenOverlayEditor() - { + private static EditRouteWindow? FindOpenOverlayEditor() { if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return null; - var editors = desktop.Windows.OfType().ToList(); + List editors = desktop.Windows.OfType().ToList(); return editors.FirstOrDefault(w => w.IsActive) ?? editors.FirstOrDefault(); } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/Platform/IPlatformIntegration.cs b/SubathonManager.UI/Platform/IPlatformIntegration.cs index 56f544c3..3d8053e5 100644 --- a/SubathonManager.UI/Platform/IPlatformIntegration.cs +++ b/SubathonManager.UI/Platform/IPlatformIntegration.cs @@ -1,7 +1,6 @@ namespace SubathonManager.UI.Platform; -public interface IPlatformIntegration -{ +public interface IPlatformIntegration { void RegisterFileAssociations(); bool TryAcquireSingleInstance(string[] args); event Action? ActivationReceived; @@ -10,10 +9,9 @@ public interface IPlatformIntegration public readonly record struct ActivationRequest(ActivationKind Kind, string Payload); -public enum ActivationKind -{ +public enum ActivationKind { Unknown, SmoFile, SmwFile, OAuth -} +} \ No newline at end of file diff --git a/SubathonManager.UI/Platform/PlatformIntegrationBase.cs b/SubathonManager.UI/Platform/PlatformIntegrationBase.cs index c48b0cc0..aa424ae3 100644 --- a/SubathonManager.UI/Platform/PlatformIntegrationBase.cs +++ b/SubathonManager.UI/Platform/PlatformIntegrationBase.cs @@ -3,8 +3,7 @@ namespace SubathonManager.UI.Platform; -public abstract class PlatformIntegrationBase : IPlatformIntegration -{ +public abstract class PlatformIntegrationBase : IPlatformIntegration { private const string MutexName = @"Global\SubathonManager_SingleInstanceMutex"; private const string PipeName = "SubathonManager_SingleInstance_Pipe"; @@ -15,13 +14,11 @@ public abstract class PlatformIntegrationBase : IPlatformIntegration public abstract void RegisterFileAssociations(); - public bool TryAcquireSingleInstance(string[] args) - { + public bool TryAcquireSingleInstance(string[] args) { _mutex = new Mutex(true, MutexName, out bool createdNew); - if (!createdNew) - { - var request = ProtocolParser.Parse(args); + if (!createdNew) { + ActivationRequest request = ProtocolParser.Parse(args); if (request.Kind != ActivationKind.Unknown) ForwardToPrimary(request); else @@ -33,31 +30,46 @@ public bool TryAcquireSingleInstance(string[] args) return true; } - private void ForwardToPrimary(ActivationRequest request) - { - try - { + public void Release() { + try { + _pipeCts?.Cancel(); + } + catch { + /**/ + } + + try { + _mutex?.ReleaseMutex(); + } + catch { + /**/ + } + + _mutex?.Dispose(); + _mutex = null; + } + + private void ForwardToPrimary(ActivationRequest request) { + try { using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); client.Connect(2000); var payload = $"{(int)request.Kind}\n{request.Payload}"; - var bytes = Encoding.UTF8.GetBytes(payload); + byte[] bytes = Encoding.UTF8.GetBytes(payload); client.Write(bytes, 0, bytes.Length); client.Flush(); } - catch {/* */ } + catch { + /* */ + } } - private void StartPipeServer() - { + private void StartPipeServer() { _pipeCts = new CancellationTokenSource(); - var token = _pipeCts.Token; + CancellationToken token = _pipeCts.Token; - Task.Run(async () => - { + Task.Run(async () => { while (!token.IsCancellationRequested) - { - try - { + try { using var server = new NamedPipeServerStream( PipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); @@ -65,32 +77,23 @@ private void StartPipeServer() await server.WaitForConnectionAsync(token); using var reader = new StreamReader(server, Encoding.UTF8); - var text = await reader.ReadToEndAsync(token); - var parts = text.Split('\n', 2); + string text = await reader.ReadToEndAsync(token); + string[] parts = text.Split('\n', 2); if (parts.Length == 0) continue; if (!int.TryParse(parts[0], out int kindInt)) continue; var kind = (ActivationKind)kindInt; - var payload = parts.Length > 1 ? parts[1] : string.Empty; + string payload = parts.Length > 1 ? parts[1] : string.Empty; ActivationReceived?.Invoke(new ActivationRequest(kind, payload)); } - catch (OperationCanceledException) - { + catch (OperationCanceledException) { break; } - catch - { /**/ } - } + catch { + /**/ + } }, token); } - - public void Release() - { - try { _pipeCts?.Cancel(); } catch { /**/ } - try { _mutex?.ReleaseMutex(); } catch { /**/ } - _mutex?.Dispose(); - _mutex = null; - } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/Platform/PosixPlatformIntegration.cs b/SubathonManager.UI/Platform/PosixPlatformIntegration.cs index 4701a69e..68439173 100644 --- a/SubathonManager.UI/Platform/PosixPlatformIntegration.cs +++ b/SubathonManager.UI/Platform/PosixPlatformIntegration.cs @@ -3,8 +3,7 @@ namespace SubathonManager.UI.Platform; -public sealed class PosixPlatformIntegration : PlatformIntegrationBase -{ +public sealed class PosixPlatformIntegration : PlatformIntegrationBase { private const string SchemeMime = "x-scheme-handler/subathonmanager"; private const string OverlayMime = "application/x-subathonmanager-overlay"; private const string WidgetMime = "application/x-subathonmanager-widget"; @@ -15,21 +14,20 @@ public sealed class PosixPlatformIntegration : PlatformIntegrationBase private static readonly int[] IconSizes = [48, 64, 128, 256]; - public override void RegisterFileAssociations() - { + public override void RegisterFileAssociations() { if (!OperatingSystem.IsLinux()) return; - try - { + try { RegisterLinux(); } - catch {/**/} + catch { + /**/ + } } - private static void RegisterLinux() - { - var exePath = Environment.ProcessPath; + private static void RegisterLinux() { + string? exePath = Environment.ProcessPath; if (string.IsNullOrWhiteSpace(exePath)) return; @@ -48,12 +46,10 @@ private static void RegisterLinux() bool iconInstalled = InstallHicolorIcons(Path.GetDirectoryName(exePath)!, dataHome); string iconLine; - if (iconInstalled) - { + if (iconInstalled) { iconLine = $"Icon={IconName}\n"; } - else - { + else { string iconFile = Path.Combine(Path.GetDirectoryName(exePath)!, "Assets", "icon.png"); iconLine = File.Exists(iconFile) ? $"Icon={iconFile}\n" : string.Empty; } @@ -107,14 +103,12 @@ private static void RegisterLinux() Run("xdg-mime", "default", DesktopFileName, CollectionMime); } - private static bool InstallHicolorIcons(string appDir, string dataHome) - { + private static bool InstallHicolorIcons(string appDir, string dataHome) { string hicolor = Path.Combine(dataHome, "icons", "hicolor"); - bool anyPresent = false; - bool anyChanged = false; + var anyPresent = false; + var anyChanged = false; - foreach (int size in IconSizes) - { + foreach (int size in IconSizes) { string source = Path.Combine(appDir, "Assets", $"icon_{size}.png"); if (!File.Exists(source)) continue; @@ -122,17 +116,18 @@ private static bool InstallHicolorIcons(string appDir, string dataHome) string targetDir = Path.Combine(hicolor, $"{size}x{size}", "apps"); string target = Path.Combine(targetDir, $"{IconName}.png"); - try - { + try { Directory.CreateDirectory(targetDir); - if (!FilesEqual(source, target)) - { + if (!FilesEqual(source, target)) { File.Copy(source, target, true); anyChanged = true; } + anyPresent = true; } - catch { /**/ } + catch { + /**/ + } } if (anyChanged) @@ -141,47 +136,42 @@ private static bool InstallHicolorIcons(string appDir, string dataHome) return anyPresent; } - private static bool FilesEqual(string a, string b) - { - try - { + private static bool FilesEqual(string a, string b) { + try { var infoA = new FileInfo(a); var infoB = new FileInfo(b); if (!infoB.Exists || infoA.Length != infoB.Length) return false; return File.ReadAllBytes(a).AsSpan().SequenceEqual(File.ReadAllBytes(b)); } - catch - { + catch { return false; } } - private static bool WriteIfChanged(string path, string content) - { + private static bool WriteIfChanged(string path, string content) { if (File.Exists(path) && File.ReadAllText(path) == content) return false; File.WriteAllText(path, content, new UTF8Encoding(false)); return true; } - private static void Run(string fileName, params string[] args) - { - try - { - var psi = new ProcessStartInfo(fileName) - { + private static void Run(string fileName, params string[] args) { + try { + var psi = new ProcessStartInfo(fileName) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; - foreach (var a in args) + foreach (string a in args) psi.ArgumentList.Add(a); - using var proc = Process.Start(psi); + using Process? proc = Process.Start(psi); proc?.WaitForExit(4000); } - catch { /**/ } // nothing took effect, oh well + catch { + /**/ + } // nothing took effect, oh well } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/Platform/ProtocolParser.cs b/SubathonManager.UI/Platform/ProtocolParser.cs index 69980b0c..66906a0c 100644 --- a/SubathonManager.UI/Platform/ProtocolParser.cs +++ b/SubathonManager.UI/Platform/ProtocolParser.cs @@ -1,29 +1,26 @@ +using System.Collections.Specialized; using System.Web; using SubathonManager.Core; using SubathonManager.Core.Objects; namespace SubathonManager.UI.Platform; -public static class ProtocolParser -{ - public static ActivationRequest Parse(string[] args) - { +public static class ProtocolParser { + public static ActivationRequest Parse(string[] args) { if (args.Length == 0) return new ActivationRequest(ActivationKind.Unknown, string.Empty); - var arg = args[0]; - + string arg = args[0]; + if (arg.EndsWith(".smo", StringComparison.OrdinalIgnoreCase) && File.Exists(arg) && - !arg.Contains("subathonmanager://oauth", StringComparison.CurrentCultureIgnoreCase)) - { + !arg.Contains("subathonmanager://oauth", StringComparison.CurrentCultureIgnoreCase)) { Utils.PendingOverlayImportPath = arg; return new ActivationRequest(ActivationKind.SmoFile, arg); } if ((arg.EndsWith(".smw", StringComparison.OrdinalIgnoreCase) || - arg.EndsWith(".smwc", StringComparison.OrdinalIgnoreCase)) && File.Exists(arg)) - { + arg.EndsWith(".smwc", StringComparison.OrdinalIgnoreCase)) && File.Exists(arg)) { Utils.PendingWidgetPackImportPath = arg; return new ActivationRequest(ActivationKind.SmwFile, arg); } @@ -33,13 +30,11 @@ public static ActivationRequest Parse(string[] args) var uri = new Uri(arg); - if (uri.Host == "oauth") - { - var provider = uri.AbsolutePath.TrimStart('/'); - var oauthQuery = HttpUtility.ParseQueryString(uri.Query); + if (uri.Host == "oauth") { + string provider = uri.AbsolutePath.TrimStart('/'); + NameValueCollection oauthQuery = HttpUtility.ParseQueryString(uri.Query); - Utils.PendingOAuthCallback = new OAuthCallback - { + Utils.PendingOAuthCallback = new OAuthCallback { Provider = provider, AccessToken = oauthQuery["access_token"] ?? "", RefreshToken = oauthQuery["refresh_token"] ?? "", @@ -51,17 +46,16 @@ public static ActivationRequest Parse(string[] args) return new ActivationRequest(ActivationKind.OAuth, arg); } - var query = HttpUtility.ParseQueryString(uri.Query); - var url = query["url"]; + NameValueCollection query = HttpUtility.ParseQueryString(uri.Query); + string? url = query["url"]; if (string.IsNullOrEmpty(url)) return new ActivationRequest(ActivationKind.Unknown, arg); - var kind = ClassifyPack(url); + ActivationKind kind = ClassifyPack(url); if (kind == ActivationKind.Unknown) kind = ClassifyPack(uri.AbsolutePath); if (kind == ActivationKind.Unknown) kind = ClassifyHost(uri.Host); - switch (kind) - { + switch (kind) { case ActivationKind.SmoFile: Utils.PendingOverlayImportPath = url; return new ActivationRequest(ActivationKind.SmoFile, url); @@ -73,9 +67,8 @@ public static ActivationRequest Parse(string[] args) } } - private static ActivationKind ClassifyPack(string value) - { - var path = value; + private static ActivationKind ClassifyPack(string value) { + string path = value; int cut = path.IndexOfAny(['?', '#']); if (cut >= 0) path = path[..cut]; @@ -87,10 +80,11 @@ private static ActivationKind ClassifyPack(string value) return ActivationKind.Unknown; } - private static ActivationKind ClassifyHost(string host) => host.ToLowerInvariant() switch - { - "overlay" or "smo" => ActivationKind.SmoFile, - "widget" or "widgets" or "smw" or "smwc" or "collection" => ActivationKind.SmwFile, - _ => ActivationKind.Unknown - }; -} + private static ActivationKind ClassifyHost(string host) { + return host.ToLowerInvariant() switch { + "overlay" or "smo" => ActivationKind.SmoFile, + "widget" or "widgets" or "smw" or "smwc" or "collection" => ActivationKind.SmwFile, + _ => ActivationKind.Unknown + }; + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs b/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs index f60f5e5e..0345355d 100644 --- a/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs +++ b/SubathonManager.UI/Platform/WindowsPlatformIntegration.cs @@ -4,45 +4,56 @@ namespace SubathonManager.UI.Platform; [SupportedOSPlatform("windows")] -public sealed class WindowsPlatformIntegration : PlatformIntegrationBase -{ - public override void RegisterFileAssociations() - { - var exePath = Environment.ProcessPath!; +public sealed class WindowsPlatformIntegration : PlatformIntegrationBase { + public override void RegisterFileAssociations() { + string exePath = Environment.ProcessPath!; EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smo", "", "SubathonManager.Overlay"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Overlay", "", "Subathon Manager Overlay"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Overlay\DefaultIcon", "", $"{exePath},0"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Overlay\shell\open\command", "", $"\"{exePath}\" \"%1\""); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Overlay", "", + "Subathon Manager Overlay"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Overlay\DefaultIcon", "", + $"{exePath},0"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Overlay\shell\open\command", "", + $"\"{exePath}\" \"%1\""); EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\subathonmanager", "", "URL:Subathon Manager Protocol"); EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\subathonmanager", "URL Protocol", ""); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\subathonmanager\shell\open\command", "", $"\"{exePath}\" \"%1\""); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\subathonmanager\shell\open\command", "", + $"\"{exePath}\" \"%1\""); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Overlay\shell\import", "", "Import into Subathon Manager"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Overlay\shell\import", "Icon", $"{exePath},0"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Overlay\shell\import", "", + "Import into Subathon Manager"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Overlay\shell\import", "Icon", + $"{exePath},0"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\Applications\SubathonManager.exe", "FriendlyAppName", "Subathon Manager"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\Applications\SubathonManager.exe", "FriendlyAppName", + "Subathon Manager"); EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smo\OpenWithProgids", "SubathonManager.Overlay", ""); EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smw", "", "SubathonManager.Widget"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Widget", "", "Subathon Manager Widget"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Widget\DefaultIcon", "", $"{exePath},0"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Widget\shell\open\command", "", $"\"{exePath}\" \"%1\""); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Widget", "", + "Subathon Manager Widget"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Widget\DefaultIcon", "", + $"{exePath},0"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.Widget\shell\open\command", "", + $"\"{exePath}\" \"%1\""); EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smw\OpenWithProgids", "SubathonManager.Widget", ""); EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smwc", "", "SubathonManager.WidgetCollection"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.WidgetCollection", "", "Subathon Manager Widget Collection"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.WidgetCollection\DefaultIcon", "", $"{exePath},0"); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.WidgetCollection\shell\open\command", "", $"\"{exePath}\" \"%1\""); - EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smwc\OpenWithProgids", "SubathonManager.WidgetCollection", ""); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.WidgetCollection", "", + "Subathon Manager Widget Collection"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.WidgetCollection\DefaultIcon", "", + $"{exePath},0"); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\SubathonManager.WidgetCollection\shell\open\command", + "", $"\"{exePath}\" \"%1\""); + EnsureRegistryValue(@"HKEY_CURRENT_USER\Software\Classes\.smwc\OpenWithProgids", + "SubathonManager.WidgetCollection", ""); } - private static void EnsureRegistryValue(string keyPath, string name, string expectedValue) - { + private static void EnsureRegistryValue(string keyPath, string name, string expectedValue) { var currentValue = Registry.GetValue(keyPath, name, null) as string; if (currentValue == expectedValue) return; Registry.SetValue(keyPath, name, expectedValue); } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/Program.cs b/SubathonManager.UI/Program.cs index 30c18c24..f04abd8a 100644 --- a/SubathonManager.UI/Program.cs +++ b/SubathonManager.UI/Program.cs @@ -1,18 +1,17 @@ +using System.Text; using Avalonia; using SubathonManager.UI.Platform; namespace SubathonManager.UI; -internal static class Program -{ +internal static class Program { public static IPlatformIntegration Platform { get; } = OperatingSystem.IsWindows() ? new WindowsPlatformIntegration() : new PosixPlatformIntegration(); - + [STAThread] - public static int Main(string[] args) - { + public static int Main(string[] args) { // if (OperatingSystem.IsLinux()) // { // Environment.SetEnvironmentVariable("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); @@ -21,29 +20,32 @@ public static int Main(string[] args) string exeDir = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory)!; Directory.SetCurrentDirectory(exeDir); - Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.OutputEncoding = Encoding.UTF8; - AppDomain.CurrentDomain.UnhandledException += (_, ex) => - { - try { File.WriteAllText("error_load.log", $"{ex.ExceptionObject}"); } catch { /**/ } + AppDomain.CurrentDomain.UnhandledException += (_, ex) => { + try { + File.WriteAllText("error_load.log", $"{ex.ExceptionObject}"); + } + catch { + /**/ + } }; - + if (!Platform.TryAcquireSingleInstance(args)) return 0; - try - { + try { return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } - finally - { + finally { Platform.Release(); } } - public static AppBuilder BuildAvaloniaApp() - => AppBuilder.Configure() + public static AppBuilder BuildAvaloniaApp() { + return AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .LogToTrace(); -} + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Services/ServiceManager.cs b/SubathonManager.UI/Services/ServiceManager.cs index 9f12a55c..b4de8e0b 100644 --- a/SubathonManager.UI/Services/ServiceManager.cs +++ b/SubathonManager.UI/Services/ServiceManager.cs @@ -2,6 +2,7 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SubathonManager.Core; using SubathonManager.Core.Interfaces; using SubathonManager.Integration; using SubathonManager.Server; @@ -9,31 +10,70 @@ namespace SubathonManager.UI.Services; -public class ServiceManager(ILogger logger) -{ - private readonly HashSet _running = new(); +public class ServiceManager(ILogger logger) { private readonly ConcurrentDictionary _locks = new(); - - public static string AppVersion - { - get - { - var ver = Assembly.GetExecutingAssembly() + private readonly HashSet _running = new(); + + public static string AppVersion { + get { + string ver = Assembly.GetExecutingAssembly() .GetCustomAttribute()? .InformationalVersion ?? "dev"; - var plusIndex = ver.IndexOf('+'); + int plusIndex = ver.IndexOf('+'); return plusIndex > 0 && ver.StartsWith('v') ? ver[..plusIndex] : ver; } } - private static IServiceProvider Provider - { - get => Core.AppServices.Provider; - set => Core.AppServices.Provider = value; + + private static IServiceProvider Provider { + get => AppServices.Provider; + set => AppServices.Provider = value; } - public async Task StartIntegrationsAsync() - { + // Core Services // + // TelemetryService + // CurrencyService + // EventService + // WebServer + // TimerService + + // Integration Services // + // Twitch, YouTube, Picarto, StreamElements, StreamLabs, GoAffPro + + // Other Services // + // DiscordWebhookService + + // Other Services (Static) // + // ExternalEventService + // BlerpChatService + // CommandService (Dependency for many) + + public static EventService Events => Provider.GetRequiredService(); + public static DiscordWebhookService DiscordWebhooks => Provider.GetRequiredService(); + public static GoAffProService GoAffPro => Provider.GetRequiredService(); + public static MakeShipService MakeShip => Provider.GetRequiredService(); + public static JuniperService Juniper => Provider.GetRequiredService(); + public static TangiaService Tangia => Provider.GetRequiredService(); + public static FourthWallService FourthWall => Provider.GetRequiredService(); + public static ThroneService Throne => Provider.GetRequiredService(); + public static PallyService Pally => Provider.GetRequiredService(); + public static DevTunnelsService DevTunnels => Provider.GetRequiredService(); + public static KoFiService KoFi => Provider.GetRequiredService(); + public static TwitchService Twitch => Provider.GetRequiredService(); + public static YouTubeService YouTube => Provider.GetRequiredService(); + public static PicartoService Picarto => Provider.GetRequiredService(); + public static StreamElementsService StreamElements => Provider.GetRequiredService(); + public static StreamLabsService StreamLabs => Provider.GetRequiredService(); + public static TipeeeStreamService TipeeeStream => Provider.GetRequiredService(); + public static TreatStreamService TreatStream => Provider.GetRequiredService(); + public static OBSService OBS => Provider.GetRequiredService(); + + public static WebServer Server => Provider.GetRequiredService(); + + public static EventService? EventsOrNull => Provider.GetService(); + public static DiscordWebhookService? DiscordWebHooksOrNull => Provider.GetService(); + + public async Task StartIntegrationsAsync() { await StartAsync(); await StartAsync(); await StartAsync(); @@ -42,7 +82,7 @@ public async Task StartIntegrationsAsync() await StartAsync(); await StartAsync(); await StartAsync(); - await StartAsync();//(fireAndForget: true); + await StartAsync(); //(fireAndForget: true); await StartAsync(); await StartAsync(); await StartAsync(); @@ -54,8 +94,7 @@ public async Task StartIntegrationsAsync() await StartAsync(); } - public async Task StopIntegrationsAsync() - { + public async Task StopIntegrationsAsync() { await StopAsync(); await StopAsync(); await StopAsync(); @@ -76,100 +115,58 @@ public async Task StopIntegrationsAsync() await StopAsync(); } - public async Task StopCoreServicesAsync() - { + public async Task StopCoreServicesAsync() { await StopAsync(); await StopAsync(); await StopAsync(); await StopAsync(); await StopAsync(); } - - private SemaphoreSlim GetLock(Type t) => - _locks.GetOrAdd(t, _ => new SemaphoreSlim(1, 1)); - - public async Task StartAsync(CancellationToken ct = default, bool fireAndForget = false) where T : IAppService - { - var lk = GetLock(typeof(T)); + + private SemaphoreSlim GetLock(Type t) { + return _locks.GetOrAdd(t, _ => new SemaphoreSlim(1, 1)); + } + + public async Task StartAsync(CancellationToken ct = default, bool fireAndForget = false) where T : IAppService { + SemaphoreSlim lk = GetLock(typeof(T)); await lk.WaitAsync(ct); - try - { + try { if (_running.Contains(typeof(T))) return; - + var service = Provider.GetRequiredService(); if (fireAndForget) #pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - Task.Run(async() => await service.StartAsync(ct), ct); // run sync + Task.Run(async () => await service.StartAsync(ct), ct); // run sync #pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - else + else await service.StartAsync(ct); _running.Add(typeof(T)); logger?.LogDebug("{Service} started", typeof(T).Name); } - finally { lk.Release(); } + finally { + lk.Release(); + } } - - public async Task StopAsync(CancellationToken ct = default) where T : IAppService - { - var lk = GetLock(typeof(T)); + + public async Task StopAsync(CancellationToken ct = default) where T : IAppService { + SemaphoreSlim lk = GetLock(typeof(T)); await lk.WaitAsync(ct); - try - { + try { if (!_running.Contains(typeof(T))) return; - + var service = Provider.GetService(); if (service != null) await service.StopAsync(ct); _running.Remove(typeof(T)); logger?.LogDebug("{Service} stopped", typeof(T).Name); } - finally { lk.Release(); } + finally { + lk.Release(); + } + } + + public bool IsRunning() { + return _running.Contains(typeof(T)); } - - public bool IsRunning() => _running.Contains(typeof(T)); - - // Core Services // - // TelemetryService - // CurrencyService - // EventService - // WebServer - // TimerService - - // Integration Services // - // Twitch, YouTube, Picarto, StreamElements, StreamLabs, GoAffPro - - // Other Services // - // DiscordWebhookService - - // Other Services (Static) // - // ExternalEventService - // BlerpChatService - // CommandService (Dependency for many) - - public static EventService Events => Provider.GetRequiredService(); - public static DiscordWebhookService DiscordWebhooks => Provider.GetRequiredService(); - public static GoAffProService GoAffPro => Provider.GetRequiredService(); - public static MakeShipService MakeShip => Provider.GetRequiredService(); - public static JuniperService Juniper => Provider.GetRequiredService(); - public static TangiaService Tangia => Provider.GetRequiredService(); - public static FourthWallService FourthWall => Provider.GetRequiredService(); - public static ThroneService Throne => Provider.GetRequiredService(); - public static PallyService Pally => Provider.GetRequiredService(); - public static DevTunnelsService DevTunnels => Provider.GetRequiredService(); - public static KoFiService KoFi => Provider.GetRequiredService(); - public static TwitchService Twitch => Provider.GetRequiredService(); - public static YouTubeService YouTube => Provider.GetRequiredService(); - public static PicartoService Picarto => Provider.GetRequiredService(); - public static StreamElementsService StreamElements => Provider.GetRequiredService(); - public static StreamLabsService StreamLabs => Provider.GetRequiredService(); - public static TipeeeStreamService TipeeeStream => Provider.GetRequiredService(); - public static TreatStreamService TreatStream => Provider.GetRequiredService(); - public static OBSService OBS => Provider.GetRequiredService(); - - public static WebServer Server => Provider.GetRequiredService(); - - public static EventService? EventsOrNull => Provider.GetService(); - public static DiscordWebhookService? DiscordWebHooksOrNull => Provider.GetService(); - } \ No newline at end of file diff --git a/SubathonManager.UI/Services/ServiceRegistration.cs b/SubathonManager.UI/Services/ServiceRegistration.cs index 65b855f1..4d244c04 100644 --- a/SubathonManager.UI/Services/ServiceRegistration.cs +++ b/SubathonManager.UI/Services/ServiceRegistration.cs @@ -1,10 +1,9 @@ -using System.IO; -using System.Net.Http; -using DevTunnels.Client; +using DevTunnels.Client; using DevTunnels.Client.DependencyInjection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; using SubathonManager.Core; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Security; @@ -16,22 +15,19 @@ namespace SubathonManager.UI.Services; -public static class ServiceRegistration -{ - public static void SetupInfrastructure(this IServiceCollection services) - { +public static class ServiceRegistration { + public static void SetupInfrastructure(this IServiceCollection services) { services.AddLogging(ConfigureLogging); services.AddSingleton(); services.AddPooledDbContextFactory(ConfigureDatabase); services.AddSingleton(); } - public static void SetupCoreServices(this IServiceCollection services) - { + public static void SetupCoreServices(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddHttpClient(nameof(CurrencyService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); - services.AddSingleton(BuildCurrencyService); + services.AddSingleton(BuildCurrencyService); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -43,18 +39,17 @@ public static void SetupCoreServices(this IServiceCollection services) else services.AddSingleton(); } - - public static void AddIntegrations(this IServiceCollection services) - { + + public static void AddIntegrations(this IServiceCollection services) { // Platforms /// services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - + // Auxiliary // services.AddSingleton(); services.AddSingleton(); - + // Order Sales // services.AddSingleton(); services.AddHttpClient(nameof(MakeShipService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); @@ -63,17 +58,12 @@ public static void AddIntegrations(this IServiceCollection services) services.AddSingleton(); // Webhooks (shared tunnel infrastructure) // - var wingetPath = Path.Combine( + string wingetPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "WinGet", "Links", "devtunnel.exe"); if (File.Exists(wingetPath)) - { - services.Configure(opts => - { - opts.CliPathOverride = wingetPath; - }); - } - + services.Configure(opts => { opts.CliPathOverride = wingetPath; }); + services.AddDevTunnelsClient(); services.AddSingleton(); @@ -97,17 +87,16 @@ public static void AddIntegrations(this IServiceCollection services) services.AddSingleton(); // Other // - services.AddHttpClient(nameof(DiscordWebhookService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan);; + services.AddHttpClient(nameof(DiscordWebhookService)).SetHandlerLifetime(Timeout.InfiniteTimeSpan); + ; services.AddSingleton(); services.AddSingleton(); } - private static void ConfigureLogging(ILoggingBuilder builder) - { + private static void ConfigureLogging(ILoggingBuilder builder) { builder.ClearProviders(); builder.AddProvider(new RotatingFileLoggerProvider("data/logs")); - builder.AddSimpleConsole(options => - { + builder.AddSimpleConsole(options => { options.TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff "; options.SingleLine = true; options.IncludeScopes = false; @@ -118,21 +107,21 @@ private static void ConfigureLogging(ILoggingBuilder builder) builder.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning); builder.AddFilter("YTLiveChat", LogLevel.Warning); builder.AddFilter("YTLiveChat.Services.YTLiveChat", LogLevel.Error); - builder.AddFilter( + builder.AddFilter( "YTLiveChat", LogLevel.Critical); - builder.SetMinimumLevel(ServiceManager.AppVersion.Contains("dev") ? LogLevel.Debug : LogLevel.Information); + builder.SetMinimumLevel(ServiceManager.AppVersion.Contains("dev") ? LogLevel.Debug : LogLevel.Information); } - - private static void ConfigureDatabase(IServiceProvider sp, DbContextOptionsBuilder options) - { - var dbPath = Config.DatabasePath; + + private static void ConfigureDatabase(IServiceProvider sp, DbContextOptionsBuilder options) { + string dbPath = Config.DatabasePath; Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); options.UseSqlite($"Data Source={dbPath}"); } - - private static CurrencyService BuildCurrencyService(IServiceProvider sp) => - new(sp.GetRequiredService>(), + + private static CurrencyService BuildCurrencyService(IServiceProvider sp) { + return new CurrencyService(sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetRequiredService().CreateClient(nameof(CurrencyService))); + } } \ No newline at end of file diff --git a/SubathonManager.UI/SubathonManager.UI.csproj b/SubathonManager.UI/SubathonManager.UI.csproj index e7cc2410..ec2db985 100644 --- a/SubathonManager.UI/SubathonManager.UI.csproj +++ b/SubathonManager.UI/SubathonManager.UI.csproj @@ -22,7 +22,7 @@ - + PreserveNewest @@ -40,26 +40,26 @@ - - - - + + + + - - - - - + + + + + - + - + - - - + + + diff --git a/SubathonManager.UI/Themes/ControlTweaks.axaml b/SubathonManager.UI/Themes/ControlTweaks.axaml index 574eaca1..93e1ebd8 100644 --- a/SubathonManager.UI/Themes/ControlTweaks.axaml +++ b/SubathonManager.UI/Themes/ControlTweaks.axaml @@ -1,76 +1,75 @@ - + - + - + \ No newline at end of file diff --git a/SubathonManager.UI/Themes/TabStyles.axaml b/SubathonManager.UI/Themes/TabStyles.axaml index 8fd8edd5..e084d209 100644 --- a/SubathonManager.UI/Themes/TabStyles.axaml +++ b/SubathonManager.UI/Themes/TabStyles.axaml @@ -3,21 +3,21 @@ - + - + - + - + \ No newline at end of file diff --git a/SubathonManager.UI/Themes/ThemeBrushes.axaml b/SubathonManager.UI/Themes/ThemeBrushes.axaml index 3ae770ec..1e728b1f 100644 --- a/SubathonManager.UI/Themes/ThemeBrushes.axaml +++ b/SubathonManager.UI/Themes/ThemeBrushes.axaml @@ -5,79 +5,79 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + - - - - - - - - + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - + + + + + - + \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/AutoCompleteBoxBehaviours.cs b/SubathonManager.UI/UiUtils/AutoCompleteBoxBehaviours.cs index 5786041d..8437b776 100644 --- a/SubathonManager.UI/UiUtils/AutoCompleteBoxBehaviours.cs +++ b/SubathonManager.UI/UiUtils/AutoCompleteBoxBehaviours.cs @@ -5,43 +5,43 @@ namespace SubathonManager.UI.UiUtils; -public static class AutoCompleteBoxBehaviours -{ +public static class AutoCompleteBoxBehaviours { public static readonly AttachedProperty OpenOnFocusProperty = AvaloniaProperty.RegisterAttached("OpenOnFocus", typeof(AutoCompleteBoxBehaviours)); - public static void SetOpenOnFocus(AutoCompleteBox box, bool value) => box.SetValue(OpenOnFocusProperty, value); - public static bool GetOpenOnFocus(AutoCompleteBox box) => box.GetValue(OpenOnFocusProperty); - - static AutoCompleteBoxBehaviours() - { - OpenOnFocusProperty.Changed.AddClassHandler((box, e) => - { + static AutoCompleteBoxBehaviours() { + OpenOnFocusProperty.Changed.AddClassHandler((box, e) => { if (e.OldValue is true || e.NewValue is not true) return; box.GotFocus += (_, _) => box.IsDropDownOpen = true; box.TemplateApplied += OnTemplateApplied; }); } - private static void OnTemplateApplied(object? sender, TemplateAppliedEventArgs e) - { + public static void SetOpenOnFocus(AutoCompleteBox box, bool value) { + box.SetValue(OpenOnFocusProperty, value); + } + + public static bool GetOpenOnFocus(AutoCompleteBox box) { + return box.GetValue(OpenOnFocusProperty); + } + + private static void OnTemplateApplied(object? sender, TemplateAppliedEventArgs e) { if (sender is not AutoCompleteBox box) return; - var brush = box.TryFindResource("TextFillColorSecondaryBrush", box.ActualThemeVariant, out var b) && b is IBrush ib + IBrush brush = box.TryFindResource("TextFillColorSecondaryBrush", box.ActualThemeVariant, out object? b) && + b is IBrush ib ? ib : Brushes.Gray; - if (e.NameScope.Find("PART_Popup")?.Child is Border popupBorder) - { + if (e.NameScope.Find("PART_Popup")?.Child is Border popupBorder) { popupBorder.BorderBrush = brush; popupBorder.BorderThickness = new Thickness(0.8); return; } - if (e.NameScope.Find("PART_SelectingItemsControl") is { } list) - { + if (e.NameScope.Find("PART_SelectingItemsControl") is { } list) { list.BorderBrush = brush; list.BorderThickness = new Thickness(0.8); } } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/ControlExtensions.cs b/SubathonManager.UI/UiUtils/ControlExtensions.cs index 1dc5e707..7d7e2263 100644 --- a/SubathonManager.UI/UiUtils/ControlExtensions.cs +++ b/SubathonManager.UI/UiUtils/ControlExtensions.cs @@ -7,45 +7,38 @@ namespace SubathonManager.UI.UiUtils; // trying to emulate WPF visual behaviours -public enum Appearance -{ +public enum Appearance { Transparent, Secondary, Primary, Danger } -public static class ControlExtensions -{ - public static void SetDynamicResource(this Control control, AvaloniaProperty property, object resourceKey) - => control.Bind(property, control.GetResourceObservable(resourceKey)); +public static class ControlExtensions { + public static void SetDynamicResource(this Control control, AvaloniaProperty property, object resourceKey) { + control.Bind(property, control.GetResourceObservable(resourceKey)); + } - public static void StyleAsTab(this Button button, bool topRounded = true) - { + public static void StyleAsTab(this Button button, bool topRounded = true) { button.CornerRadius = topRounded ? new CornerRadius(6, 6, 0, 0) : new CornerRadius(6); button.BorderThickness = topRounded ? new Thickness(1, 1, 1, 0) : new Thickness(1); button.Padding = new Thickness(10, 6, 10, 6); if (!button.Classes.Contains("tab")) button.Classes.Add("tab"); } - - public static void SetTabActive(this Button button, bool active) - { - if (active) - { + + public static void SetTabActive(this Button button, bool active) { + if (active) { button.SetDynamicResource(TemplatedControl.BackgroundProperty, "OpaqueSecondaryBackground"); button.SetDynamicResource(TemplatedControl.BorderBrushProperty, "AccentFillColorDefaultBrush"); } - else - { + else { button.SetDynamicResource(TemplatedControl.BackgroundProperty, "ControlFillColorDefaultBrush"); button.SetDynamicResource(TemplatedControl.BorderBrushProperty, "ControlElevationBorderBrush"); } } - public static Button ApplyAppearance(this Button button, Appearance appearance) - { - switch (appearance) - { + public static Button ApplyAppearance(this Button button, Appearance appearance) { + switch (appearance) { case Appearance.Transparent: button.Background = Brushes.Transparent; button.BorderBrush = Brushes.Transparent; @@ -62,6 +55,7 @@ public static Button ApplyAppearance(this Button button, Appearance appearance) button.Foreground = Brushes.White; break; } + return button; } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/CssVariableTemplateSelector.cs b/SubathonManager.UI/UiUtils/CssVariableTemplateSelector.cs index e9353506..0e29e5e5 100644 --- a/SubathonManager.UI/UiUtils/CssVariableTemplateSelector.cs +++ b/SubathonManager.UI/UiUtils/CssVariableTemplateSelector.cs @@ -5,8 +5,7 @@ namespace SubathonManager.UI.UiUtils; -public class CssVariableTemplateSelector : IDataTemplate -{ +public class CssVariableTemplateSelector : IDataTemplate { public IDataTemplate? DefaultTemplate { get; set; } public IDataTemplate? ColorTemplate { get; set; } public IDataTemplate? SizeTemplate { get; set; } @@ -15,14 +14,16 @@ public class CssVariableTemplateSelector : IDataTemplate public IDataTemplate? IntTemplate { get; set; } public IDataTemplate? OpacityTemplate { get; set; } - public bool Match(object? data) => data is CssVariable; + public bool Match(object? data) { + return data is CssVariable; + } - public Control? Build(object? param) => Pick(param)?.Build(param); + public Control? Build(object? param) { + return Pick(param)?.Build(param); + } - private IDataTemplate? Pick(object? item) - { - if (item is CssVariable cssVar) - { + private IDataTemplate? Pick(object? item) { + if (item is CssVariable cssVar) { if (cssVar.Type == WidgetCssVariableType.Color) return ColorTemplate; if (cssVar.Type == WidgetCssVariableType.Size) return SizeTemplate; if (cssVar.Type == WidgetCssVariableType.Int) return IntTemplate; @@ -30,6 +31,7 @@ public class CssVariableTemplateSelector : IDataTemplate if (cssVar.Type == WidgetCssVariableType.Opacity) return OpacityTemplate; if (cssVar.Type.GetOptions().Count > 0) return OptionsTemplate; } + return DefaultTemplate; } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/DirtySaveGuard.cs b/SubathonManager.UI/UiUtils/DirtySaveGuard.cs index 7b58bbbf..99a2717a 100644 --- a/SubathonManager.UI/UiUtils/DirtySaveGuard.cs +++ b/SubathonManager.UI/UiUtils/DirtySaveGuard.cs @@ -7,19 +7,11 @@ namespace SubathonManager.UI.UiUtils; -public static class DirtySaveGuard -{ - private sealed class Baseline - { - public object? Value; - } - +public static class DirtySaveGuard { private static readonly ConditionalWeakTable Baselines = new(); - public static bool TryGetValue(object? control, out object? value) - { - switch (control) - { + public static bool TryGetValue(object? control, out object? value) { + switch (control) { case TextBox tb: value = tb.Text ?? ""; return true; @@ -47,34 +39,34 @@ public static bool TryGetValue(object? control, out object? value) } } - public static void Rebase(object? control) - { - if (control is not AvaloniaObject obj || !TryGetValue(control, out var value)) return; - if (Baselines.TryGetValue(obj, out var baseline)) baseline.Value = value; + public static void Rebase(object? control) { + if (control is not AvaloniaObject obj || !TryGetValue(control, out object? value)) return; + if (Baselines.TryGetValue(obj, out Baseline? baseline)) baseline.Value = value; else Baselines.Add(obj, new Baseline { Value = value }); } - public static void RebaseAll(Visual? root) - { + public static void RebaseAll(Visual? root) { if (root == null) return; Rebase(root); - foreach (var child in root.GetVisualChildren()) + foreach (Visual child in root.GetVisualChildren()) RebaseAll(child); } - public static bool Consume(object? control) - { - if (control is not AvaloniaObject obj || !TryGetValue(control, out var current)) return true; + public static bool Consume(object? control) { + if (control is not AvaloniaObject obj || !TryGetValue(control, out object? current)) return true; - if (!Baselines.TryGetValue(obj, out var baseline)) - { + if (!Baselines.TryGetValue(obj, out Baseline? baseline)) { Baselines.Add(obj, new Baseline { Value = current }); return true; } if (Equals(baseline.Value, current)) return false; - + baseline.Value = current; return true; } -} + + private sealed class Baseline { + public object? Value; + } +} \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/EnterKeyCommit.cs b/SubathonManager.UI/UiUtils/EnterKeyCommit.cs index f9b7d12f..e7dee65d 100644 --- a/SubathonManager.UI/UiUtils/EnterKeyCommit.cs +++ b/SubathonManager.UI/UiUtils/EnterKeyCommit.cs @@ -6,12 +6,9 @@ namespace SubathonManager.UI.UiUtils; -public static class EnterKeyCommit -{ - public static void Attach(InputElement root, Action commit) - { - root.AddHandler(InputElement.KeyDownEvent, (_, e) => - { +public static class EnterKeyCommit { + public static void Attach(InputElement root, Action commit) { + root.AddHandler(InputElement.KeyDownEvent, (_, e) => { if (e.Key != Key.Enter) return; if (!ShouldCommit(e.Source)) return; @@ -20,10 +17,8 @@ public static void Attach(InputElement root, Action commit) }, RoutingStrategies.Bubble); } - private static bool ShouldCommit(object? source) - { - switch (source) - { + private static bool ShouldCommit(object? source) { + switch (source) { case TextBox { AcceptsReturn: true }: case ComboBox { IsDropDownOpen: true }: case AutoCompleteBox { IsDropDownOpen: true }: @@ -37,12 +32,9 @@ private static bool ShouldCommit(object? source) } } - private static bool HasExcludedAncestor(Visual? v) - { - while (v != null) - { - switch (v) - { + private static bool HasExcludedAncestor(Visual? v) { + while (v != null) { + switch (v) { case TextBox { AcceptsReturn: true }: case ComboBox { IsDropDownOpen: true }: case AutoCompleteBox { IsDropDownOpen: true }: @@ -50,8 +42,10 @@ private static bool HasExcludedAncestor(Visual? v) case MenuItem: return true; } + v = v.GetVisualParent(); } + return false; } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/EventTypeMenu.cs b/SubathonManager.UI/UiUtils/EventTypeMenu.cs index af5052f9..7b5727e7 100644 --- a/SubathonManager.UI/UiUtils/EventTypeMenu.cs +++ b/SubathonManager.UI/UiUtils/EventTypeMenu.cs @@ -13,15 +13,12 @@ public sealed record EventTypeMenuEntry( Action OnSelected, string? Category = null); -public static class EventTypeMenu -{ +public static class EventTypeMenu { public static void Show(Control placementTarget, IReadOnlyList entries, - bool groupBySourceType = true, string? clearLabel = null, Action? onClear = null) - { + bool groupBySourceType = true, string? clearLabel = null, Action? onClear = null) { var flyout = new MenuFlyout { Placement = PlacementMode.Bottom }; - var searchBox = new TextBox - { + var searchBox = new TextBox { PlaceholderText = "Search...", MinWidth = 170, Height = 34, @@ -31,11 +28,9 @@ public static void Show(Control placementTarget, IReadOnlyList onClear(); @@ -43,29 +38,25 @@ public static void Show(Control placementTarget, IReadOnlyList nestedItems = BuildNested(entries, groupBySourceType); + foreach (MenuItem item in nestedItems) flyout.Items.Add(item); List currentMatches = []; - searchBox.TextChanged += (_, _) => - { + searchBox.TextChanged += (_, _) => { while (flyout.Items.Count > fixedItemCount) flyout.Items.RemoveAt(fixedItemCount); - var query = (searchBox.Text ?? string.Empty).Trim(); + string query = (searchBox.Text ?? string.Empty).Trim(); - if (query.Length == 0) - { + if (query.Length == 0) { currentMatches.Clear(); - foreach (var item in nestedItems) flyout.Items.Add(item); + foreach (MenuItem item in nestedItems) flyout.Items.Add(item); return; } currentMatches = entries.Where(en => Matches(en, query, groupBySourceType)).ToList(); - foreach (var entry in currentMatches) - { - var captured = entry; - var flat = new MenuItem - { + foreach (EventTypeMenuEntry entry in currentMatches) { + EventTypeMenuEntry captured = entry; + var flat = new MenuItem { Header = MakeHeader(entry.Category is { Length: > 0 } ? $"{entry.Source} - {entry.Category} - {entry.Label}" : $"{entry.Source} - {entry.Label}", entry.IsSelected) @@ -75,8 +66,7 @@ public static void Show(Control placementTarget, IReadOnlyList - { + searchBox.KeyDown += (_, e) => { if (e.Key != Key.Enter || currentMatches.Count == 0) return; e.Handled = true; flyout.Hide(); @@ -87,20 +77,17 @@ public static void Show(Control placementTarget, IReadOnlyList BuildNested(IReadOnlyList entries, bool groupBySourceType) - { - if (!groupBySourceType) - { + private static List BuildNested(IReadOnlyList entries, bool groupBySourceType) { + if (!groupBySourceType) { var sourceItems = new List(); - foreach (var sourceGroup in entries.GroupBy(en => en.Source) + foreach (IGrouping sourceGroup in entries.GroupBy(en => en.Source) .OrderBy(g => SubathonEventSourceHelper.GetSourceOrder(g.Key))) sourceItems.Add(BuildSourceItem(sourceGroup, out _)); return sourceItems; @@ -108,19 +95,17 @@ private static List BuildNested(IReadOnlyList entr var groupItems = new List(); - var groups = entries + IOrderedEnumerable> groups = entries .GroupBy(en => en.Source.GetGroup()) .OrderBy(g => g.Min(en => SubathonEventSourceHelper.GetSourceOrder(en.Source))); - foreach (var group in groups) - { + foreach (IGrouping group in groups) { var groupItem = new MenuItem(); - bool groupHasSelection = false; + var groupHasSelection = false; - foreach (var sourceGroup in group.GroupBy(en => en.Source) - .OrderBy(g => SubathonEventSourceHelper.GetSourceOrder(g.Key))) - { - var sourceItem = BuildSourceItem(sourceGroup, out bool sourceHasSelection); + foreach (IGrouping sourceGroup in group.GroupBy(en => en.Source) + .OrderBy(g => SubathonEventSourceHelper.GetSourceOrder(g.Key))) { + MenuItem sourceItem = BuildSourceItem(sourceGroup, out bool sourceHasSelection); groupHasSelection |= sourceHasSelection; groupItem.Items.Add(sourceItem); } @@ -133,52 +118,46 @@ private static List BuildNested(IReadOnlyList entr } private static MenuItem BuildSourceItem(IGrouping sourceGroup, - out bool hasSelection) - { + out bool hasSelection) { var sourceItem = new MenuItem(); hasSelection = false; var categoryItems = new Dictionary(StringComparer.OrdinalIgnoreCase); var categorySelections = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var entry in sourceGroup) - { - var captured = entry; + foreach (EventTypeMenuEntry entry in sourceGroup) { + EventTypeMenuEntry captured = entry; var leaf = new MenuItem { Header = MakeHeader(entry.Label, entry.IsSelected) }; hasSelection |= entry.IsSelected; leaf.Click += (_, _) => captured.OnSelected(); - if (entry.Category is { Length: > 0 } category) - { - if (!categoryItems.TryGetValue(category, out var categoryItem)) - { + if (entry.Category is { Length: > 0 } category) { + if (!categoryItems.TryGetValue(category, out MenuItem? categoryItem)) { categoryItem = new MenuItem(); categoryItems[category] = categoryItem; sourceItem.Items.Add(categoryItem); } + categoryItem.Items.Add(leaf); categorySelections[category] = categorySelections.GetValueOrDefault(category) | entry.IsSelected; } - else - { + else { sourceItem.Items.Add(leaf); } } - foreach (var (category, categoryItem) in categoryItems) + foreach ((string category, MenuItem categoryItem) in categoryItems) categoryItem.Header = MakeHeader(category, categorySelections.GetValueOrDefault(category)); sourceItem.Header = MakeHeader(sourceGroup.Key.ToString(), hasSelection); return sourceItem; } - private static TextBlock MakeHeader(string text, bool hasSelection) - { - var header = new TextBlock - { + private static TextBlock MakeHeader(string text, bool hasSelection) { + var header = new TextBlock { Text = text, FontWeight = hasSelection ? FontWeight.SemiBold : FontWeight.Normal }; if (hasSelection) header.Foreground = Brushes.CornflowerBlue; return header; } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/FileManagerFocus.cs b/SubathonManager.UI/UiUtils/FileManagerFocus.cs index b66c721f..904f94eb 100644 --- a/SubathonManager.UI/UiUtils/FileManagerFocus.cs +++ b/SubathonManager.UI/UiUtils/FileManagerFocus.cs @@ -5,8 +5,7 @@ namespace SubathonManager.UI.UiUtils; -public static partial class FileManagerFocus -{ +public static class FileManagerFocus { #region WINDOWS private const int SwRestore = 9; @@ -21,19 +20,21 @@ public static partial class FileManagerFocus private static extern bool IsIconic(IntPtr hWnd); [SupportedOSPlatform("windows")] - public static bool TryFocusExplorer(string? folder, string? selectFile = null) - { + public static bool TryFocusExplorer(string? folder, string? selectFile = null) { if (string.IsNullOrWhiteSpace(folder)) return false; string target; - try { target = Path.GetFullPath(folder).TrimEnd(Path.DirectorySeparatorChar); } - catch { return false; } + try { + target = Path.GetFullPath(folder).TrimEnd(Path.DirectorySeparatorChar); + } + catch { + return false; + } object? shell = null; object? windows = null; - try - { + try { var shellType = Type.GetTypeFromProgID("Shell.Application"); if (shellType == null) return false; @@ -45,16 +46,14 @@ public static bool TryFocusExplorer(string? folder, string? selectFile = null) if (Invoke(windows, "Count") is not int count) return false; - for (int i = 0; i < count; i++) - { + for (var i = 0; i < count; i++) { object? window = null; - try - { + try { window = Invoke(windows, "Item", i); if (window == null) continue; object? document = GetProperty(window, "Document"); - string? path = GetProperty(GetProperty(GetProperty(document, "Folder"), "Self"), "Path") as string; + var path = GetProperty(GetProperty(GetProperty(document, "Folder"), "Self"), "Path") as string; if (string.IsNullOrWhiteSpace(path)) continue; if (!string.Equals(path.TrimEnd(Path.DirectorySeparatorChar), target, @@ -72,19 +71,18 @@ public static bool TryFocusExplorer(string? folder, string? selectFile = null) TrySelectItem(document, selectFile); return true; } - catch {/**/} - finally - { + catch { + /**/ + } + finally { Release(window); } } } - catch - { + catch { return false; } - finally - { + finally { Release(windows); Release(shell); } @@ -92,12 +90,10 @@ public static bool TryFocusExplorer(string? folder, string? selectFile = null) return false; } - private static void TrySelectItem(object? document, string? selectFile) - { + private static void TrySelectItem(object? document, string? selectFile) { if (document == null || string.IsNullOrWhiteSpace(selectFile)) return; - try - { + try { object? folder = GetProperty(document, "Folder"); if (folder == null) return; @@ -107,46 +103,50 @@ private static void TrySelectItem(object? document, string? selectFile) // SVSI_SELECT | SVSI_DESELECTOTHERS | SVSI_ENSUREVISIBLE | SVSI_FOCUSED Invoke(document, "SelectItem", item, 0x0001 | 0x0004 | 0x0008 | 0x0010); } - catch { /**/ } + catch { + /**/ + } } - private static object? Invoke(object target, string name, params object?[] args) - => target.GetType().InvokeMember(name, BindingFlags.InvokeMethod | BindingFlags.GetProperty, + private static object? Invoke(object target, string name, params object?[] args) { + return target.GetType().InvokeMember(name, BindingFlags.InvokeMethod | BindingFlags.GetProperty, null, target, args); + } - private static object? GetProperty(object? target, string name) - => target?.GetType().InvokeMember(name, BindingFlags.GetProperty, null, target, null); + private static object? GetProperty(object? target, string name) { + return target?.GetType().InvokeMember(name, BindingFlags.GetProperty, null, target, null); + } - private static void Release(object? comObject) - { - try - { + private static void Release(object? comObject) { + try { if (comObject != null && Marshal.IsComObject(comObject)) #pragma warning disable CA1416 Marshal.ReleaseComObject(comObject); #pragma warning restore CA1416 } - catch { /**/ } + catch { + /**/ + } } #endregion #region LINUX - public static bool TryShowItemsOverDBus(string? path) - { + public static bool TryShowItemsOverDBus(string? path) { if (string.IsNullOrWhiteSpace(path)) return false; string uri; - try { uri = new Uri(Path.GetFullPath(path)).AbsoluteUri; } - catch { return false; } - - try - { - using var process = Process.Start(new ProcessStartInfo("gdbus") - { - ArgumentList = - { + try { + uri = new Uri(Path.GetFullPath(path)).AbsoluteUri; + } + catch { + return false; + } + + try { + using Process? process = Process.Start(new ProcessStartInfo("gdbus") { + ArgumentList = { "call", "--session", "--dest", "org.freedesktop.FileManager1", "--object-path", "/org/freedesktop/FileManager1", @@ -162,22 +162,27 @@ public static bool TryShowItemsOverDBus(string? path) if (process == null) return false; - if (!process.WaitForExit(3000)) - { - try { process.Kill(entireProcessTree: true); } catch { /**/ } + if (!process.WaitForExit(3000)) { + try { + process.Kill(true); + } + catch { + /**/ + } + return false; } return process.ExitCode == 0; } - catch - { + catch { return false; } } - private static string EscapeGVariant(string value) - => value.Replace("\\", "\\\\").Replace("'", "\\'"); + private static string EscapeGVariant(string value) { + return value.Replace("\\", "\\\\").Replace("'", "\\'"); + } #endregion -} +} \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/JSVariableTemplateSelector.cs b/SubathonManager.UI/UiUtils/JSVariableTemplateSelector.cs index 5fbfd454..de7493d0 100644 --- a/SubathonManager.UI/UiUtils/JSVariableTemplateSelector.cs +++ b/SubathonManager.UI/UiUtils/JSVariableTemplateSelector.cs @@ -5,8 +5,7 @@ namespace SubathonManager.UI.UiUtils; -public class JsVariableTemplateSelector : IDataTemplate -{ +public class JsVariableTemplateSelector : IDataTemplate { public IDataTemplate? DefaultTemplate { get; set; } // same as String public IDataTemplate? EventTypeListTemplate { get; set; } @@ -21,12 +20,15 @@ public class JsVariableTemplateSelector : IDataTemplate public IDataTemplate? FloatTemplate { get; set; } public IDataTemplate? FilteredEventTypeListTemplate { get; set; } - public bool Match(object? data) => data is JsVariable; + public bool Match(object? data) { + return data is JsVariable; + } - public Control? Build(object? param) => Pick(param)?.Build(param); + public Control? Build(object? param) { + return Pick(param)?.Build(param); + } - private IDataTemplate? Pick(object? item) - { + private IDataTemplate? Pick(object? item) { if (item is not JsVariable jsVar) return DefaultTemplate; if (((WidgetVariableType?)jsVar.Type).IsFileVariable()) return FileVarTemplate; @@ -35,8 +37,7 @@ public class JsVariableTemplateSelector : IDataTemplate jsVar.Type != WidgetVariableType.EventTypeList) return FilteredEventTypeListTemplate; - return jsVar.Type switch - { + return jsVar.Type switch { WidgetVariableType.String => DefaultTemplate, WidgetVariableType.Int => IntTemplate, WidgetVariableType.Percent => PercentTemplate, @@ -50,4 +51,4 @@ public class JsVariableTemplateSelector : IDataTemplate _ => DefaultTemplate }; } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/TextBoxAssist.cs b/SubathonManager.UI/UiUtils/TextBoxAssist.cs index 09b0c6ab..4634e735 100644 --- a/SubathonManager.UI/UiUtils/TextBoxAssist.cs +++ b/SubathonManager.UI/UiUtils/TextBoxAssist.cs @@ -8,38 +8,43 @@ namespace SubathonManager.UI.UiUtils; -public static class TextBoxAssist -{ +public static class TextBoxAssist { public static readonly AttachedProperty RevealProperty = AvaloniaProperty.RegisterAttached("Reveal", typeof(TextBoxAssist)); - public static bool GetReveal(TextBox t) => t.GetValue(RevealProperty); - public static void SetReveal(TextBox t, bool v) => t.SetValue(RevealProperty, v); - public static readonly AttachedProperty ClearProperty = AvaloniaProperty.RegisterAttached("Clear", typeof(TextBoxAssist)); - public static bool GetClear(TextBox t) => t.GetValue(ClearProperty); - public static void SetClear(TextBox t, bool v) => t.SetValue(ClearProperty, v); - - static TextBoxAssist() - { + static TextBoxAssist() { RevealProperty.Changed.AddClassHandler((t, _) => Rebuild(t)); ClearProperty.Changed.AddClassHandler((t, _) => Rebuild(t)); } - private static void Rebuild(TextBox tb) - { + public static bool GetReveal(TextBox t) { + return t.GetValue(RevealProperty); + } + + public static void SetReveal(TextBox t, bool v) { + t.SetValue(RevealProperty, v); + } + + public static bool GetClear(TextBox t) { + return t.GetValue(ClearProperty); + } + + public static void SetClear(TextBox t, bool v) { + t.SetValue(ClearProperty, v); + } + + private static void Rebuild(TextBox tb) { bool reveal = GetReveal(tb); bool clear = GetClear(tb); - if (!reveal && !clear) - { + if (!reveal && !clear) { tb.InnerRightContent = null; return; } - var panel = new StackPanel - { + var panel = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; @@ -48,10 +53,8 @@ private static void Rebuild(TextBox tb) tb.InnerRightContent = panel; } - private static Button BuildClearButton(TextBox tb) - { - var btn = new Button - { + private static Button BuildClearButton(TextBox tb) { + var btn = new Button { Width = 26, Height = 24, Padding = new Thickness(0), @@ -64,19 +67,23 @@ private static Button BuildClearButton(TextBox tb) }; btn.SetDynamicResource(TemplatedControl.ForegroundProperty, "TextFillColorSecondaryBrush"); ToolTip.SetTip(btn, "Clear"); - btn.Click += (_, _) => { tb.Clear(); tb.Focus(); }; + btn.Click += (_, _) => { + tb.Clear(); + tb.Focus(); + }; + + void Update() { + btn.IsVisible = !string.IsNullOrEmpty(tb.Text) && !tb.IsReadOnly; + } - void Update() => btn.IsVisible = !string.IsNullOrEmpty(tb.Text) && !tb.IsReadOnly; tb.TextChanged += (_, _) => Update(); Update(); return btn; } - private static ToggleButton BuildRevealButton(TextBox tb) - { + private static ToggleButton BuildRevealButton(TextBox tb) { var icon = new SymIcon { Glyph = "Eye16" }; - var btn = new ToggleButton - { + var btn = new ToggleButton { Width = 26, Height = 24, Padding = new Thickness(0), @@ -89,12 +96,11 @@ private static ToggleButton BuildRevealButton(TextBox tb) }; btn.SetDynamicResource(TemplatedControl.ForegroundProperty, "TextFillColorPrimaryBrush"); ToolTip.SetTip(btn, "Show / hide"); - btn.IsCheckedChanged += (_, _) => - { + btn.IsCheckedChanged += (_, _) => { bool on = btn.IsChecked == true; tb.RevealPassword = on; icon.Glyph = on ? "EyeOff16" : "Eye16"; }; return btn; } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/UiHelpers.cs b/SubathonManager.UI/UiUtils/UiHelpers.cs index 2dea0ca3..a1fe14c8 100644 --- a/SubathonManager.UI/UiUtils/UiHelpers.cs +++ b/SubathonManager.UI/UiUtils/UiHelpers.cs @@ -11,118 +11,106 @@ namespace SubathonManager.UI.UiUtils; -public static class UiHelpers -{ - public static async Task TrySetClipboardTextAsync(string text) - { - var clipboard = GetClipboard(); +public static class UiHelpers { + public static async Task TrySetClipboardTextAsync(string text) { + IClipboard? clipboard = GetClipboard(); if (clipboard == null) return false; - for (int i = 0; i < 5; i++) - { - try - { + for (var i = 0; i < 5; i++) + try { await Dispatcher.UIThread.InvokeAsync(async () => await clipboard.SetTextAsync(text)); return true; } - catch { await Task.Delay(50); } - } + catch { + await Task.Delay(50); + } + return false; } - private static IClipboard? GetClipboard() - { + private static IClipboard? GetClipboard() { if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) return desktop.MainWindow?.Clipboard; return null; } - - public static void EnableClickAwayUnfocus(TopLevel topLevel) - { - topLevel.AddHandler(InputElement.PointerPressedEvent, (_, e) => - { + + public static void EnableClickAwayUnfocus(TopLevel topLevel) { + topLevel.AddHandler(InputElement.PointerPressedEvent, (_, e) => { if (e.Source is Visual v && IsWithinTextInput(v)) return; topLevel.FocusManager?.Focus(null); - }, RoutingStrategies.Tunnel, handledEventsToo: true); + }, RoutingStrategies.Tunnel, true); } - private static bool IsWithinTextInput(Visual? v) - { - while (v != null) - { + private static bool IsWithinTextInput(Visual? v) { + while (v != null) { if (v is TextBox or AutoCompleteBox) return true; v = v.GetVisualParent(); } + return false; } - public static bool IsInteractiveSource(object? source, Visual? stopAt = null) - { + public static bool IsInteractiveSource(object? source, Visual? stopAt = null) { var v = source as Visual; - while (v != null && v != stopAt) - { + while (v != null && v != stopAt) { if (v is Button or CheckBox or RadioButton) return true; v = v.GetVisualParent(); } + return false; } - public static void UpdateButtonPendingBorder(Border border, bool hasPendingChanges) - { + public static void UpdateButtonPendingBorder(Border border, bool hasPendingChanges) { border.BorderBrush = hasPendingChanges ? new SolidColorBrush(Color.FromRgb(0xF5, 0xC5, 0x18)) : new SolidColorBrush(Colors.Transparent); } - public static bool RevealInFileManager(string? path) - { + public static bool RevealInFileManager(string? path) { if (string.IsNullOrWhiteSpace(path)) return false; if (!File.Exists(path)) return OpenFolder(Directory.Exists(path) ? path : Path.GetDirectoryName(path)); - try - { + try { // diff actions so not calling raw open folder, doesn't matter for linux - if (OperatingSystem.IsWindows()) - { + if (OperatingSystem.IsWindows()) { if (FileManagerFocus.TryFocusExplorer(Path.GetDirectoryName(path), path)) return true; Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true }); } - else if (OperatingSystem.IsMacOS()) + else if (OperatingSystem.IsMacOS()) { Process.Start(new ProcessStartInfo("open") { ArgumentList = { "-R", path } }); - else - { + } + else { if (FileManagerFocus.TryShowItemsOverDBus(path)) return true; return OpenFolder(Path.GetDirectoryName(path)); } + return true; } - catch - { + catch { return false; } } - public static bool OpenFolder(string? dir) - { + public static bool OpenFolder(string? dir) { if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir)) return false; - try - { - if (OperatingSystem.IsWindows()) - { + try { + if (OperatingSystem.IsWindows()) { if (FileManagerFocus.TryFocusExplorer(dir)) return true; Process.Start(new ProcessStartInfo("explorer.exe", $"\"{dir}\"") { UseShellExecute = true }); } - else if (OperatingSystem.IsMacOS()) + else if (OperatingSystem.IsMacOS()) { Process.Start(new ProcessStartInfo("open") { ArgumentList = { dir } }); - else + } + else { Process.Start(new ProcessStartInfo("xdg-open") { ArgumentList = { dir } }); + } + return true; } - catch - { + catch { return false; } } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/UiUtils/WindowIcons.cs b/SubathonManager.UI/UiUtils/WindowIcons.cs index 1e41008b..292aa991 100644 --- a/SubathonManager.UI/UiUtils/WindowIcons.cs +++ b/SubathonManager.UI/UiUtils/WindowIcons.cs @@ -3,32 +3,28 @@ namespace SubathonManager.UI.UiUtils; -public static class WindowIcons -{ +public static class WindowIcons { private const string IconUri = "avares://SubathonManager/Assets/icon_128.png"; private static WindowIcon? _icon; private static bool _loaded; - public static void Apply(Window window) - { + public static void Apply(Window window) { if (OperatingSystem.IsWindows()) return; if (Load() is { } icon) window.Icon = icon; } - private static WindowIcon? Load() - { + private static WindowIcon? Load() { if (_loaded) return _icon; _loaded = true; - try - { - using var stream = AssetLoader.Open(new Uri(IconUri)); + try { + using Stream stream = AssetLoader.Open(new Uri(IconUri)); _icon = new WindowIcon(stream); } - catch - { + catch { _icon = null; } + return _icon; } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/Validation/NumericInputBehaviour.cs b/SubathonManager.UI/Validation/NumericInputBehaviour.cs index 23e41696..732c6e69 100644 --- a/SubathonManager.UI/Validation/NumericInputBehaviour.cs +++ b/SubathonManager.UI/Validation/NumericInputBehaviour.cs @@ -4,10 +4,8 @@ namespace SubathonManager.UI.Validation; -public static class NumericInputBehaviour -{ - public enum NumericMode - { +public static class NumericInputBehaviour { + public enum NumericMode { None, Integer, SignedInteger, @@ -17,50 +15,50 @@ public enum NumericMode public static readonly AttachedProperty ModeProperty = AvaloniaProperty.RegisterAttached( - "Mode", typeof(NumericInputBehaviour), NumericMode.None); - - public static void SetMode(TextBox element, NumericMode value) => element.SetValue(ModeProperty, value); - public static NumericMode GetMode(TextBox element) => element.GetValue(ModeProperty); + "Mode", typeof(NumericInputBehaviour)); private static readonly Dictionary LastValid = new(); - static NumericInputBehaviour() - { + static NumericInputBehaviour() { ModeProperty.Changed.AddClassHandler(OnModeChanged); } - private static void OnModeChanged(TextBox tb, AvaloniaPropertyChangedEventArgs e) - { + public static void SetMode(TextBox element, NumericMode value) { + element.SetValue(ModeProperty, value); + } + + public static NumericMode GetMode(TextBox element) { + return element.GetValue(ModeProperty); + } + + private static void OnModeChanged(TextBox tb, AvaloniaPropertyChangedEventArgs e) { tb.TextChanged -= OnTextChanged; LastValid.Remove(tb); - if (GetMode(tb) != NumericMode.None) - { + if (GetMode(tb) != NumericMode.None) { LastValid[tb] = tb.Text ?? string.Empty; tb.TextChanged += OnTextChanged; tb.DetachedFromVisualTree += (_, _) => LastValid.Remove(tb); } } - private static void OnTextChanged(object? sender, TextChangedEventArgs e) - { + private static void OnTextChanged(object? sender, TextChangedEventArgs e) { if (sender is not TextBox tb) return; - var mode = GetMode(tb); + NumericMode mode = GetMode(tb); if (mode == NumericMode.None) return; - var text = tb.Text ?? string.Empty; - if (IsAcceptable(text, mode)) - { + string text = tb.Text ?? string.Empty; + if (IsAcceptable(text, mode)) { LastValid[tb] = text; return; } - var previous = LastValid.TryGetValue(tb, out var v) ? v : string.Empty; + + string previous = LastValid.TryGetValue(tb, out string? v) ? v : string.Empty; int caret = Math.Min(tb.CaretIndex, previous.Length); tb.Text = previous; tb.CaretIndex = caret; } - private static bool IsAcceptable(string text, NumericMode mode) - { + private static bool IsAcceptable(string text, NumericMode mode) { if (text.Length == 0) return true; bool allowSign = mode is NumericMode.SignedInteger or NumericMode.SignedDecimal; @@ -68,16 +66,16 @@ private static bool IsAcceptable(string text, NumericMode mode) if (text == "-") return allowSign; if (text == ".") return allowDecimal; - if (text == "-." ) return allowSign && allowDecimal; + if (text == "-.") return allowSign && allowDecimal; - if (allowDecimal) - { - var styles = NumberStyles.AllowDecimalPoint | (allowSign ? NumberStyles.AllowLeadingSign : NumberStyles.None); + if (allowDecimal) { + NumberStyles styles = NumberStyles.AllowDecimalPoint | + (allowSign ? NumberStyles.AllowLeadingSign : NumberStyles.None); return double.TryParse(text, styles, CultureInfo.InvariantCulture, out _); } - var intStyles = allowSign ? NumberStyles.AllowLeadingSign : NumberStyles.None; + NumberStyles intStyles = allowSign ? NumberStyles.AllowLeadingSign : NumberStyles.None; return int.TryParse(text, intStyles, CultureInfo.InvariantCulture, out _) || long.TryParse(text, intStyles, CultureInfo.InvariantCulture, out _); } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/CssColorPicker.axaml b/SubathonManager.UI/Views/CssColorPicker.axaml index 45d353c1..fa0f8dd2 100644 --- a/SubathonManager.UI/Views/CssColorPicker.axaml +++ b/SubathonManager.UI/Views/CssColorPicker.axaml @@ -10,14 +10,14 @@ + ColorChanged="ColorView_ColorChanged" /> + HorizontalAlignment="Stretch" VerticalAlignment="Stretch" /> + TextChanged="CssValueBox_TextChanged" LostFocus="CssValueBox_LostFocus" /> - + \ No newline at end of file diff --git a/SubathonManager.UI/Views/CssColorPicker.axaml.cs b/SubathonManager.UI/Views/CssColorPicker.axaml.cs index d2a32f6e..7fc88cf9 100644 --- a/SubathonManager.UI/Views/CssColorPicker.axaml.cs +++ b/SubathonManager.UI/Views/CssColorPicker.axaml.cs @@ -2,110 +2,99 @@ using System.Text.RegularExpressions; using Avalonia; using Avalonia.Controls; +using Avalonia.Data; using Avalonia.Interactivity; using Avalonia.Media; namespace SubathonManager.UI.Views; -public partial class CssColorPicker : UserControl -{ +public partial class CssColorPicker : UserControl { public static readonly StyledProperty CssColorProperty = AvaloniaProperty.Register( - nameof(CssColor), string.Empty, defaultBindingMode: global::Avalonia.Data.BindingMode.TwoWay); + nameof(CssColor), string.Empty, defaultBindingMode: BindingMode.TwoWay); - public string CssColor - { - get => GetValue(CssColorProperty); - set => SetValue(CssColorProperty, value); - } - - public event EventHandler? ColorChanged; private bool _updatingFromInternal; - public CssColorPicker() - { + public CssColorPicker() { InitializeComponent(); - Loaded += (_, _) => - { + Loaded += (_, _) => { if (!string.IsNullOrWhiteSpace(CssColor)) ApplyCssString(CssColor); }; } - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { + public string CssColor { + get => GetValue(CssColorProperty); + set => SetValue(CssColorProperty, value); + } + + public event EventHandler? ColorChanged; + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == CssColorProperty && !_updatingFromInternal) ApplyCssString(change.GetNewValue()); } - private void ColorView_ColorChanged(object? sender, ColorChangedEventArgs e) - { + private void ColorView_ColorChanged(object? sender, ColorChangedEventArgs e) { if (_updatingFromInternal) return; SyncFromColor(e.NewColor); } - private void CssValueBox_TextChanged(object? sender, TextChangedEventArgs e) - { + private void CssValueBox_TextChanged(object? sender, TextChangedEventArgs e) { if (_updatingFromInternal) return; ApplyCssString(CssValueBox.Text ?? string.Empty); } - private void CssValueBox_LostFocus(object? sender, RoutedEventArgs e) - { - if (TryParseColor(CssValueBox.Text, out var c)) SyncFromColor(c); + private void CssValueBox_LostFocus(object? sender, RoutedEventArgs e) { + if (TryParseColor(CssValueBox.Text, out Color c)) SyncFromColor(c); } - private void SyncFromColor(Color color) - { - var css = ColorToCssString(color); + private void SyncFromColor(Color color) { + string css = ColorToCssString(color); bool changed = css != CssColor; _updatingFromInternal = true; - try - { + try { if (ColorViewCtl.Color != color) ColorViewCtl.Color = color; SwatchFill.Background = new SolidColorBrush(color); if (CssValueBox.Text != css) CssValueBox.Text = css; CssColor = css; } - finally - { + finally { _updatingFromInternal = false; } + if (changed) ColorChanged?.Invoke(this, new RoutedEventArgs()); } - private void ApplyCssString(string? css) - { - if (string.IsNullOrWhiteSpace(css) || !TryParseColor(css, out var color)) return; + private void ApplyCssString(string? css) { + if (string.IsNullOrWhiteSpace(css) || !TryParseColor(css, out Color color)) return; _updatingFromInternal = true; - try - { + try { if (ColorViewCtl.Color != color) ColorViewCtl.Color = color; SwatchFill.Background = new SolidColorBrush(color); if (CssValueBox.Text != css) CssValueBox.Text = css; } - finally - { + finally { _updatingFromInternal = false; } } - private static string ColorToCssString(Color c) - => c.A == 255 ? $"#{c.R:X2}{c.G:X2}{c.B:X2}" : $"rgba({c.R},{c.G},{c.B},{c.A / 255.0:F2})"; + private static string ColorToCssString(Color c) { + return c.A == 255 ? $"#{c.R:X2}{c.G:X2}{c.B:X2}" : $"rgba({c.R},{c.G},{c.B},{c.A / 255.0:F2})"; + } - [GeneratedRegex(@"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)", RegexOptions.IgnoreCase, "en-CA")] + [GeneratedRegex(@"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)", RegexOptions.IgnoreCase, + "en-CA")] private static partial Regex IsRgbaColourParseRegex(); - private static bool TryParseColor(string? css, out Color color) - { + private static bool TryParseColor(string? css, out Color color) { color = Colors.White; if (string.IsNullOrWhiteSpace(css)) return false; css = css.Trim(); - var m = IsRgbaColourParseRegex().Match(css); - if (m.Success) - { + Match m = IsRgbaColourParseRegex().Match(css); + if (m.Success) { byte r = byte.Parse(m.Groups[1].Value); byte g = byte.Parse(m.Groups[2].Value); byte b = byte.Parse(m.Groups[3].Value); @@ -118,4 +107,4 @@ private static bool TryParseColor(string? css, out Color color) return Color.TryParse(css, out color); } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/EventListView.axaml b/SubathonManager.UI/Views/EventListView.axaml index e36679af..8587f2bd 100644 --- a/SubathonManager.UI/Views/EventListView.axaml +++ b/SubathonManager.UI/Views/EventListView.axaml @@ -10,40 +10,40 @@ Background="Transparent"> @@ -52,7 +52,7 @@ - + @@ -64,46 +64,50 @@ + FontSize="16" FontWeight="Bold" /> + FontSize="12" /> + FontSize="14" + Foreground="{DynamicResource TextFillColorPrimaryBrushSoft}" + HorizontalAlignment="Left" VerticalAlignment="Center" /> + FontWeight="SemiBold" HorizontalAlignment="Right" + VerticalAlignment="Center" + FontSize="14" Margin="0,4,0,0" /> - - - - - - + + + + + + - + - - - - - + + + + + @@ -112,23 +116,24 @@ - + + IsVisible="{Binding SecondsValue, Converter={StaticResource IsNotZeroToVisibilityConverter}}" /> + IsVisible="{Binding SecondsValue, Converter={StaticResource IsNotZeroToVisibilityConverter}}" /> + IsVisible="{Binding PointsValue, Converter={StaticResource IsNotZeroToVisibilityConverter}}" /> + IsVisible="{Binding PointsValue, Converter={StaticResource IsNotZeroToVisibilityConverter}}" /> - + - - + + @@ -144,12 +149,13 @@ ToolTip.Tip="Retry" IsVisible="{Binding ProcessedToSubathon, Converter={StaticResource InverseBoolToVisibilityConverter}}" Click="ReprocessBtn_Click"> - + - @@ -159,4 +165,4 @@ - + \ No newline at end of file diff --git a/SubathonManager.UI/Views/EventListView.axaml.cs b/SubathonManager.UI/Views/EventListView.axaml.cs index a55e762d..f595a85c 100644 --- a/SubathonManager.UI/Views/EventListView.axaml.cs +++ b/SubathonManager.UI/Views/EventListView.axaml.cs @@ -14,15 +14,12 @@ namespace SubathonManager.UI.Views; -public partial class EventListView : UserControl -{ - public ObservableCollection EventItems { get; set; } = new(); - private readonly int _maxItems = 20; - private readonly IDbContextFactory _factory; +public partial class EventListView : UserControl { private readonly IConfig _config; + private readonly IDbContextFactory _factory; + private readonly int _maxItems = 20; - public EventListView() - { + public EventListView() { _factory = AppServices.Provider.GetRequiredService>(); InitializeComponent(); EventListPanel.ItemsSource = EventItems; @@ -34,25 +31,27 @@ public EventListView() SettingsEvents.EventVisibilityChanged += OnEventVisibilityChanged; } - private void OnSubathonEventsDeleted(List events) - => Task.Run(async () => await LoadRecentEvents()); + public ObservableCollection EventItems { get; set; } = new(); + + private void OnSubathonEventsDeleted(List events) { + Task.Run(async () => await LoadRecentEvents()); + } - private void OnEventVisibilityChanged() - => Task.Run(async () => await LoadRecentEvents()); + private void OnEventVisibilityChanged() { + Task.Run(async () => await LoadRecentEvents()); + } - private async void OnSubathonEventProcessed(SubathonEvent subathonEvent, bool wasEffective) - { - bool showOverride = _config.GetBool("App", "ShowLockedEvents", false); + private async void OnSubathonEventProcessed(SubathonEvent subathonEvent, bool wasEffective) { + bool showOverride = _config.GetBool("App", "ShowLockedEvents"); if (!showOverride && subathonEvent.PointsValue < 1 - && subathonEvent.GetFinalSecondsValueRaw() <= 0 - && subathonEvent.EventType != SubathonEventType.Command - && subathonEvent.EventType != SubathonEventType.DonationAdjustment - && subathonEvent.EventType != SubathonEventType.TwitchHypeTrain) return; - - await Dispatcher.UIThread.InvokeAsync(() => - { - var existing = EventItems.FirstOrDefault(x => x.Id == subathonEvent.Id); + && subathonEvent.GetFinalSecondsValueRaw() <= 0 + && subathonEvent.EventType != SubathonEventType.Command + && subathonEvent.EventType != SubathonEventType.DonationAdjustment + && subathonEvent.EventType != SubathonEventType.TwitchHypeTrain) return; + + await Dispatcher.UIThread.InvokeAsync(() => { + SubathonEvent? existing = EventItems.FirstOrDefault(x => x.Id == subathonEvent.Id); if (existing != null) EventItems.Remove(existing); @@ -62,14 +61,12 @@ await Dispatcher.UIThread.InvokeAsync(() => }); } - private async Task LoadRecentEvents() - { - bool showOverride = _config.GetBool("App", "ShowLockedEvents", false); - await using var db = await _factory.CreateDbContextAsync(); + private async Task LoadRecentEvents() { + bool showOverride = _config.GetBool("App", "ShowLockedEvents"); + await using AppDbContext db = await _factory.CreateDbContextAsync(); SubathonData? subathon = await db.SubathonDatas.AsNoTracking().FirstOrDefaultAsync(s => s.IsActive); List events = new(); if (subathon != null) - { events = await db.SubathonEvents.Where(ev => ev.SubathonId == subathon.Id && (showOverride || ev.SecondsValue > 0 || ev.PointsValue >= 1 || ev.Command != SubathonCommandType.None @@ -79,23 +76,17 @@ private async Task LoadRecentEvents() .OrderByDescending(e => e.EventTimestamp) .Take(_maxItems) .ToListAsync(); - } - await Dispatcher.UIThread.InvokeAsync(() => - { + await Dispatcher.UIThread.InvokeAsync(() => { EventItems.Clear(); events.ForEach(ev => EventItems.Add(ev)); }); } - private void ReprocessBtn_Click(object? sender, RoutedEventArgs e) - { + private void ReprocessBtn_Click(object? sender, RoutedEventArgs e) { if (sender is Button { DataContext: SubathonEvent ev }) - { - Task.Run(() => - { - SubathonEvent newEv = new SubathonEvent - { + Task.Run(() => { + var newEv = new SubathonEvent { Id = ev.Id, SubathonId = ev.SubathonId, EventTimestamp = ev.EventTimestamp, @@ -107,24 +98,21 @@ private void ReprocessBtn_Click(object? sender, RoutedEventArgs e) Amount = ev.Amount, Currency = ev.Currency, SecondsValue = ev.SecondsValue, - PointsValue = ev.PointsValue, + PointsValue = ev.PointsValue }; - using var db = _factory.CreateDbContext(); + using AppDbContext db = _factory.CreateDbContext(); ServiceManager.EventsOrNull?.DeleteSubathonEvent(db, ev); SubathonEvents.RaiseSubathonEventCreated(newEv); }); - } } - private void DeleteBtn_Click(object? sender, RoutedEventArgs e) - { + private void DeleteBtn_Click(object? sender, RoutedEventArgs e) { if (sender is not Button { DataContext: SubathonEvent ev }) return; if (ev.Command.IsControlTypeCommand()) return; - Task.Run(() => - { - using var db = _factory.CreateDbContext(); + Task.Run(() => { + using AppDbContext db = _factory.CreateDbContext(); ServiceManager.EventsOrNull?.DeleteSubathonEvent(db, ev); }); } -} +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/ExportOverlayDialog.axaml b/SubathonManager.UI/Views/ExportOverlayDialog.axaml index 39113779..fd96ea9b 100644 --- a/SubathonManager.UI/Views/ExportOverlayDialog.axaml +++ b/SubathonManager.UI/Views/ExportOverlayDialog.axaml @@ -14,48 +14,50 @@ - - + + - + + VerticalContentAlignment="Center" /> - + + VerticalContentAlignment="Center" /> - + + ToolTip.Tip="Groups this overlay under imports/overlays/<author> when imported" /> - + + ToolTip.Tip="Comma separated labels describing the overlay" /> - + + VerticalAlignment="Center" IsThreeState="True" IsCheckedChanged="SelectAllBox_Changed" /> + Text="Please verify the files to be included in the export. Not all selected may be in use." /> - + @@ -65,16 +67,16 @@ BorderThickness="1" CornerRadius="4" Padding="10,8" Margin="0,0,0,14"> + Margin="0,0,8,0" VerticalAlignment="Top" /> + Text="This export may not include all required files, and may include many not required. Files referenced by hardcoded paths inside widget HTML/JS/CSS sources, or assets outside each widget's folder, may not be bundled automatically." /> - - + + - - + + @@ -31,48 +34,53 @@ - + + SelectionChanged="GoalSetSelectorBox_SelectionChanged" /> - - + Margin="238,0,0,0" SelectionChanged="GoalSetType_SelectionChanged" /> - - + + - - + + - - + - + \ No newline at end of file diff --git a/SubathonManager.UI/Views/ObsBrowserSourcesPanel.axaml.cs b/SubathonManager.UI/Views/ObsBrowserSourcesPanel.axaml.cs index c1189b7c..788b4cc3 100644 --- a/SubathonManager.UI/Views/ObsBrowserSourcesPanel.axaml.cs +++ b/SubathonManager.UI/Views/ObsBrowserSourcesPanel.axaml.cs @@ -1,5 +1,7 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Documents; +using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; @@ -10,6 +12,7 @@ using SubathonManager.Core.Enums; using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.Integration; @@ -18,19 +21,16 @@ namespace SubathonManager.UI.Views; -public partial class ObsBrowserSourcesPanel : UserControl -{ +public partial class ObsBrowserSourcesPanel : UserControl { + private List _lastCards = []; + private bool? _lastScriptStatus; private bool _refreshing; private bool _refreshQueued; - private bool? _lastScriptStatus; - private List _lastCards = []; - public ObsBrowserSourcesPanel() - { + public ObsBrowserSourcesPanel() { InitializeComponent(); - Loaded += (_, _) => - { + Loaded += (_, _) => { IntegrationEvents.ConnectionUpdated -= OnConnectionUpdated; IntegrationEvents.ConnectionUpdated += OnConnectionUpdated; ServiceManager.OBS.HelperScriptStatusChanged -= OnHelperScriptStatusChanged; @@ -39,16 +39,14 @@ public ObsBrowserSourcesPanel() ServiceManager.OBS.BrowserSourcesChanged += OnBrowserSourcesChanged; _ = RefreshAsync(); }; - Unloaded += (_, _) => - { + Unloaded += (_, _) => { IntegrationEvents.ConnectionUpdated -= OnConnectionUpdated; ServiceManager.OBS.HelperScriptStatusChanged -= OnHelperScriptStatusChanged; ServiceManager.OBS.BrowserSourcesChanged -= OnBrowserSourcesChanged; }; } - private void UpdatePanelStatusText(int count, bool scriptActive) - { + private void UpdatePanelStatusText(int count, bool scriptActive) { PanelStatusText.Text = count == 0 ? "No overlay browser sources found" : scriptActive @@ -56,36 +54,33 @@ private void UpdatePanelStatusText(int count, bool scriptActive) : $"{count} source(s) - helper script not loaded, SRGB control unavailable"; } - private void OnBrowserSourcesChanged() - => Dispatcher.UIThread.Post(() => _ = RefreshAsync()); + private void OnBrowserSourcesChanged() { + Dispatcher.UIThread.Post(() => _ = RefreshAsync()); + } - private void OnHelperScriptStatusChanged(bool active) - { + private void OnHelperScriptStatusChanged(bool active) { if (_lastScriptStatus == active) return; Dispatcher.UIThread.Post(() => _ = RefreshAsync()); } - private void OnConnectionUpdated(IntegrationConnection? connection) - { + private void OnConnectionUpdated(IntegrationConnection? connection) { if (connection is not { Source: SubathonEventSource.OBS, Service: "OBS" }) return; Dispatcher.UIThread.Post(() => _ = RefreshAsync()); } - private void Refresh_Click(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e) - => _ = RefreshAsync(); + private void Refresh_Click(object? sender, RoutedEventArgs e) { + _ = RefreshAsync(); + } - private async Task RefreshAsync() - { - if (_refreshing) - { + private async Task RefreshAsync() { + if (_refreshing) { _refreshQueued = true; return; } + _refreshing = true; - try - { - if (!ServiceManager.OBS.Connected) - { + try { + if (!ServiceManager.OBS.Connected) { CardsStack.Children.Clear(); _lastCards = []; _lastScriptStatus = null; @@ -99,68 +94,62 @@ private async Task RefreshAsync() ServiceManager.OBS.RecheckHelperScript(); var config = AppServices.Provider.GetRequiredService(); - var port = config.Get("Server", "Port", "14040") ?? "14040"; + string port = config.Get("Server", "Port", "14040") ?? "14040"; - var cards = await Task.Run(() => ServiceManager.OBS.GetOverlayBrowserSourcesAsync(port)); + List cards = + await Task.Run(() => ServiceManager.OBS.GetOverlayBrowserSourcesAsync(port)); Dictionary routeNames; var factory = AppServices.Provider.GetRequiredService>(); - await using (var db = await factory.CreateDbContextAsync()) - { + await using (AppDbContext db = await factory.CreateDbContextAsync()) { routeNames = await db.Routes.ToDictionaryAsync(r => r.Id, r => r.Name); } bool scriptActive = ServiceManager.OBS.HelperScriptActive; - var ordered = cards.OrderBy(c => c.SceneName).ThenBy(c => c.SourceName).ToList(); + List ordered = cards.OrderBy(c => c.SceneName).ThenBy(c => c.SourceName).ToList(); - if (scriptActive == _lastScriptStatus && ordered.SequenceEqual(_lastCards)) - { + if (scriptActive == _lastScriptStatus && ordered.SequenceEqual(_lastCards)) { UpdatePanelStatusText(ordered.Count, scriptActive); return; } + _lastScriptStatus = scriptActive; _lastCards = ordered; CardsStack.Children.Clear(); - foreach (var card in ordered) - { + foreach (ObsBrowserSourceCard card in ordered) { string? overlayName = null; - bool unknownOverlay = false; - if (card.RouteId.HasValue) - { - if (routeNames.TryGetValue(card.RouteId.Value, out var name)) overlayName = name; + var unknownOverlay = false; + if (card.RouteId.HasValue) { + if (routeNames.TryGetValue(card.RouteId.Value, out string? name)) overlayName = name; else unknownOverlay = true; } + CardsStack.Children.Add(BuildCard(card, overlayName, unknownOverlay)); } UpdatePanelStatusText(ordered.Count, scriptActive); } - catch (Exception) - { + catch (Exception) { PanelStatusText.Text = "Failed to query OBS"; } - finally - { + finally { _refreshing = false; } - if (_refreshQueued) - { + if (_refreshQueued) { _refreshQueued = false; await RefreshAsync(); } } - private Control BuildCard(ObsBrowserSourceCard card, string? overlayName, bool unknownOverlay) - { - var border = new Border - { + private Control BuildCard(ObsBrowserSourceCard card, string? overlayName, bool unknownOverlay) { + var border = new Border { BorderBrush = new SolidColorBrush(Color.Parse("#333")), - BorderThickness = new global::Avalonia.Thickness(1), - CornerRadius = new global::Avalonia.CornerRadius(6), - Padding = new global::Avalonia.Thickness(10, 8, 12, 8), - Margin = new global::Avalonia.Thickness(0, 0, 12, 8) + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(10, 8, 12, 8), + Margin = new Thickness(0, 0, 12, 8) }; var grid = new Grid(); @@ -172,8 +161,7 @@ private Control BuildCard(ObsBrowserSourceCard card, string? overlayName, bool u titleGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); titleGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); - var title = new TextBlock - { + var title = new TextBlock { Text = card.SourceName, TextTrimming = TextTrimming.CharacterEllipsis, FontSize = 13, @@ -182,20 +170,17 @@ private Control BuildCard(ObsBrowserSourceCard card, string? overlayName, bool u ToolTip.SetTip(title, card.Url); titleGrid.Children.Add(title); - var overlayLabel = new TextBlock - { + var overlayLabel = new TextBlock { FontSize = 11, VerticalAlignment = VerticalAlignment.Center, - Margin = new global::Avalonia.Thickness(8, 0, 0, 0) + Margin = new Thickness(8, 0, 0, 0) }; - if (unknownOverlay) - { + if (unknownOverlay) { overlayLabel.Text = "Unknown Overlay"; overlayLabel.Foreground = Brushes.Orange; ToolTip.SetTip(overlayLabel, "URL points at an overlay that no longer exists in SubathonManager"); } - else if (!string.IsNullOrEmpty(overlayName)) - { + else if (!string.IsNullOrEmpty(overlayName)) { overlayLabel.Text = overlayName; overlayLabel.Foreground = Brushes.CornflowerBlue; } @@ -204,10 +189,9 @@ private Control BuildCard(ObsBrowserSourceCard card, string? overlayName, bool u titleGrid.Children.Add(overlayLabel); grid.Children.Add(titleGrid); - var sceneText = new TextBlock - { + var sceneText = new TextBlock { FontSize = 11, - Margin = new global::Avalonia.Thickness(0, 1, 0, 6), + Margin = new Thickness(0, 1, 0, 6), TextTrimming = TextTrimming.CharacterEllipsis }; sceneText.Inlines?.Add(new Run("Scene: ") { Foreground = ThemeBrush("TextFillColorSecondaryBrush") }); @@ -223,8 +207,7 @@ private Control BuildCard(ObsBrowserSourceCard card, string? overlayName, bool u var controls = new StackPanel { Orientation = Orientation.Horizontal }; controlsRow.Children.Add(controls); - var widthBox = new TextBox - { + var widthBox = new TextBox { Text = card.Width.ToString(), Width = 62, Height = 30, @@ -232,49 +215,44 @@ private Control BuildCard(ObsBrowserSourceCard card, string? overlayName, bool u VerticalContentAlignment = VerticalAlignment.Center }; ToolTip.SetTip(widthBox, "Width"); - var heightBox = new TextBox - { + var heightBox = new TextBox { Text = card.Height.ToString(), Width = 62, Height = 30, FontSize = 12, - Margin = new global::Avalonia.Thickness(4, 0, 0, 0), + Margin = new Thickness(4, 0, 0, 0), VerticalContentAlignment = VerticalAlignment.Center }; ToolTip.SetTip(heightBox, "Height"); - var applySizeBtn = new Button - { + var applySizeBtn = new Button { Content = "Set", Height = 30, - Padding = new global::Avalonia.Thickness(10, 2, 10, 2), - Margin = new global::Avalonia.Thickness(6, 0, 0, 0) + Padding = new Thickness(10, 2, 10, 2), + Margin = new Thickness(6, 0, 0, 0) }; ToolTip.SetTip(applySizeBtn, "Apply resolution (not transform) to the browser source"); - applySizeBtn.Click += (_, _) => - { + applySizeBtn.Click += (_, _) => { if (!int.TryParse(widthBox.Text?.Trim(), out int w) || w < 1 || !int.TryParse(heightBox.Text?.Trim(), out int h) || h < 1) return; ServiceManager.OBS.SetBrowserSourceSize(card.SourceName, w, h); }; controls.Children.Add(widthBox); - controls.Children.Add(new TextBlock - { + controls.Children.Add(new TextBlock { Text = "x", VerticalAlignment = VerticalAlignment.Center, - Margin = new global::Avalonia.Thickness(4, 0, 0, 0) + Margin = new Thickness(4, 0, 0, 0) }); controls.Children.Add(heightBox); controls.Children.Add(applySizeBtn); bool scriptActive = ServiceManager.OBS.HelperScriptActive; - var srgbCheck = new CheckBox - { + var srgbCheck = new CheckBox { Content = "SRGB Off", FontSize = 12, VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center, - Margin = new global::Avalonia.Thickness(16, 0, 0, 0), + Margin = new Thickness(16, 0, 0, 0), IsChecked = card.SrgbOff, IsThreeState = false, IsEnabled = scriptActive @@ -287,42 +265,39 @@ await ServiceManager.OBS.RequestBlendMethodAsync( card.SourceName, card.SceneName, card.SceneItemId, srgbCheck.IsChecked ?? false); controls.Children.Add(srgbCheck); - var visibilityBtn = MakeIconButton(card.Visible ? "Eye24" : "EyeOff24", - card.Visible ? "Visible" : "Hidden", leftMargin: 16); + Button visibilityBtn = MakeIconButton(card.Visible ? "Eye24" : "EyeOff24", + card.Visible ? "Visible" : "Hidden", 16); bool currentVisible = card.Visible; - visibilityBtn.Click += (_, _) => - { + visibilityBtn.Click += (_, _) => { bool newVisible = !currentVisible; ServiceManager.OBS.SetSceneItemVisible(card.SceneName, card.SceneItemId, newVisible); currentVisible = newVisible; - if (visibilityBtn.Content is SymIcon icon) - { + if (visibilityBtn.Content is SymIcon icon) { icon.Glyph = newVisible ? "Eye24" : "EyeOff24"; icon.FontSize = 16; } + ToolTip.SetTip(visibilityBtn, newVisible ? "Visible" : "Hidden"); }; controls.Children.Add(visibilityBtn); - var rightBtns = new StackPanel - { + var rightBtns = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right }; Grid.SetColumn(rightBtns, 1); - if (!string.IsNullOrEmpty(overlayName) && card.RouteId.HasValue) - { - var editBtn = MakeIconButton("Edit24", "Edit Overlay", leftMargin: 12); + if (!string.IsNullOrEmpty(overlayName) && card.RouteId.HasValue) { + Button editBtn = MakeIconButton("Edit24", "Edit Overlay", 12); editBtn.Click += async (_, _) => await OpenOverlayEditorAsync(card.RouteId.Value); rightBtns.Children.Add(editBtn); } - var refreshBtn = MakeIconButton("ArrowClockwise24", "Refresh source inside OBS", leftMargin: 6); + Button refreshBtn = MakeIconButton("ArrowClockwise24", "Refresh source inside OBS", 6); refreshBtn.Click += (_, _) => ServiceManager.OBS.RefreshBrowserSource(card.SourceName); rightBtns.Children.Add(refreshBtn); - var deleteBtn = MakeIconButton("Delete24", "Remove browser source from OBS", leftMargin: 6, danger: true); + Button deleteBtn = MakeIconButton("Delete24", "Remove browser source from OBS", 6, true); deleteBtn.Click += async (_, _) => await ConfirmAndDeleteAsync(card); rightBtns.Children.Add(deleteBtn); @@ -333,14 +308,12 @@ await ServiceManager.OBS.RequestBlendMethodAsync( return border; } - private Button MakeIconButton(string glyph, string tooltip, double leftMargin, bool danger = false) - { - var btn = new Button - { + private Button MakeIconButton(string glyph, string tooltip, double leftMargin, bool danger = false) { + var btn = new Button { Width = 32, Height = 30, - Padding = new global::Avalonia.Thickness(2), - Margin = new global::Avalonia.Thickness(leftMargin, 0, 0, 0), + Padding = new Thickness(2), + Margin = new Thickness(leftMargin, 0, 0, 0), Content = new SymIcon { Glyph = glyph, FontSize = 16 } }; btn.Classes.Add("iconbtn"); @@ -349,20 +322,17 @@ private Button MakeIconButton(string glyph, string tooltip, double leftMargin, b return btn; } - private async Task OpenOverlayEditorAsync(Guid routeId) - { + private async Task OpenOverlayEditorAsync(Guid routeId) { var factory = AppServices.Provider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - var route = await db.Routes.FirstOrDefaultAsync(r => r.Id == routeId); + await using AppDbContext db = await factory.CreateDbContextAsync(); + Route? route = await db.Routes.FirstOrDefaultAsync(r => r.Id == routeId); if (route == null) return; (TopLevel.GetTopLevel(this) as MainWindow)?.OpenRouteEditor(route); } - private async Task ConfirmAndDeleteAsync(ObsBrowserSourceCard card) - { - var dialog = new FAContentDialog - { + private async Task ConfirmAndDeleteAsync(ObsBrowserSourceCard card) { + var dialog = new FAContentDialog { Title = "Delete Browser Source", Content = $"Delete source '{card.SourceName}' from OBS?\n", PrimaryButtonText = "Delete", @@ -375,8 +345,9 @@ private async Task ConfirmAndDeleteAsync(ObsBrowserSourceCard card) ServiceManager.OBS.RemoveBrowserSource(card.SourceName); } - private IBrush ThemeBrush(string key) - => this.TryFindResource(key, this.ActualThemeVariant, out var b) && b is IBrush brush + private IBrush ThemeBrush(string key) { + return this.TryFindResource(key, this.ActualThemeVariant, out object? b) && b is IBrush brush ? brush : Brushes.Gray; -} + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/Prompts/PromptRunListView.axaml b/SubathonManager.UI/Views/Prompts/PromptRunListView.axaml index 25afd6c7..d348b65a 100644 --- a/SubathonManager.UI/Views/Prompts/PromptRunListView.axaml +++ b/SubathonManager.UI/Views/Prompts/PromptRunListView.axaml @@ -7,14 +7,14 @@ x:CompileBindings="False" Background="Transparent" x:Class="SubathonManager.UI.Views.Prompts.PromptRunListView"> - + - + @@ -24,37 +24,47 @@ + FontSize="15" FontWeight="Bold" TextWrapping="Wrap" /> + Foreground="{DynamicResource TextFillColorPrimaryBrushSoft}" + FontSize="12" /> + Foreground="{DynamicResource TextFillColorPrimaryBrushSoft}" + Margin="0,0,6,0" /> - + Foreground="{DynamicResource TextFillColorPrimaryBrushSoft}" + Margin="0,0,6,0" /> + - + - + Foreground="{DynamicResource TextFillColorPrimaryBrushSoft}" /> + - + - - + + @@ -63,4 +73,4 @@ - + \ No newline at end of file diff --git a/SubathonManager.UI/Views/Prompts/PromptRunListView.axaml.cs b/SubathonManager.UI/Views/Prompts/PromptRunListView.axaml.cs index 0d549340..e7a0d0de 100644 --- a/SubathonManager.UI/Views/Prompts/PromptRunListView.axaml.cs +++ b/SubathonManager.UI/Views/Prompts/PromptRunListView.axaml.cs @@ -13,14 +13,11 @@ namespace SubathonManager.UI.Views.Prompts; -public partial class PromptRunListView : UserControl -{ - public ObservableCollection RunItems { get; } = new(); +public partial class PromptRunListView : UserControl { private const int MaxItems = 20; private readonly IDbContextFactory _factory; - public PromptRunListView() - { + public PromptRunListView() { _factory = AppServices.Provider.GetRequiredService>(); InitializeComponent(); PromptRunListPanel.ItemsSource = RunItems; @@ -32,65 +29,59 @@ public PromptRunListView() SubathonEvents.PromptRunProgressUpdated += OnProgressUpdated; } - private void OnRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) - { - Dispatcher.UIThread.Post(() => - { - var vm = RunItems.FirstOrDefault(v => v.RunId == run.Id); - if (vm != null) - { + public ObservableCollection RunItems { get; } = new(); + + private void OnRunUpdate(SubathonPromptRun run, SubathonPrompt? prompt) { + Dispatcher.UIThread.Post(() => { + PromptRunViewModel? vm = RunItems.FirstOrDefault(v => v.RunId == run.Id); + if (vm != null) { vm.UpdateFromRun(run); if (run.Status == SubathonPromptRunStatus.Completed) vm.Progress = vm.SnapshotTargetValue; else if (run.Status != SubathonPromptRunStatus.Active) vm.Progress = 0; } - else - { + else { _ = LoadRecentRuns(); } }); } - private void OnProgressUpdated(SubathonPromptRun run, long progress) - { - Dispatcher.UIThread.Post(() => - { - var vm = RunItems.FirstOrDefault(v => v.RunId == run.Id); + private void OnProgressUpdated(SubathonPromptRun run, long progress) { + Dispatcher.UIThread.Post(() => { + PromptRunViewModel? vm = RunItems.FirstOrDefault(v => v.RunId == run.Id); if (vm != null) vm.Progress = progress; }); } - private async Task LoadRecentRuns() - { + private async Task LoadRecentRuns() { await Task.Delay(200); - await using var db = await _factory.CreateDbContextAsync(); - var runs = await db.SubathonPromptRuns + await using AppDbContext db = await _factory.CreateDbContextAsync(); + List runs = await db.SubathonPromptRuns .Include(r => r.LinkedPrompt) .OrderByDescending(r => r.StartedAt) .Take(MaxItems) .AsNoTracking() .ToListAsync(); - await Dispatcher.UIThread.InvokeAsync(() => - { - var existingProgress = RunItems.ToDictionary(v => v.RunId, v => v.Progress); + await Dispatcher.UIThread.InvokeAsync(() => { + Dictionary existingProgress = RunItems.ToDictionary(v => v.RunId, v => v.Progress); RunItems.Clear(); - foreach (var run in runs) - { - existingProgress.TryGetValue(run.Id, out var progress); + foreach (SubathonPromptRun run in runs) { + existingProgress.TryGetValue(run.Id, out long progress); RunItems.Add(new PromptRunViewModel(run, progress)); } }); } } -public class PromptRunViewModel(SubathonPromptRun run, long progress = 0) : INotifyPropertyChanged -{ - public event PropertyChangedEventHandler? PropertyChanged; - private void Notify([CallerMemberName] string? n = null) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); +public class PromptRunViewModel(SubathonPromptRun run, long progress = 0) : INotifyPropertyChanged { + private DateTime? _endedAt = run.EndedAt; + + private long _progress = progress; + + private SubathonPromptRunStatus _status = run.Status; public SubathonPromptRun Run { get; } = run; public Guid RunId => Run.Id; @@ -99,25 +90,31 @@ private void Notify([CallerMemberName] string? n = null) public DateTime StartedAt => Run.StartedAt; public long SnapshotTargetValue => Run.SnapshotTargetValue; - private SubathonPromptRunStatus _status = run.Status; - public SubathonPromptRunStatus Status - { + public SubathonPromptRunStatus Status { get => _status; - set { _status = value; Notify(); Notify(nameof(IsActive)); } + set { + _status = value; + Notify(); + Notify(nameof(IsActive)); + } } - private DateTime? _endedAt = run.EndedAt; - public DateTime? EndedAt - { + public DateTime? EndedAt { get => _endedAt; - set { _endedAt = value; Notify(); } + set { + _endedAt = value; + Notify(); + } } - private long _progress = progress; - public long Progress - { + public long Progress { get => _progress; - set { _progress = value; Notify(); Notify(nameof(ProgressText)); Notify(nameof(ProgressPct)); } + set { + _progress = value; + Notify(); + Notify(nameof(ProgressText)); + Notify(nameof(ProgressPct)); + } } public string ProgressText => $"{_progress} / {SnapshotTargetValue}"; @@ -126,12 +123,16 @@ public long Progress ? Math.Min((double)_progress / SnapshotTargetValue, 1.0) : 0; - public void UpdateFromRun(SubathonPromptRun updated) - { + public bool IsActive => Status == SubathonPromptRunStatus.Active; + public event PropertyChangedEventHandler? PropertyChanged; + + private void Notify([CallerMemberName] string? n = null) { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); + } + + public void UpdateFromRun(SubathonPromptRun updated) { Status = updated.Status; EndedAt = updated.EndedAt; Notify(nameof(IsActive)); } - - public bool IsActive => Status == SubathonPromptRunStatus.Active; -} +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml b/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml index 9c5f3783..50abb91a 100644 --- a/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml +++ b/SubathonManager.UI/Views/Prompts/PromptsEditor.axaml @@ -14,30 +14,36 @@ - - - - - - - + + + + + + - + @@ -46,141 +52,156 @@ - + + SelectionChanged="SetSelectorBox_SelectionChanged" /> - - + - + + IsCheckedChanged="SetEnabled_Changed" /> - + - - - + + + - - - + + + - - - + + + - - + - + FontSize="12" Foreground="Orange" /> - - + + - + + SelectionChanged="PromptType_SelectionChanged" /> - - + + - + - - + + - - + + - - + + - - + + + IsCheckedChanged="PromptInfinite_Changed" /> - + - - - @@ -60,46 +64,47 @@ Content="Login with Microsoft" Click="LoginMicrosoft_Click" Width="170" Height="30" Padding="4,2" - Foreground="{DynamicResource TextFillColorPrimaryBrush}"/> + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - + \ No newline at end of file diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.axaml.cs index 31d12bb5..32d76489 100644 --- a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/ObsSettings.axaml.cs @@ -8,21 +8,17 @@ using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.Integration; -using SubathonManager.UI.UiUtils; using SubathonManager.UI.Services; +using SubathonManager.UI.UiUtils; namespace SubathonManager.UI.Views.SettingsViews.ExternalSoftware; -public partial class ObsSettings : SettingsControl -{ - public ObsSettings() - { +public partial class ObsSettings : SettingsControl { + public ObsSettings() { InitializeComponent(); - Loaded += (_, _) => - { - var (host, port, pw) = ServiceManager.OBS.GetConfig(); - SuppressUnsavedChanges(() => - { + Loaded += (_, _) => { + (string host, string port, string pw) = ServiceManager.OBS.GetConfig(); + SuppressUnsavedChanges(() => { ObsHostBox.Text = host; ObsPortBox.Text = port; ObsPasswordBox.Text = pw; @@ -36,74 +32,63 @@ public ObsSettings() UpdateScriptStatus(ServiceManager.OBS.HelperScriptActive); ServiceManager.OBS.RecheckHelperScript(); }; - Unloaded += (_, _) => - { + Unloaded += (_, _) => { IntegrationEvents.ConnectionUpdated -= UpdateStatus; ServiceManager.OBS.HelperScriptStatusChanged -= OnHelperScriptStatusChanged; }; } - private void OnHelperScriptStatusChanged(bool active) - => Dispatcher.UIThread.Post(() => UpdateScriptStatus(active)); - - private void UpdateScriptStatus(bool active) - { - if (active) - { - var loaded = ServiceManager.OBS.HelperScriptVersion; - var expected = OBSService.ExpectedHelperScriptVersion; - if (ServiceManager.OBS.HelperScriptOutdated) - { + private void OnHelperScriptStatusChanged(bool active) { + Dispatcher.UIThread.Post(() => UpdateScriptStatus(active)); + } + + private void UpdateScriptStatus(bool active) { + if (active) { + string? loaded = ServiceManager.OBS.HelperScriptVersion; + string? expected = OBSService.ExpectedHelperScriptVersion; + if (ServiceManager.OBS.HelperScriptOutdated) { ScriptStatusText.Text = loaded != null ? $"Outdated (v{loaded} loaded, v{expected} available) - reload in OBS Tools -> Scripts" : $"Outdated (v{expected} available) - reload in OBS Tools -> Scripts"; ScriptStatusText.Foreground = Brushes.Orange; } - else - { + else { ScriptStatusText.Text = loaded != null ? $"Active (v{loaded})" : "Active"; ScriptStatusText.Foreground = Brushes.LimeGreen; } } - else if (ServiceManager.OBS.Connected) - { + else if (ServiceManager.OBS.Connected) { ScriptStatusText.Text = "Not Loaded (add via OBS Tools -> Scripts)"; ScriptStatusText.Foreground = Brushes.Orange; } - else - { + else { ScriptStatusText.Text = "Unknown (not connected)"; ScriptStatusText.Foreground = Brushes.Gray; } } - private async void CopyScriptPath_Click(object? sender, RoutedEventArgs e) - { + private async void CopyScriptPath_Click(object? sender, RoutedEventArgs e) { await UiHelpers.TrySetClipboardTextAsync(OBSService.ScriptPath); } - private void RecheckScript_Click(object? sender, RoutedEventArgs e) - { + private void RecheckScript_Click(object? sender, RoutedEventArgs e) { ServiceManager.OBS.RecheckHelperScript(); } - internal override void UpdateStatus(IntegrationConnection? connection) - { + internal override void UpdateStatus(IntegrationConnection? connection) { if (connection is not { Source: SubathonEventSource.OBS, Service: "OBS" }) return; UpdateObsStatus(connection.Status); } - private void UpdateObsStatus(bool connected) - { + private void UpdateObsStatus(bool connected) { Host.UpdateConnectionStatus(connected, ObsStatusText, ObsConnectBtn); UpdateScriptStatus(ServiceManager.OBS.HelperScriptActive); } - private void ObsConnect_Click(object? sender, RoutedEventArgs e) - { - var host = (ObsHostBox.Text ?? "").Trim(); - var port = (ObsPortBox.Text ?? "").Trim(); - var password = (ObsPasswordBox.Text ?? "").Trim(); + private void ObsConnect_Click(object? sender, RoutedEventArgs e) { + string host = (ObsHostBox.Text ?? "").Trim(); + string port = (ObsPortBox.Text ?? "").Trim(); + string password = (ObsPasswordBox.Text ?? "").Trim(); if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(port)) return; @@ -115,7 +100,14 @@ private void ObsConnect_Click(object? sender, RoutedEventArgs e) ServiceManager.OBS.TryConnect(); } - public override bool UpdateValueSettings(AppDbContext db) => false; - public override void UpdateCurrencyBoxes(List currencies, string selected) { } - public override (string, string, TextBox?, TextBox?) GetValueBoxes(SubathonValue val) => ("", "", null, null); -} + public override bool UpdateValueSettings(AppDbContext db) { + return false; + } + + public override void UpdateCurrencyBoxes(List currencies, string selected) { + } + + public override (string, string, TextBox?, TextBox?) GetValueBoxes(SubathonValue val) { + return ("", "", null, null); + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.axaml b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.axaml index 2faf0d8b..8c1bda72 100644 --- a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/StreamDeckSettings.axaml @@ -9,15 +9,15 @@ - - - - - - - - + + + + + - + @@ -75,37 +87,42 @@ BorderThickness="1" BorderBrush="#333" CornerRadius="8"> - - + - - - - + + + + - - - - + + + + - + @@ -115,120 +132,144 @@ - + + SelectionChanged="WheelSelectorBox_SelectionChanged" /> - - + - - + + - - - - - + + + + - + @@ -51,29 +54,32 @@ BorderThickness="1" BorderBrush="#333" CornerRadius="8"> - + - - - - + + + + - + @@ -82,146 +88,169 @@ - + - + - + + Foreground="CornflowerBlue" VerticalAlignment="Center" /> - - + - - + + - + + ToolTip.Tip="Enter the tier name as it appears from the platform. Use DEFAULT for platforms with a single membership tier." /> - - - - + + + - - - - + + + - - + - - - + + + - - + + - - + + - - + + - - + - - + + - - + + - + - - + + + ToolTip.Tip="Number of spins added per qualifying batch (multiplied by how many batches qualify)." /> - + + FontSize="12" Foreground="Gray" TextWrapping="Wrap" /> + IsVisible="False" /> @@ -128,9 +129,9 @@ Margin="0,0,0,6" HorizontalAlignment="Stretch"> - + + Foreground="{DynamicResource UiBrushTextTertiary}" /> @@ -145,13 +146,14 @@ - + + Foreground="{DynamicResource UiBrushTextTertiary}" /> + Foreground="{DynamicResource UiBrushTextTertiary}" /> @@ -159,12 +161,13 @@ IsVisible="{Binding #GroupToggle.IsChecked}"> - + - - - + - - + - + - - + - + - + - + - - + - + IsVisible="{Binding !HasPreview}" + HorizontalAlignment="Center" + VerticalAlignment="Center" /> - + - + - + - + - - - + + + - + - + ToolTip.Tip="{Binding TooltipText}" /> + Foreground="{DynamicResource UiBrushTextTertiary}" /> - + Foreground="{DynamicResource UiBrushTextTertiary}" /> + @@ -277,12 +301,12 @@ + Foreground="{DynamicResource UiBrushTextTertiary}" /> + From c481348df8e9cc1c5540d8deb82e28d8695be045 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:00:22 -0300 Subject: [PATCH 16/39] fix magic rune presets for wheel and prompt --- ...withSword_Magic-Rune_Magic-Wheel_1.0.0.smw | Bin 56529 -> 56568 bytes ...thSword_Magic-Rune_Prompt-Scroll_1.0.0.smw | Bin 2767615 -> 2767592 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Wheel_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Wheel_1.0.0.smw index 414aceabfb074605997b90ea39de64f014913be7..dc7c9fc27d0305e495fb9247fb13c9040ffa5659 100644 GIT binary patch delta 5754 zcmZXYWl$STw1$IQaVzeGQVPK(KyfWnycDOwA-EI?ZpGc*HMn~V1&WhGaf-V`kgwc+ z_s*TUvS)VQ**!D+?ChVj=bh<5Lw2Aw1u~#Q32Msdjf2gOq`fc!06OaAIcfkv^Z7VK`&-#mk_8dE}-1<-I0>$r=4}E?=(zLWI4=x_ulOq zgyrHULeIq&0vfo;++tlLP971W;1U1rTs+q2Mw#?66}t>{v?~1MM2L6W)gk# zNphZeQc7~a1@8yBFx(Q=vtC1C-SNJq2@mg%9YKZQoqY{?s!<)epLPNMpdBeG)(X^NZ z3++aSv3}@R=Hibw3SRQ+-b`}mtPZ|+N2lD5mkx2>Jyp2ssZHVwx=gPaa8&#GX;NlI z*uz8?z=@Wv#TXTgqpxX4nioA_5h4#AmOH%wM`}zDV{^<)HWC;~$ry+gnnb)~Us#UM zJ$9t09ffSDr^%vaLIDgH*)*_@=bJ{OWlW@Lg|jc+bvnkX3j z`0Vvr%)^krUmfOts6BcY<1oV`4H;prRAk?Tb2s^0W^HFtw9j3n=Nw_4eJB6fT!FJ! z+LGooenZc@nfZl{GA=0v`$0-z>kqH%nhZ2`&b{{|Mf5FG>~Vs)_Mp|IMI5bg;0Uf^ z3dV&s){ozv+6rP%S@=}S&ETxY-)BqED!sBQ;FC17k{AqEGp{3qf~4@_mhlqT&M{Rc zWv@?-o~iFB;Agi#f37feZA`BCgKVJ1RO2su_|0p@qKwp3<8l#hLVn7Q-%kZsAFMMD z4~cqedqoe^C%jv2iLXANdjv?O5wn%>)Sr+aiX2v7F*}M$n(HaNRqG=1SLQsQFi&_H zqsXRFo+3zfrg7)p8b03XSCWzy1Q!q{pEXT-4ZD6S6#jnuIrFf8`gAEK z?zDXhV9Z1RG_?$lA)E-~k?KZx$59%9+_UC?a7Mu5&y^2IDtA=-+3>OJ2F4#1K5|kf z1~s@vZP|bOj!)TrWT;eQ!fy@1XIt4;S6*sTl6LaqG-XLuZF$=wC~P}ZYbGHta9-tW zYmQ6Nb*wfa-2zx88nzEH6EdMzSj5u>H3ePKUX+(;jmL3fGI@6gy0A_j(>>!$K-fP% zbpv7gsrjM%zJY>gJkWjf+EU3D#G6Mh*Dg*5V?Yg)filetH0WoC3`-fzQoO0_gbHWr zMi_;JznjndK(R-P1z|*=VGP^l@0T;N%X91;ea#}qBA2tSEkMV!7cekuec-tAxT}X; zyUD(Lz?a|pQYbkq(rO$w5wG2n==$@WTYxZRIgUoWt_p7IJIb=s>H4>C2zzh^Pg8$( z%p4$#H-Gi@s>cjZZf@NpG4&StwY7Y6Xkyf@PN!vT5X({ouLv#iFNl)!A8Xt2$``2i zHS^33cxU%^BTJ;q&8;amD1np=-m9qV#!k$l#0oOuxzD?0>*>ySe}JOwFNWHxJvCsO z^sZ-{=B&2}^Fu6(bFy;EC*;BG>3mXjQM<|m@MDtJIBC)g?HetNmydT^BK4&pTG@-b+z7-ZcJaD_wb(rDWxU}N70u>s<3-{!zDsOn}q2Xui z=JF&s>LI~38JR6Gam1TC=e&P)l|>Np2TL5nfb0_Ke3po7B}HHAnzSQ(_v`?=@v43W zv=w?VCFCbW-?W;N66n73pw_KBTVL;Y-sF1Z%H8179x+w}I|iQ}ikVvX=%Ll!<2R|& zyhSHY|67{^-7$bR1OR}oFaSW3%tS;EtECxtI+Al=uLmhGX0xjsZlE5ZN&BdD%Mo*N zle?ZMTRI<`fgRD@gAC4>8UU5jfd-7NaB294=_N47%}SgwKDR`we%^j6XCdeAM|*^A zlK%!7mxSxMU1Fo_puvt9!eTgXCE8d&q=L)XS_^)WNb{o3Obp+~#<{!6&AJjntt{V3=BTX>wA@|9^hv~{^(Ifi19HtsQ zF7ti}cukbHf9XAji)&?Ne{Cs@#6r025khVP@>fn*{^3f;tP;%uNt%rE&YgTs0d5j7 zN>RpCu$+8RlNJFWpR8VykfbH5%xazxQy;?~Fl(o&$gr+S`LX`BEmounWLY^>)?~-T zyWY>+Fv%_M&J%S`(QS}(sDGpMoVX`1&sU8!O0kCQQ%ECq#&xDR=H}|`!&KB zgezy7tuggslj>Z>4g}L0Q>TE>>L+%V)W>@%^*nDoSM0y`&i#yI??YuZjinIX$-Aab z*NCSdMPNt5&OlE!rnd!Z#xO?}tdztWmxaRq&0ZS!UqXL!C?@+P zA_6Kn7Pdgs%03!y@+fu0^yTIQ>g?teysM)(7u>*geRn~d*jwF1onx}3XN=cUw^^Q+ z-Kn>h(XBYOu*A0!E#FsgmjO%|C6rsK-Hdh zc>EN%Nu0zX))N|jM0)JroS;$_lBR-8YHD}E9t7Ee2@zMmgMLyDTbBDO)jhZb3{CHeD0@M(f~*40={`F>jb_!CDhqr z#_lplOP&z74ZiUHa{ByT-m#ra^#xt*bmh#Pa?nVRPH`v(XJ#X6S))(Z7*$@^QaQU1 zb=xE?68_nW9@s%-FFwV5T###f0NG5r(G0#8kC8k-8dkc0?~Oq=KnuKg51-h1b^Dxm zZpDC)QI+3NWY`^x&rf`f2AZ2EwMxPp_F>fH*KQhJynyptOsjV%>yW~Qy-Rs`t<2vy zQWnJ461`&?mvVWtc$AQ<2y%#Ezrsqo3}c0%k$w){k?kQvMwqGf$zO;6ywS;Pmv6H<%?;7?yHL#^8WU75h3Ef<9m_@=iZh)&d3hD8|vD!l1KXFvQR)EMzu zcm6&&tcR7%FZZN0;WIX7D8+OD{_5H)X7rk&?hwBj!#9sG{lxGUtdVP$(duhm9SAIY zp4uy)pTG?5rCRsEKVDlK4qnbEczmgH%?UIT=PtD@ZY#g{6Zu9!PAbjApUxMNtP z!e5mhWTRwQ0u&}^8sLSZ{y51d=y?s#$gTb597f>mITQ5d@!82bT7l~NFApPR+m{fH zV~JiUK0*GXen|TOLcqV6K9vCjJ_GY93ogI%!FavhW!+Ye7i@nKYNb?q7hx@F9q4d! z&~r2Q=xXRV0G27lZpzT=dVx{;(geH2KcC|4&fGZJEN9nebB1O1`^+;vFYgn-H7o6^ zm7k=>Y{pp4WC!<@bP}Y-`=D0el-IKKgGY*nMQ&1dtoqv+I2ERQk}tK1?+}b0#tm1# zNQgYlY>}du75S_aQV~4>i^!o6KRx}4XR{q8P~XbYO-)~j-uw-QUxmRqE{m~%N!C$> zdXvt$PB!qR8@XM6Nc{U-91$}WXXWJPdAFcam z9+OLMxLaLOcZzpk5=Aw${}NX5kFRW!uVL_fD*a@MFi@Zt3nb#$3De4HbL4DIE^u{@rl2^p5hZmjpc-3b; zSX7hXyH)lXv4X%B)()2%C#bg?;-D@7!19p{Ejit^pXyNYOBHDmDT3J&_ZqyrVH5cD zX7CqX4^05}#I5somF-ZZ7;!r(kU?8E8gAWUbkfLl1!JQ5vyt=0RKTTcU;-PUY_I9N z6HdeI^o2~=_3Jeq8fBEAGt}XuOxo|=`Zr4vX%(?XvfAxbKKM&+u57D&;AkZ|ZYU*D zW4hVhW)Arm?X<>@e>N#_ z806InDI1VuOU|-nNlVhphV~_Y)m+J{t>||&9T%z8tjx#1r|7*Fb#KbWzzV^5VMf~m0eO`B>lwksf^C)5X$Hfp&RvxVBf7!(RVkF zla@9)+d;n5ph=k1Dw(iDWwn@v@*yIRg4`#|HCqgAmCPtXvEAHXiFA7J^bt!>VY7rV zzC_-fSRrSS7Uvkem!K>O;=qo5)obx9*w@r)K?of?$Wj&5jr_rxbjm$8;Jr&)AV&y5@>f!g+ZQE>S zoDRAZSMPqY3yyMh484qt!5&{_uHEusKVY8ZcbzQDH!rK9{M5Q70*of8loBMtRZ94_ zF4&p*UJz$uT6M_kQZYcQbf&~!`z7>LSa38fZNk#H35UFhBY*gges8OuS8kx}o=v-T zarRQqOO+p1yr}m28<)U_hvCR28RSe^geX2Txm~Gdx z*@Yy<#IEh*4&LeRS(s4B4@0eSv)x=wz!uD2!`l7NpUfUK0-@9WY|-w~V5b0rQIjQk zlRIvhYnmLxlm6&t#6EhvC3LibRcSfkj8DTNtWx-HnKw{iS#33X$nn#0RJUrq$8Y~C z_3nvC+t_qP`St`Fj|g0L$rW5((lo&kd87X36bF>JKH+qSi-J?P{XZu6M1A-6x+82l zy{@Ql$hp>gu!BqoOe?B3S@!C_FNP10<9?@x>8#OU&UAAaC}0*T42JjnzPFiU7RRlZ zJyR$yWiWH1u*%_nSJ^dtZMCYT^^tl{K@?H6sdd+BcaLuP`8+Omr-gs+;-hfaZnX;r zbbT73Jpmq!wbKhgyMFGfvZp*0{pigqo>lhiR;la6V0o#u!fI$Og#$m5P8r`G>>Y%u5+vhVI7P2L#7ptUC&m zJ7_R%z%k|_w@a^6TLjLT+I=Zb=G19=w&@p59u@vD8C|tlX{j!#FhK*#T81m1=gxG~ z0%MI{p`}2w4|tl*Kajc2PI>plR#zKFruNTx8p#+l>&7BOaCwE|(q3m-K3w*U>_quwAUD<&Qklwxq0?{pBc3 zp#jI@&a6cYU-t=?ti*pBhq|A~-I_w2$sk=4(rrQ-*3M3$9BT?{f8tARR!}QkbRzq{_Qo&K^DIdTge7o=9V=0r_v*`H+=O;sSK+31VS}z)pFduG z_qzc@kHfbfpD#;}*U6o)3Wh~sIf?LRAu+>wiI;rL#$I!#mac0ziGUlX|HbzxVI#mj@Y247ofiaW;M>bMj zDlWpovz1^gk>UC$V!Fn-QltI-cG>A=0 zUqd51*V`t9w$+-Lk)s|~>z@QutE6E6`9kqy!u7UF4vW7rmVLnvAGUn2qf{b;+tKSo znJWD|qaIMdHXi9w(fYMLDA2dwUgR)D9>h57Mkl81xpC0k^8}bR{*y{*u5V0`!znT9{@h5RHn|7y<`$FH&lbc6A^;*aF+51MN<_GJ=tZM zx`~y@54|Zk&h=dbsu4=E;0gyS0FZ3D!fyH&Kn4H+umS(uUFFA4dMw3Z4A8e>M4RnG-e7 zBbjD}*6hDg|F2*EU#K~s|BJG5cXarlEWM4VvVYHhlVk9oEX=FC|6b5{uH^h%DWyEFmE& z`24>Q|Cu-Q?)`Gl%sq4OhdXoT+$+?BZ>R@2+Rz6$$l7K4M&!Z^ESd*6R3H$D07UZz zY`XOtlpjC_0!51>G5&ObiTMYH77F09EWbsy?o^F$MaswV+L|5Jmk=gN=pp}SogT|I zZTgXMBCp!Yb>^Akx5)cIj3V(ewez>g!rDXVH@WjKKeDJ4J$xf?Rg#b+UB=J}l1S0# z6C}fE_wN3dm-Uu34xYwBIij{~mJlO24c*0JoL)715}$aU((o0)X7@=I{{8A(waMU3 zko+{lQ8qU9@imW?CzzsA61*cbnx3wtTy1>*gN8Mnank>7I-5-6jJf+;wxkh-N{Yu3 z6)F7X8@h3fIk`$Vqh-tiw%nmPpleS@DjGe%bFUY=tI(i^YoX)>5k)ZOwz6^cU*esY$yx6(I}1K z3Y|Cno3BSKV1yvMoZZtNjJ|_GbK1COH4{=w;3>PF$E}Z=3DiKNme_Md_lbyXbqU^K zj_&b;+vFgi*(_I>8vk6?DVO?STSQ>qldnDL!(!lPxtkMf-BFcu)wYS9{>D!hpA**GRHsBcQHfGKWknQnt04Ap4GuWU*le(dbOM=&J~*PH=!ZMBm+SNZ z#mB^@pN?#Vh!&-7I*mrjDiW_6n;yXep2*oB8-g=9@&`J+bmVK4XlU`8E@`=9K-^I^ zG(;6+;=z!P&v)N-;Z=4{LU5g;_eA67qn7O?;F!hRkBE0PY|O9)SEf}3fBe(pr8N)GQ-HLs{*7U$&KN(lvp9hApN6aI0(vqgTWVFtit5U_# zWi0mXpE*I|KqTD2v^lm3vaY~BtWbk~tez{(&uS1yzx%!~(M$&`D9U}~9jku^$MC;X zQUe#5p|FT{NgSfJ86@Bf=nD;Nl2YN5trxetw)sZeyes9ll6#qX)KhjkF&uzAViQ_A z6c^>KGb1Zb>+QSKFw_>f+;C%&;&ic3~fc3eWWuM z@?=Zny0T(~_;{xrxL1 zioYW=&SUE1#ztVjXf9%u*?F%#tT{thjf}SIW?u<68usuBan&>Ypvd?*7&7%l>YT>^ zyPKeCbqo|HE__jy6$O{EIdy8C*zx6_vo zn-(%QV;L=jGAMmC$Ak$=y_bH6V5!4GpJd1?$bI@UIULvbgbrC0cnMm1Wg&p`mM51( zB~s;b80;a(g3rN5Aq9jETs}MoH_>w+GZzdHX0SjzD>`Q-)t`zo(-vJVEY?YSeA%<7 zVM@fCI$2Kklbeq z>eNfMEQ;9oN8ya`jI3W{wM+e4$b6ik*|;$Tk>!3vP%3x5e+`tyH%*T{qvm4OGRTTI zV=Qg<%B85fOzKGBR=hNoPkfX%Jb@mwXPqozs~?vakEM8(kn<@roSAW*Fkez9+(1)n zvp=pYXyA_T>@)5vmo?&O@+jjrXQz9pWM4v-N8Dt;bBU`Se_#M_LQt7bdb5zsYcLbrn_k zb^HrbFlofLo27T>(zJ5f%wtu1e;n6BiV% znImH6m(TIcpn)e_Ygp3ly}@>Z=Rs)P5Jv{1*e3ByD()LU!@%lHWajY8uit^^67LpG z&B&^2Gs3QeE$e>*&7&u(A)u2l&LHsTa^G(p}3$%g+jNMg#C2Pgw2m?s6<- zk|CAC@V}!mTLuUP61fz4TScb#SKr?dpbL-o;g$4r5An61bx?AUehN}yC06#i$V*|O zwU`qdF(N^~m7NGg4o^W8gDz3H2yQp3B zzUa-DQIc^6$k41jD~w=J z;@p?mHz(&y%#~6>FJ9^9<>Cm>@=iCcNvFvuW|z0Fxq=vM14~+FH<+H01ur*!uvrhD zBKUep%4{a+d*|`w*zPtJP=^Rw@L79sWybcKcX^e-RmcF}vV)N^bTHZS8av7L*(sT4 zwqCdQL_ZsSlD)juRq*d8>XsH{TybY=1m&wVg}!S*!P{gE~+%9BM{4g)SvN zB_1J~@v1h-XgTW;dy$Y8M3{T&8yXpXKxrHvDIQ7~rM^>TG(NA3<+7IF&=mLc@<$D9 zy0dQ0(fv5A?J&Rxi2ZCCNL(&Eiz>mmGvLTv;zb36i-X08?wvYKJiL|Q4+GRnipuNJ zTES~fOqLI1_t_ApC25D-Qg--pd4XFY_FwLr6X6gbJ?VIg+UmODeV~qr`-gv; zzEdT?Y1SAo#WNt!p_QI*hdVlSm|^~7+-b!vSUk zTNeb^vpOEYq$w)r3rm`LTCQe>nL$jr@CN}vi5A$#5; z#PQ3UE2L!jT~&PcDG4*zI?y7_5kIHObkyqv&>~~+ zRp_66jgAV!A%k^-O|dk4KSbbxKudH;a#vcw80uLhN#3z%ng^qn$^$qL#dL>tDD!Sb3Mp+xr)fzgp1( z_je2(j9GE$EoVQs=2GeZimL5pxL`f7q ze9fhTyAVsY=_Xw;FFx=t9+v_|IKkRj63>Q*0%fiO|4xjrJH-25mCiNlQRmRTjPXNtdWA0m`NZV*jEn$f52{GY+?XRK{55%Ko_xZK^%0Z4Qt;65U7WY3(w|i^Pf}hw16Xdp zgOjLtA8eROW(;3slYJh4A??O?;?|LXkLb-K88ns3;RTeFqa2l`3X4yaV%-b)+MZVr zCy2wu6cQ@>{+#UIj%#ve1rS!6YvLPm!E|Udlk4opMl->><*GUsmB+>s^nJeJr51># z!a^1=i9b07e;fuEa%rBZCY0M#0E7BIeTb-LAb`mlK6*OH|H_iwgTu4+(! zu+ujCY?BEMk4v09e`L~{sn(_Pb1ZJ~6%tanBGD!*)TIr#FQa%hXVi3x6DU3O_hKPt zhB|@2RPr_p@?{^M1}l3!?_DFHiTrM=}r5@~(- z3&m`rC_468<$`)|S}@uX;8*5kn<2mHKF~)yehaQktsDDmn?`zv^!)=cPS%pV0`t@8C@wHOb=r2e6 z$sR!m7(}m~I8Fd!J|CLTC+qB2i3Sz&#>|m)o7TP}FGp_d_lnkLTNfP!hFu}w$bJy>K zkv!eK)6c(PyaQCZEqBy^?#bC@++fP+R&I_Tf5?E49!l!Syij?im-o;N24$Z zyN1ET zp&AJ+o6cATOIK29BEim!X~q-RF2sV+&Q0ZHjXPMApxvf}^3H7pl-$)_^rCk^yG?42 zO~pPk)z&h^@#ju)r_!z@eu&J64cEycW{zRn6${U#u`9U9c$#kQ9^z>f-MxEVlnlA* z5ehq@QcWPMG>2i3yC93ARkz5AijL<@>-{6Vw{z=u2ys4NPg9q=mCUI>4;yp6pX1aM zUAZ*k#(QWWtCo4R{{37*z7E!QHeL`%PgfUhC>Ayq=>Pk~f) zmKc%fMK0`6X5`%>7t(W?i_0$o1akAR@o}*6h4|W7x@kjk|4A(fg#Ryx|LtqOcI~|r6d_fkiHg=96?+u5_bfp@lAs9cv1e_igr{mfwOXS?q(&)9 zD7CAlMk!iUjq?5U_q)D-eeZLQ_%&;V!wbO3q) z1Ar001h@cT2Cx8F0c-$v00)2*zy;t2@BnxLd;orc06-8R1P}&@07L z;37a0AO(;HzyUJtT~Jw*)z(vvVOFlPYN)dWm4%SS3<9OgfI!@!!Z=j~FY%esP|&7c z*vCeJbgiqVr6>!i;Y7WagO-{;vm8kL!$Zj_T;Q?+>@z;jZg;jBB&!{3C)U=a-L&L- z3NJ+>e}?ztx%gI$+TRUFj6{^*$%JS|VYq}$GEgs={hn8#kQhAwL~RarE^w*3R-2Eh z%#`&m2b_B4U0~Z)^y1ugqEX{oFKa2POlYWMhbkC|PTAS#?^>gB_(`fNXC5*}W%|Tq zQxkqcA+E>bilJ3vM#sCDT=a#~;jGsh3^b`5*A>VzA*vec`nxKna^HgkyngA(9^5A` z`;Ck~<6hxOW;Z;_3XD_}(?!>zyJOSvFA1pFdp)pGa={yw1dI;R3)fn11+Rgv&K~+% z&k=t2vicb=TSqyr3LaoY(1co8{J%0xl`_&wc5IiwZpdQ|$8ICpx#ygR_aw!@W%+9DH1M;iX5lc}Cwx;! zgp^`cdzX!^_-G)oPM>J0J z$bl*YoIuyVH}+{D8;p9@_Scq3$m8il$nB3~^^dgsS0Ka|EwoC2SA&Z_F#cf$C`SGtJJ)6=c8*23Fck}(+%m?da=AjL{~^Y1g*g3>?q%?xf1 zBt=WMUvTy8*wxTjE$gSNDu$J_X~It{EF&4$tx;5i>qjGh+-y0UG5)~}Ow4azoaEL0RV zb8U!`wV9f*e)s0s-%=# zVjA~UO&2k5c(K^3{GWc3{`E`B^tU3ojE=3sard4gYnVnU5BB-99n5^jc%3W;;-O7T z`@+JP_shlacd-R2u^r~Va)|%?v#7q{ik(8}p45A#yJoGev7eXI2#t}_c-YiGiIkhm z_Dx{oJ&ZTh8wE;eS-0m16Jsp%s-{Qe(-ob`DEdJR#g7*M0`as*WEt5iSCTkZ{jPF z%lUb@38}cefRp^9Ej{KV({Jx`PDQ!?Us_h?e)NNeO)!q!J=MgGFL6t%(hNGV!xC&6 znr(Z`*wo!R!eL{%z)fjp!aN}~STRbw2Zyeb~XL}_G=Mh)Go=F_*ApVvE;g<)j+!XG?;a5CSD}x@$~~- z-|_7Yh@)8_MR!0WpL)=drnv2NMV)WzW_zRiL>L8rBPnTVB3=_-8Ih&>)PSMN7rZ_z zanc)0Jn8r~Px(~SjE)MD9U{}}H4*9XClYFV!1 zc&(B1?lvsdP+U;vNKLBhTixGvblov*J`C@^U#y>uw}aqoIBw3~KMcqKnB^#8>&g)%<@y zwfA>cQ!0Mj6JXDDIONnjkF}#_Tf|>IbNyW3W;Hyl>FxViHN(B;K$y+BP2{vkO8zqK z51iB`^L4>1{jbepi+z6>!1pU3r5x4Apxs#>Dcl-;?Ip`3U*^b7K19O;S{Brc`tXPB zHwXs4TD3S=ziEa&uB-T*5Mm-!b4&x}Whaq+L-SsAl>u9l?tYJNqO+hpK1gS4D!*Iq zLDXf6zc<@6r;*OGM_Bo4TfQ&J5(b8x<6qM&1bn9J9YveJG}QPfezz1AX}JU+BpY!~ z>Wv6Fua@ktC3|)+pT!ugvQ}=9eO$Esy*AJL%f*hF+|H#N3FDp)9o#&Y)B&V^%W{!zgluS{KlUxkqD3Ab$zYn z**_&|h+Pd_trVFUZ@W&r))1)KX`cTw6y~XUJ|EzhLnLAyi zbe2H!R7`k@7HQe1^0n~!1ghT5u0ks`Lae+~50U>v3sG(TqGgy@n#tNU=#A1v#*_A$ z6P6n;Szo?FG4;M`FZC~qlish4-$}Dh71yY|8{B3z{ZN3z>y!?x0ahOqb1?ZP{mUj- z+5-pf3!0J&7`z!=F?LrVnyzDUt>*fqX-$U0P1B-X;!I%h56e$3GzA&_N!if;==QS` zgD}sCWQBKcW44ZZ+!v7BGopEsK8tHR-IUu%eOwNFIqS%}^Yl)B7EIj=)O-BKMu-Y& zdydKzWZj%)lU#XZZ&+{gnD@?Qd&73W6&>UEq9}cvVu4O5p4HY@)~J>-Cn;wA0K+R| z3d@TodenzMIL+epU64ME`cV-ja7zg!+2-(EJ8{M(`ZC%lpww@JUGxrFeY{@z$&65q zg=3@3qJZ!3zJ#WhN%#0vo=a5^p4l05Ip@GJfu>p#u;k&M@}~{fEi<@Z{tD2R3pieO zrsi6Dqnd@a0Oc&zh&uC~WBo1@5ed`g8=XQzM0WeV= zO(8mk^xDkKpW?A5ZL9s~jZZ#wCYRb}xo+@@688MxUg0TK%d~<;`)oNqvIG%ca-TL2 z5w6Cko-fovi1bjI9|R=4U*TBZq?@u<5wipzrMKW-Qnf%$(F71QIOT>LIyG| zA*}oDFKWf9IF;P$#9Beqet{0bgKCDjg9|;cA!HyRP+`V3gnZ$;DgsKQvnrcQ5#%lu zxrRW{>~H@G0s$7rV2CD&2Lu8!g8ugdAPJoRDKP-szAVTyI4I0JC`=(FG#DEaChr%9 z4ZQGwmmm-rBmjCvzxVGfdI9|39YMaE@hATo!UM{6V-;fk^VIhLJCDx5eTVR6$FUj@8#qn zQ4vxX6`4kgd{W~~$_On5nNOo+TnS!OD$##yPaWeiI>jf!fNlevr#78H+` zG|x#njg(lC=^)~DI15hN+SeY|Z1*u= z35FP%hdgfr7Id+^8#FH!fx~p%qb+}?!fI9P?9{yVG3ddCxN(?o;rWhumEa3OAhrzd zYxmxJu$KK=ud>3W?*W`ODuumUKx>6Iu$F@}{ejE`t(4izcqtV+e)ct*qVHdu%J?U+!}_>#`fcBhELwS`q9bD4#NiD z<0hB<+_~z+=jLy0;RfXaP0)Y+`#x+)T4$e}pO?Pof{i%VTU|F*gQ`{w!|o9lmWuK%^U{`cnk zKb!0SZq`5Ftbegt|8leb)n@(c&H6W+^=~)p-)+{v->m^7#6hx+nTC3wrgrPMHBRLRm_NpYjBVDxuA-iD!f<_@j`3N(hfIzY47bdv_o)@ z!E7bUfWBi8Z1fAi=LsKxd-RtyCr5Kd#3{*mnahcz-`?n<{{?F3x4{RR!`1#hFTobo z8_pG)qMT=|f|M#*5OsgT2s}Qyc%H}5eGK+J43ip9*v&Y$+XyhhSWoz;n-NVf5po0r z-^x&T6ak(oxVItNLL6X1xxEcfm*D`*72De|>mnTTtlQ38_%`inOG}Ke#B0H4#3zsm zf)%ke;s}Z_;AuogfNk~>O;NdGY`gPzKryccuZU0= z-$MVb=?xMkXc1ezI8k(eZY0sXnq4|avB2jEDRcP~s+Mt7$Y?OAF)A!1dB!+a0OpY; zOp+e#n~>21w3~mXX~_IOmCtBN6^S+001~#rKbWF)aD(X9#VbN`G<6YwcWp(Z5iV&l z(gZ;2%S*J|P!~k4L}}TNQEd1eq7*&0JSS+^>f@?qTU=G_0=zW#jjB)E`1ruGu#Vve z?FEAqZ##;wf!g5{jg{!p4?&H)3zT{5(+mt&Bak(64h4VeiM}`2e*tkF_gE;|C$h(T zVAv)FR@pKhzx!#Pyx$owyyIvE8B)-z!b8GHxll`VfIi=dg5VeRPqg#DiNgMXC#iGr)z63TiVP68CRFer-b1P z2qb@s9>9#7w0cc&vO>1h8}P$<9!DC>=XSNl8$qg9D;4LwB;87z!HLn-JA9ax^uEhQ z;BIN#u>i`@hX4Ecjjh^dHvDyG*K!&^9-w|REWMV)r~)K%7Rr{}l2vZ?`~s_GBKWGz zWAlj1oI_fFfP%vrV}yNqcor**rc&TfF}!~d4WF*nyYA7k*vXQ$Xa^dyXfy&EBSi}m4bj6i1wZ^E-In1hd|v8qJq%3Kp6F8 zXeK6BDVo^#e7;TKyiU^_M2Xbp-o3)<|uD7V62@#-zjK6aXX7$^rQV+4!m@Db(= zGqdJ5ajluNom(=-vyZW~*A|_ECHn$BBb3!B!I+@$IUI^{K#9iaOW#jyKT&_!8K(f9 zPC)&(_mb%QfRI!;KUG+$cyEZJG#WJbT&_~I&r;#`C}jI=Qeaxra?v_WY)0%6dWscE zN`4*>2578j$KX_bmDAnjE=;_0+=_cWD%o=}KM>2%0d+X-HWmq4*z8(JTGegW6)Cd8+`&dVn-U3Z* zIpUl$7Ux*up}j`(&s7WHZkTGI9wE(L($KjH&;;4wUu`|~*n8Fzg;Z)y2bhi8LxM(O z4S~AJ?LQ{3NcmcS&(P%Cv0UE+b?O;}w`(LCs4%(o-y(Tpz@P2jyRCl`v?tLJMdkwm zJS}NX%SC;A<%GO%u9`YMsk?xJoApqz4o}@cf%jPYKCs{CvviKNGn-6x9H@;fvY=`sJb#~ zazp4EwnRwAxFj}2KGHHb0oS?Naz5DU6Z^#L^*>zz)>y^L3cV@Je>3_x44;TfcH?3L52l zXnd%f*SfXp)SH{C&3&`jzAo@T=AfxxM-#gUm$NUyFs&5ea3C6vboPq|=(|qQ+jlIz zn~661u?rwg!B2k*&L!?l>f}>Jh5a}iu)t+T80%wE*T4>wThrkGuY~(tT>A4vHSmC* zYj`PjGMb{O;*x@UOz=!{wo+stepfOXJ$m%$QT1tG@oJLppHWF?lu>mtF~0=ULu>PQ z&kJUMobfN=ZJO(ZyyA-mBRYAI<~K7#R9{rMCd?k{W&&a6mrfpbQ-bb-F7y{r58d37 zSlPX=D~6b>b^gy%nvj+vvvYyBZ%F~>IW2R3o}8{`SS@+bM7gzumvLmUKdh=8EAVy~ z$j-{`Zp6}E;xcE}BYv!L(X_^O4stMdKm7S0x6c15paZu|<|)h#feM8R0fh~Gg$fsi3K)e78HEZOg$f&m3LJ$B9fb-Wg$f^q3Lu3FA%zMeg$g5u3M7RJC4~wm zg$gHy3MjP-DMIGAZUZV5G?$w?DkwYv0001YX=G<*bS`RhZ*EXa0Rj{N6aWAK2mlOa8OGH1^@s600#gE0L=ja0310g0000fms`RB From 4f990feb5bc765effa8bfc7b80df6e1d57d6b94c Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:49:28 -0300 Subject: [PATCH 17/39] initial vts integration experimental --- .../Enums/SubathonEventSource.cs | 6 +- .../Enums/VTubeStudioEnums.cs | 25 + SubathonManager.Core/Enums/WheelSpinEnums.cs | 11 +- .../Objects/VTSWheelAction.cs | 178 ++++ SubathonManager.Core/Security/StorageKeys.cs | 2 + SubathonManager.Core/Utils.cs | 5 +- .../SubathonManager.Integration.csproj | 4 +- SubathonManager.Integration/VTSService.cs | 787 ++++++++++++++++++ SubathonManager.UI/Services/ServiceManager.cs | 3 + .../Services/ServiceRegistration.cs | 1 + .../VTubeStudioSettings.axaml | 110 +++ .../VTubeStudioSettings.axaml.cs | 281 +++++++ .../ExternalSoftwareSettings.axaml.cs | 3 + .../Views/WheelSpin/WheelEditor.axaml | 81 +- .../Views/WheelSpin/WheelEditor.axaml.cs | 460 +++++++++- 15 files changed, 1941 insertions(+), 16 deletions(-) create mode 100644 SubathonManager.Core/Enums/VTubeStudioEnums.cs create mode 100644 SubathonManager.Core/Objects/VTSWheelAction.cs create mode 100644 SubathonManager.Integration/VTSService.cs create mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/VTubeStudioSettings.axaml create mode 100644 SubathonManager.UI/Views/SettingsViews/ExternalSoftware/VTubeStudioSettings.axaml.cs diff --git a/SubathonManager.Core/Enums/SubathonEventSource.cs b/SubathonManager.Core/Enums/SubathonEventSource.cs index 37b60ded..8de249d1 100644 --- a/SubathonManager.Core/Enums/SubathonEventSource.cs +++ b/SubathonManager.Core/Enums/SubathonEventSource.cs @@ -100,7 +100,11 @@ public enum SubathonEventSource { [EventSourceMeta(Description = "Juniper Creates", SourceGroup = SubathonSourceGroup.ExternalService, SourceOrder = 66, Order = 55, Visible = true)] - JuniperCreates // like goaffpro, do lookup on product id to find store name as source + JuniperCreates, // like goaffpro, do lookup on product id to find store name as source + + [EventSourceMeta(Description = "VTube Studio", SourceGroup = SubathonSourceGroup.ExternalSoftware, + SourceOrder = 905, Order = 904, Visible = false)] + VTubeStudio } [ExcludeFromCodeCoverage] diff --git a/SubathonManager.Core/Enums/VTubeStudioEnums.cs b/SubathonManager.Core/Enums/VTubeStudioEnums.cs new file mode 100644 index 00000000..431a9212 --- /dev/null +++ b/SubathonManager.Core/Enums/VTubeStudioEnums.cs @@ -0,0 +1,25 @@ +namespace SubathonManager.Core.Enums; + +public enum VtsTargetKind { + Expression, + Parameter, + Hotkey +} + +public enum VtsToggleAction { + DoNothing, + On, + Off, + Toggle +} + +public enum VtsParameterAfterAction { + DoNothing, + ResetToOriginal, + SetNewValue +} + +public enum VtsHotkeyAfterAction { + DoNothing, + TriggerAgain +} \ No newline at end of file diff --git a/SubathonManager.Core/Enums/WheelSpinEnums.cs b/SubathonManager.Core/Enums/WheelSpinEnums.cs index 02eb9f36..e99c0220 100644 --- a/SubathonManager.Core/Enums/WheelSpinEnums.cs +++ b/SubathonManager.Core/Enums/WheelSpinEnums.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; namespace SubathonManager.Core.Enums; @@ -16,7 +16,10 @@ public enum WheelSpinActionType { SetMultiplier = SubathonCommandType.SetMultiplier, [WheelSpinActionMeta(IsDoneImmediately = true, Label = "Add Rerolls")] - Reroll = 1000 + Reroll = 1000, + + [WheelSpinActionMeta(Label = "VTube Studio")] + VTubeStudio = 1001 } [ExcludeFromCodeCoverage] @@ -37,6 +40,10 @@ public static bool IsDoneImmediately(this WheelSpinActionType type) { return EnumMetaCache.Get(type)?.IsDoneImmediately ?? false; } + public static bool HasPlayAction(this WheelSpinActionType type) { + return type.IsCommand() || type == WheelSpinActionType.VTubeStudio; + } + public static SubathonCommandType ToCommandType(this WheelSpinActionType type) { if (!type.IsCommand()) return SubathonCommandType.Unknown; diff --git a/SubathonManager.Core/Objects/VTSWheelAction.cs b/SubathonManager.Core/Objects/VTSWheelAction.cs new file mode 100644 index 00000000..57449b1b --- /dev/null +++ b/SubathonManager.Core/Objects/VTSWheelAction.cs @@ -0,0 +1,178 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using SubathonManager.Core.Enums; + +namespace SubathonManager.Core.Objects; + +[ExcludeFromCodeCoverage] +public sealed class VTSWheelAction { + public const string ParameterSeparator = "|"; + + public VtsTargetKind Kind { get; set; } = VtsTargetKind.Expression; + public string Target { get; set; } = ""; + public VtsToggleAction ToggleAction { get; set; } = VtsToggleAction.On; + public double Value { get; set; } + public TimeSpan Duration { get; set; } = TimeSpan.Zero; + + public VtsToggleAction AfterToggle { get; set; } = VtsToggleAction.DoNothing; + public VtsParameterAfterAction AfterParameter { get; set; } = VtsParameterAfterAction.DoNothing; + public VtsHotkeyAfterAction AfterHotkey { get; set; } = VtsHotkeyAfterAction.DoNothing; + + public double AfterValue { get; set; } + + public bool HasRevert => Duration > TimeSpan.Zero && Kind switch { + VtsTargetKind.Expression => AfterToggle != VtsToggleAction.DoNothing, + VtsTargetKind.Parameter => true, + VtsTargetKind.Hotkey => AfterHotkey != VtsHotkeyAfterAction.DoNothing, + _ => false + }; + + public string TimerKey => $"vts-wheel-{Kind}-{Target}".ToLowerInvariant(); + + public static bool TryParse(string? parameter, [NotNullWhen(true)] out VTSWheelAction? action) { + action = null; + if (string.IsNullOrWhiteSpace(parameter)) return false; + + string[] parts = parameter.Split(ParameterSeparator); + if (parts.Length < 5) return false; + if (!Enum.TryParse(parts[0], true, out VtsTargetKind kind)) return false; + + var parsed = new VTSWheelAction { + Kind = kind, + Target = parts[1].Trim() + }; + + switch (kind) { + case VtsTargetKind.Expression: + if (!Enum.TryParse(parts[2], true, out VtsToggleAction toggle)) return false; + parsed.ToggleAction = toggle; + break; + case VtsTargetKind.Parameter: + if (!TryParseNumber(parts[2], out double value)) return false; + parsed.Value = value; + break; + case VtsTargetKind.Hotkey: + break; + default: + return false; + } + + parsed.Duration = int.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out int seconds) + && seconds > 0 + ? TimeSpan.FromSeconds(seconds) + : TimeSpan.Zero; + + switch (kind) { + case VtsTargetKind.Expression: + parsed.AfterToggle = Enum.TryParse(parts[4], true, out VtsToggleAction afterToggle) + ? afterToggle + : VtsToggleAction.DoNothing; + break; + case VtsTargetKind.Parameter: + parsed.AfterParameter = Enum.TryParse(parts[4], true, out VtsParameterAfterAction afterParam) + ? afterParam + : VtsParameterAfterAction.DoNothing; + break; + case VtsTargetKind.Hotkey: + parsed.AfterHotkey = Enum.TryParse(parts[4], true, out VtsHotkeyAfterAction afterHotkey) + ? afterHotkey + : VtsHotkeyAfterAction.DoNothing; + break; + } + + if (parts.Length >= 6 && TryParseNumber(parts[5], out double afterValue)) + parsed.AfterValue = afterValue; + + action = parsed; + return true; + } + + public string ToParameterString() { + string initial = Kind switch { + VtsTargetKind.Expression => ToggleAction.ToString(), + VtsTargetKind.Parameter => Value.ToString(CultureInfo.InvariantCulture), + _ => "Trigger" + }; + + string after = Kind switch { + VtsTargetKind.Expression => AfterToggle.ToString(), + VtsTargetKind.Parameter => AfterParameter.ToString(), + _ => AfterHotkey.ToString() + }; + + string afterValue = Kind == VtsTargetKind.Parameter && AfterParameter == VtsParameterAfterAction.SetNewValue + ? AfterValue.ToString(CultureInfo.InvariantCulture) + : ""; + + return string.Join(ParameterSeparator, + Kind.ToString(), + Target.Trim(), + initial, + ((int)Duration.TotalSeconds).ToString(CultureInfo.InvariantCulture), + after, + afterValue); + } + + public bool IsValid(out string error) { + if (string.IsNullOrWhiteSpace(Target)) { + error = Kind switch { + VtsTargetKind.Expression => "Pick an expression file", + VtsTargetKind.Parameter => "Pick a parameter name", + _ => "Pick a hotkey" + }; + return false; + } + + switch (Kind) { + // if not do nothing but no duration is set + case VtsTargetKind.Expression when ToggleAction == VtsToggleAction.DoNothing: + error = "The rolled action cannot be 'Do Nothing' - it would never do anything"; + return false; + case VtsTargetKind.Parameter when AfterParameter == VtsParameterAfterAction.SetNewValue + && Duration <= TimeSpan.Zero: + error = "A duration is required to set a value after the timer"; + return false; + case VtsTargetKind.Expression when AfterToggle != VtsToggleAction.DoNothing + && Duration <= TimeSpan.Zero: + error = "A duration is required for the action after the timer"; + return false; + case VtsTargetKind.Hotkey when AfterHotkey != VtsHotkeyAfterAction.DoNothing + && Duration <= TimeSpan.Zero: + error = "A duration is required to trigger the hotkey again"; + return false; + default: + error = ""; + return true; + } + } + + public string Describe() { + string initial = Kind switch { + VtsTargetKind.Expression => ToggleAction.ToString(), + VtsTargetKind.Parameter => $"= {Value.ToString(CultureInfo.InvariantCulture)}", + _ => "trigger" + }; + + if (!HasRevert) return $"{Target} {initial}"; + + string after = Kind switch { + VtsTargetKind.Expression => AfterToggle.ToString(), + VtsTargetKind.Parameter => AfterParameter == VtsParameterAfterAction.SetNewValue + ? $"= {AfterValue.ToString(CultureInfo.InvariantCulture)}" + : AfterParameter.ToString(), + _ => AfterHotkey.ToString() + }; + + return $"{Target} {initial} for {FormatDuration(Duration)} then {after}"; + } + + private static string FormatDuration(TimeSpan duration) { + if (duration.TotalHours >= 1) return $"{(int)duration.TotalHours}h{duration.Minutes:00}m"; + if (duration.TotalMinutes >= 1) return $"{(int)duration.TotalMinutes}m{duration.Seconds:00}s"; + return $"{(int)duration.TotalSeconds}s"; + } + + private static bool TryParseNumber(string text, out double value) { + return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Security/StorageKeys.cs b/SubathonManager.Core/Security/StorageKeys.cs index dc4411d0..147cdbc0 100644 --- a/SubathonManager.Core/Security/StorageKeys.cs +++ b/SubathonManager.Core/Security/StorageKeys.cs @@ -15,6 +15,8 @@ public static class StorageKeys { public const string OBSWebSocketPassword = "SM.OBS.WebSocket.Password"; + public const string VTubeStudioAuthToken = "SM.VTubeStudio.AuthToken"; + public const string TipeeeStreamAccessToken = "SM.TipeeeStream.AccessToken"; public const string TipeeeStreamRefreshToken = "SM.TipeeeStream.RefreshToken"; public const string TipeeeStreamApiKey = "SM.TipeeeStream.ApiKey"; diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index 98f2fa44..83b02763 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -52,10 +52,9 @@ public static void UpdateConnection(IntegrationConnection connection) { } public static TimeSpan ParseDurationString(string input) { - if (string.IsNullOrWhiteSpace(input)) { - Console.WriteLine($"Error - Invalid TimeString to Parse: {input}"); + if (string.IsNullOrWhiteSpace(input)) + // Console.WriteLine($"Error - Invalid TimeString to Parse: {input}"); return TimeSpan.Zero; - } input = input.Trim(); if (input.Contains(':')) diff --git a/SubathonManager.Integration/SubathonManager.Integration.csproj b/SubathonManager.Integration/SubathonManager.Integration.csproj index a29c659a..de6c40e4 100644 --- a/SubathonManager.Integration/SubathonManager.Integration.csproj +++ b/SubathonManager.Integration/SubathonManager.Integration.csproj @@ -24,6 +24,7 @@ + @@ -35,8 +36,7 @@ - + diff --git a/SubathonManager.Integration/VTSService.cs b/SubathonManager.Integration/VTSService.cs new file mode 100644 index 00000000..02f9b4ab --- /dev/null +++ b/SubathonManager.Integration/VTSService.cs @@ -0,0 +1,787 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; +using System.Net.WebSockets; +using Microsoft.Extensions.Logging; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Objects; +using SubathonManager.Core.Security; +using SubathonManager.Core.Security.Interfaces; +using VTubeStudio.Client; +using VTubeStudio.Client.Errors; +using VTubeStudio.Client.Events; +using VTubeStudio.Client.Messages; + +namespace SubathonManager.Integration; + +[ExcludeFromCodeCoverage] +public sealed record VtsHotkey(string Id, string Name, string Type, string File, string Description) { + public bool IsExpressionToggle => string.Equals(Type, "ToggleExpression", StringComparison.OrdinalIgnoreCase); +} + +[ExcludeFromCodeCoverage] +public sealed record VtsExpression(string File, string Name, bool Active); + +[ExcludeFromCodeCoverage] +public sealed record VtsParameter( + string Name, + string AddedBy, // may be way to determine? + double Value, + double Min, + double Max, + double DefaultValue, + bool IsCustom); + +public class VTSService( + ILogger? logger, + IConfig config, + ISecureStorage secureStorage, + ITimerService? timerService = null) + : IAppService, IDisposable { + public const string ConfigSection = "VTubeStudio"; + private const string PluginName = "Subathon Manager"; + private const string PluginDeveloper = "WolfwithSword"; + private const string ServiceName = "VTubeStudio"; + + private readonly ConcurrentDictionary _heldParameters = new(StringComparer.Ordinal); + private readonly ILogger? _logger = logger; + + private readonly Utils.ServiceReconnectState _reconnectState = + new(TimeSpan.FromSeconds(3), 1000, TimeSpan.FromSeconds(30), true); + + private readonly List _subscriptions = []; + + private VTubeStudioClient? _client; + private CancellationTokenSource? _holdCts; + private volatile bool _stopRequested; + + public bool Connected { get; private set; } + + public string? CurrentModelId { get; private set; } + public string? CurrentModelName { get; private set; } + + public IReadOnlyList CachedHotkeys { get; private set; } = []; + public IReadOnlyList CachedExpressions { get; private set; } = []; + public IReadOnlyList CachedParameters { get; private set; } = []; + + public bool Enabled => config.GetBool(ConfigSection, "Enabled"); + private string Host => (config.Get(ConfigSection, "Host", "localhost") ?? "localhost").Trim(); + private string Port => (config.Get(ConfigSection, "Port", "8001") ?? "8001").Trim(); + + private int InjectIntervalMs => + int.TryParse(config.Get(ConfigSection, "InjectIntervalMs", "100"), out int ms) && ms >= 20 ? ms : 100; + + public IReadOnlyCollection HeldParameters => _heldParameters.Keys.ToList(); + + public Task StartAsync(CancellationToken ct = default) { + _stopRequested = false; + BroadcastStatus(false); + + if (!Enabled) { + _logger?.LogInformation("[VTSService] Disabled. Integration inactive"); + return Task.CompletedTask; + } + + _ = Task.Run(() => ConnectAsync(ct), ct); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken ct = default) { + _stopRequested = true; + _reconnectState.Cts?.Cancel(); + await TeardownClientAsync(); + BroadcastStatus(false); + } + + public void Dispose() { + _stopRequested = true; + StopHoldLoop(); + _reconnectState.Dispose(); + GC.SuppressFinalize(this); + } + + public event Action? ModelDataChanged; + + public async Task RestartAsync(CancellationToken ct = default) { + await StopAsync(ct); + await StartAsync(ct); + } + + public bool SaveConfig(string host, string port, bool enabled, bool forceSave = false) { + var hasUpdated = false; + hasUpdated |= config.Set(ConfigSection, "Host", host); + hasUpdated |= config.Set(ConfigSection, "Port", port); + hasUpdated |= config.SetBool(ConfigSection, "Enabled", enabled); + if (hasUpdated && forceSave) + config.Save(); + return hasUpdated; + } + + public (string host, string port, bool enabled) GetConfig() { + return (Host, Port, Enabled); + } + + public void ClearAuthToken() { + secureStorage.Delete(StorageKeys.VTubeStudioAuthToken); + } + + private async Task ConnectAsync(CancellationToken ct) { + if (_stopRequested || !Enabled) return; + + await TeardownClientAsync(); + + VTubeStudioClient? client = null; + try { + var options = new VTubeStudioClientOptions { + Endpoint = new Uri($"ws://{Host}:{Port}"), + PluginName = PluginName, + PluginDeveloper = PluginDeveloper + }; + + client = new VTubeStudioClient(options); + client.Disconnected += OnDisconnected; + + await client.ConnectAsync(ct); + + string? existingToken = secureStorage.GetOrDefault(StorageKeys.VTubeStudioAuthToken, string.Empty); + if (string.IsNullOrWhiteSpace(existingToken)) { + existingToken = null; + _logger?.LogInformation( + "[VTSService] No stored auth token. Approve the plugin in the VTubeStudio window"); + } + + string token = await client.RequestAndAuthenticateAsync(existingToken, ct); + if (!string.IsNullOrWhiteSpace(token) && !string.Equals(token, existingToken, StringComparison.Ordinal)) + secureStorage.Set(StorageKeys.VTubeStudioAuthToken, token); + + _client = client; + client = null; + Connected = true; + _reconnectState.Reset(); + _reconnectState.Cts?.Cancel(); + + _logger?.LogInformation("[VTSService] Connected and authenticated"); + BroadcastStatus(true); + + await SubscribeToModelEventsAsync(CancellationToken.None); + StartHoldLoop(); + await RefreshAsync(CancellationToken.None); + } + catch (OperationCanceledException) { + await DiscardAsync(client); + } + catch (VTubeStudioApiException ex) { + await DiscardAsync(client); + + if (ex.ErrorId is VTubeStudioErrorId.TokenRequestDeniedByUser + or VTubeStudioErrorId.AuthenticationTokenInvalid) { + ClearAuthToken(); + _logger?.LogWarning("[VTSService] Authentication rejected ({ErrorId}). Stored token cleared", + ex.ErrorId); + } + else if (_reconnectState.Retries < 2) { + _logger?.LogWarning("[VTSService] API error during connect: {ErrorId} - {Message}", + ex.ErrorId, ex.ApiMessage); + } + + _ = Task.Run(ReconnectWithBackoffAsync, CancellationToken.None); + } + catch (Exception ex) { + await DiscardAsync(client); + LogConnectFailure(ex); + _ = Task.Run(ReconnectWithBackoffAsync, CancellationToken.None); + } + } + + private async Task DiscardAsync(VTubeStudioClient? client) { + if (client == null) return; + client.Disconnected -= OnDisconnected; + await SafeDisposeAsync(client); + } + + private void LogConnectFailure(Exception ex) { + int retries = _reconnectState.Retries; + + if (IsExpectedConnectFailure(ex)) { + if (retries < 2) + _logger?.LogWarning( + "[VTSService] Cannot reach VTube Studio at {Host}:{Port} ({Reason}). Is it running with the " + + "Plugin API started? Retrying in the background", Host, Port, ParseException(ex)); + else if (retries % 10 == 0) + _logger?.LogTrace("[VTSService] Still cannot reach VTube Studio at {Host}:{Port} (attempt {N})", + Host, Port, retries); + return; + } + + if (retries < 2) + _logger?.LogWarning(ex, "[VTSService] Connection attempt failed"); + else if (retries % 10 == 0) + _logger?.LogDebug("[VTSService] Connection attempt failed (attempt {N}): {Reason}", + retries, ParseException(ex)); + } + + private static bool IsExpectedConnectFailure(Exception ex) { + for (Exception? e = ex; e != null; e = e.InnerException) + if (e is WebSocketException or SocketException or IOException or HttpRequestException + or TimeoutException or OperationCanceledException) + return true; + + return false; + } + + private static string ParseException(Exception ex) { + Exception root = ex; + while (root.InnerException != null) root = root.InnerException; + return root is SocketException socket ? socket.SocketErrorCode.ToString() : root.Message; + } + + private void OnDisconnected(object? sender, EventArgs e) { + if (!Connected) return; + Connected = false; + StopHoldLoop(); + BroadcastStatus(false); + + if (_stopRequested) return; + if (_reconnectState.Retries < 2) + _logger?.LogWarning("[VTSService] Disconnected from VTube Studio. Retrying in the background"); + _ = Task.Run(ReconnectWithBackoffAsync, CancellationToken.None); + } + + private async Task ReconnectWithBackoffAsync() { + if (!await _reconnectState.Lock.WaitAsync(0)) return; + + try { + _reconnectState.Cts?.Cancel(); + _reconnectState.Cts = new CancellationTokenSource(); + CancellationToken token = _reconnectState.Cts.Token; + + while (!token.IsCancellationRequested && !Connected && !_stopRequested && Enabled) { + if (!_reconnectState.InfiniteRetries && _reconnectState.Retries >= _reconnectState.MaxRetries) { + _logger?.LogError("[VTSService] Max reconnect retries reached"); + return; + } + + _reconnectState.Retries++; + TimeSpan delay = _reconnectState.Backoff; + + if (_reconnectState.Retries < 3 || _reconnectState.Retries % 60 == 0) + _logger?.LogDebug("[VTSService] Reconnect attempt {N} in {Delay}s", + _reconnectState.Retries, delay.TotalSeconds); + + try { + await Task.Delay(delay, token); + if (!Connected && !_stopRequested) await ConnectAsync(token); + } + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { + if (_reconnectState.Retries < 2) + _logger?.LogWarning(ex, "[VTSService] Reconnect error"); + else + _logger?.LogDebug("[VTSService] Reconnect error: {Reason}", ParseException(ex)); + } + + _reconnectState.Backoff = TimeSpan.FromMilliseconds( + Math.Min( + _reconnectState.Backoff.TotalMilliseconds * 2, + _reconnectState.MaxBackoff.TotalMilliseconds)); + } + } + finally { + _reconnectState.Lock.Release(); + } + } + + private async Task TeardownClientAsync() { + StopHoldLoop(); + + foreach (IDisposable sub in _subscriptions) + try { + sub.Dispose(); + } + catch { + /**/ + } + + _subscriptions.Clear(); + _heldParameters.Clear(); + + VTubeStudioClient? client = Interlocked.Exchange(ref _client, null); + Connected = false; + CurrentModelId = null; + CurrentModelName = null; + CachedHotkeys = []; + CachedExpressions = []; + CachedParameters = []; + + if (client == null) return; + client.Disconnected -= OnDisconnected; + await SafeDisposeAsync(client); + } + + private async Task SafeDisposeAsync(VTubeStudioClient client) { + try { + await client.DisposeAsync(); + } + catch (Exception ex) { + _logger?.LogDebug("[VTSService] Error disposing client: {Reason}", ParseException(ex)); + } + } + + private async Task SubscribeToModelEventsAsync(CancellationToken ct) { + VTubeStudioClient? client = _client; + if (client == null) return; + + try { + _subscriptions.Add(client.Events.On(OnModelLoaded)); + _subscriptions.Add(client.Events.On(OnModelConfigChanged)); + await client.SubscribeAsync(ct: ct); + await client.SubscribeAsync(ct: ct); + } + catch (Exception ex) when (IsExpectedConnectFailure(ex)) { + _logger?.LogDebug("[VTSService] Could not subscribe to model events: {Reason}", ParseException(ex)); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to subscribe to model events"); + } + } + + private void OnModelLoaded(ModelLoadedEventPayload payload) { + CurrentModelId = payload.ModelLoaded ? payload.ModelId : null; + CurrentModelName = payload.ModelLoaded ? payload.ModelName : null; + _heldParameters.Clear(); + _ = Task.Run(() => RefreshAsync()); + } + + private void OnModelConfigChanged(ModelConfigChangedEventPayload payload) { + _ = Task.Run(() => RefreshAsync()); + } + + private void BroadcastStatus(bool status) { + IntegrationEvents.RaiseConnectionUpdate(new IntegrationConnection { + Source = SubathonEventSource.VTubeStudio, + Service = ServiceName, + Name = "WebSocket", + Status = status, + Configured = Enabled + }); + } + + public async Task RefreshAsync(CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected) return false; + + try { + CurrentModelResponse model = await client.GetCurrentModelAsync(ct); + CurrentModelId = model.ModelLoaded ? model.ModelId : null; + CurrentModelName = model.ModelLoaded ? model.ModelName : null; + + CachedHotkeys = await GetHotkeysAsync(ct); + CachedExpressions = await GetExpressionsAsync(ct); + CachedParameters = await GetParametersAsync(ct); + + ModelDataChanged?.Invoke(); + return true; + } + catch (Exception ex) when (IsExpectedConnectFailure(ex)) { + _logger?.LogDebug("[VTSService] Refresh skipped: {Reason}", ParseException(ex)); + return false; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Refresh failed"); + return false; + } + } + + public async Task> GetHotkeysAsync(CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected) return []; + + try { + HotkeysInCurrentModelResponse response = + await client.GetHotkeysAsync(new HotkeysInCurrentModelRequest(), ct); + if (!response.ModelLoaded) return []; + + return response.AvailableHotkeys + .Select(h => new VtsHotkey(h.HotkeyId, h.Name, h.Type, h.File ?? string.Empty, h.Description ?? string.Empty)) + .ToList(); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to list hotkeys"); + return []; + } + } + + public async Task> GetExpressionsAsync(CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected) return []; + + try { + ExpressionStateResponse response = + await client.GetExpressionStateAsync(new ExpressionStateRequest { Details = false }, ct); + if (!response.ModelLoaded) return []; + + return response.Expressions + .Select(e => new VtsExpression(e.File, e.Name, e.Active)) + .ToList(); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to list expressions"); + return []; + } + } + + public async Task> GetParametersAsync(CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected) return []; + + try { + InputParameterListResponse response = await client.GetInputParametersAsync(ct); + if (!response.ModelLoaded) return []; + + List parameters = response.DefaultParameters + .Select(p => Map(p, false)) + .ToList(); + + parameters.AddRange(response.CustomParameters.Select(p => Map(p, true))); + return parameters; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to list input parameters"); + return []; + } + + static VtsParameter Map(ParameterInfo p, bool isCustom) { + return new VtsParameter(p.Name, p.AddedBy ?? "", p.Value, p.Min, p.Max, p.DefaultValue, isCustom); + } + } + + public async Task> GetLive2DParametersAsync(CancellationToken ct = default) { + // unused, but future scope? + VTubeStudioClient? client = _client; + if (client == null || !Connected) return []; + + try { + Live2DParameterListResponse response = await client.GetLive2DParametersAsync(ct); + if (!response.ModelLoaded) return []; + + return response.Parameters + .Select(p => new VtsParameter(p.Name, p.AddedBy ?? "", p.Value, p.Min, p.Max, p.DefaultValue, false)) + .ToList(); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to list Live2D parameters"); + return []; + } + } + + public async Task GetParameterValueAsync(string parameterName, CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected || string.IsNullOrWhiteSpace(parameterName)) return null; + + try { + ParameterInfo info = await client.GetParameterValueAsync( + new ParameterValueRequest { Name = parameterName }, ct); + return info.Value; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to read parameter {Parameter}", parameterName); + return null; + } + } + + public async Task GetExpressionStateAsync(string expressionFile, CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected || string.IsNullOrWhiteSpace(expressionFile)) return null; + + try { + ExpressionStateResponse response = await client.GetExpressionStateAsync( + new ExpressionStateRequest { Details = false, ExpressionFile = expressionFile }, ct); + + ExpressionInfo? match = response.Expressions + .FirstOrDefault(e => string.Equals(e.File, expressionFile, StringComparison.OrdinalIgnoreCase)); + return match?.Active; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to read expression state {Expression}", expressionFile); + return null; + } + } + + public async Task TriggerHotkeyAsync(string hotkeyId, string? itemInstanceId = null, + CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected || string.IsNullOrWhiteSpace(hotkeyId)) return false; + + try { + await client.TriggerHotkeyAsync(new HotkeyTriggerRequest { + HotkeyId = hotkeyId, + ItemInstanceId = itemInstanceId + }, ct); + return true; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to trigger hotkey {Hotkey}", hotkeyId); + return false; + } + } + + public async Task SetExpressionStateAsync(string expressionFile, bool active, + CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected || string.IsNullOrWhiteSpace(expressionFile)) return false; + + try { + await client.SetExpressionAsync(new ExpressionActivationRequest { + ExpressionFile = expressionFile, + Active = active + }, ct); + return true; + } + catch (Exception ex) { + _logger?.LogWarning(ex, "[VTSService] Failed to set expression {Expression} to {Active}", + expressionFile, active); + return false; + } + } + + public async Task ApplyExpressionActionAsync(string expressionFile, VtsToggleAction action, + CancellationToken ct = default) { + if (action == VtsToggleAction.DoNothing) return true; + if (!Connected || string.IsNullOrWhiteSpace(expressionFile)) return false; + + bool active; + if (action == VtsToggleAction.Toggle) { + bool? current = await GetExpressionStateAsync(expressionFile, ct); + if (current == null) return false; + active = !current.Value; + } + else { + active = action == VtsToggleAction.On; + } + + return await SetExpressionStateAsync(expressionFile, active, ct); + } + + public async Task SetParameterValueAsync(string parameterName, double value, double? weight = null, + bool hold = true, CancellationToken ct = default) { + VTubeStudioClient? client = _client; + if (client == null || !Connected || string.IsNullOrWhiteSpace(parameterName)) return false; + + var injection = new ParameterValue { Id = parameterName, Value = value, Weight = weight }; + if (hold) _heldParameters[parameterName] = injection; + + try { + await InjectAsync(client, [injection], ct); + return true; + } + catch (Exception ex) { + if (hold) _heldParameters.TryRemove(parameterName, out _); + _logger?.LogWarning(ex, "[VTSService] Failed to set parameter {Parameter}", parameterName); + return false; + } + } + + public bool ReleaseParameter(string parameterName) { + return _heldParameters.TryRemove(parameterName, out _); + } + + public void ReleaseAllParameters() { + _heldParameters.Clear(); + } + + public bool IsParameterHeld(string parameterName) { + return _heldParameters.ContainsKey(parameterName); + } + + private static Task InjectAsync(VTubeStudioClient client, IReadOnlyList values, + CancellationToken ct) { + return client.InjectParameterDataAsync(new InjectParameterDataRequest { + Mode = "set", + FaceFound = false, + ParameterValues = values + }, ct); + } + + private void StartHoldLoop() { + StopHoldLoop(); + var cts = new CancellationTokenSource(); + _holdCts = cts; + _ = Task.Run(() => RunHoldLoopAsync(cts.Token), cts.Token); + } + + private void StopHoldLoop() { + CancellationTokenSource? cts = Interlocked.Exchange(ref _holdCts, null); + + try { + cts?.Cancel(); + } + catch { + /**/ + } + } + + private async Task RunHoldLoopAsync(CancellationToken ct) { + var reportedFailure = false; + + while (!ct.IsCancellationRequested) { + try { + await Task.Delay(InjectIntervalMs, ct); + } + catch (OperationCanceledException) { + return; + } + + VTubeStudioClient? client = _client; + if (client == null || !Connected || _heldParameters.IsEmpty) continue; + + try { + await InjectAsync(client, _heldParameters.Values.ToList(), ct); + reportedFailure = false; + } + catch (OperationCanceledException) { + return; + } + catch (Exception ex) { + if (reportedFailure) continue; + reportedFailure = true; + _logger?.LogDebug("[VTSService] Parameter hold injection failed: {Reason}", ParseException(ex)); + } + } + } + + public async Task ExecuteWheelActionAsync(VTSWheelAction action, CancellationToken ct = default) { + if (!Connected) { + _logger?.LogInformation( + "[VTSService] Wheel action skipped: not connected to VTube Studio. Leaving the spin pending"); + return false; + } + + if (!action.IsValid(out string invalid)) { + _logger?.LogWarning("[VTSService] Wheel action is not valid: {Error}", invalid); + return false; + } + + switch (action.Kind) { + case VtsTargetKind.Expression: + return await RunExpressionWheelActionAsync(action, ct); + case VtsTargetKind.Parameter: + return await RunParameterWheelActionAsync(action, ct); + case VtsTargetKind.Hotkey: + return await RunHotkeyWheelActionAsync(action, ct); + default: + return false; + } + } + + private async Task RunExpressionWheelActionAsync(VTSWheelAction action, CancellationToken ct) { + if (!await TargetExistsAsync(action, ct)) return false; + if (!await ApplyExpressionActionAsync(action.Target, action.ToggleAction, ct)) return false; + ScheduleRevert(action, null); + return true; + } + + private async Task RunParameterWheelActionAsync(VTSWheelAction action, CancellationToken ct) { + double? original = await GetParameterValueAsync(action.Target, ct); + if (original == null) { + _logger?.LogWarning( + "[VTSService] Wheel action target parameter \"{Parameter}\" not found on the current model", + action.Target); + return false; + } + + if (!await SetParameterValueAsync(action.Target, action.Value, ct: ct)) return false; + ScheduleRevert(action, original); + return true; + } + + private async Task RunHotkeyWheelActionAsync(VTSWheelAction action, CancellationToken ct) { + if (!await TargetExistsAsync(action, ct)) return false; + if (!await TriggerHotkeyAsync(action.Target, ct: ct)) return false; + ScheduleRevert(action, null); + return true; + } + + private async Task TargetExistsAsync(VTSWheelAction action, CancellationToken ct) { + if (Matches()) return true; + + await RefreshAsync(ct); + if (Matches()) return true; + + _logger?.LogWarning( + "[VTSService] Wheel action target {Kind} \"{Target}\" is not on the current model ({Model})", + action.Kind, action.Target, CurrentModelName ?? "none"); + return false; + + bool Matches() { + return action.Kind switch { + VtsTargetKind.Expression => CachedExpressions.Any(e => + string.Equals(e.File, action.Target, StringComparison.OrdinalIgnoreCase)), + VtsTargetKind.Hotkey => CachedHotkeys.Any(h => + string.Equals(h.Id, action.Target, StringComparison.OrdinalIgnoreCase)), + VtsTargetKind.Parameter => CachedParameters.Any(p => + string.Equals(p.Name, action.Target, StringComparison.Ordinal)), + _ => false + }; + } + } + + private void ScheduleRevert(VTSWheelAction action, double? originalValue) { + if (!action.HasRevert) return; + + if (timerService == null) { + _logger?.LogWarning("[VTSService] No timer service available; skipping the wheel action revert."); + return; + } + + string key = action.TimerKey; + timerService.Register(key, action.Duration, async revertCt => { + timerService.Unregister(key); + await RevertWheelActionAsync(action, originalValue, revertCt); + }); + + _logger?.LogInformation("[VTSService] Wheel action revert for {Target} scheduled in {Duration}", + action.Target, action.Duration); + } + + private async Task RevertWheelActionAsync(VTSWheelAction action, double? originalValue, CancellationToken ct) { + if (!Connected) { + _logger?.LogInformation("[VTSService] Skipping wheel action revert for {Target}: not connected.", + action.Target); + return; + } + + switch (action.Kind) { + case VtsTargetKind.Expression: + await ApplyExpressionActionAsync(action.Target, action.AfterToggle, ct); + break; + + case VtsTargetKind.Parameter: + switch (action.AfterParameter) { + case VtsParameterAfterAction.DoNothing: + ReleaseParameter(action.Target); + break; + case VtsParameterAfterAction.ResetToOriginal: + ReleaseParameter(action.Target); + if (originalValue.HasValue) + await SetParameterValueAsync(action.Target, originalValue.Value, hold: false, ct: ct); + break; + case VtsParameterAfterAction.SetNewValue: + ReleaseParameter(action.Target); + await SetParameterValueAsync(action.Target, action.AfterValue, hold: false, ct: ct); + break; + } + + break; + + case VtsTargetKind.Hotkey: + if (action.AfterHotkey == VtsHotkeyAfterAction.TriggerAgain) + await TriggerHotkeyAsync(action.Target, ct: ct); + break; + } + } +} \ No newline at end of file diff --git a/SubathonManager.UI/Services/ServiceManager.cs b/SubathonManager.UI/Services/ServiceManager.cs index b4de8e0b..85ba6a72 100644 --- a/SubathonManager.UI/Services/ServiceManager.cs +++ b/SubathonManager.UI/Services/ServiceManager.cs @@ -67,6 +67,7 @@ private static IServiceProvider Provider { public static TipeeeStreamService TipeeeStream => Provider.GetRequiredService(); public static TreatStreamService TreatStream => Provider.GetRequiredService(); public static OBSService OBS => Provider.GetRequiredService(); + public static VTSService VTubeStudio => Provider.GetRequiredService(); public static WebServer Server => Provider.GetRequiredService(); @@ -75,6 +76,7 @@ private static IServiceProvider Provider { public async Task StartIntegrationsAsync() { await StartAsync(); + await StartAsync(); await StartAsync(); await StartAsync(); await StartAsync(); @@ -96,6 +98,7 @@ public async Task StartIntegrationsAsync() { public async Task StopIntegrationsAsync() { await StopAsync(); + await StopAsync(); await StopAsync(); await StopAsync(); await StopAsync(); diff --git a/SubathonManager.UI/Services/ServiceRegistration.cs b/SubathonManager.UI/Services/ServiceRegistration.cs index 4d244c04..6dc9fa8a 100644 --- a/SubathonManager.UI/Services/ServiceRegistration.cs +++ b/SubathonManager.UI/Services/ServiceRegistration.cs @@ -91,6 +91,7 @@ public static void AddIntegrations(this IServiceCollection services) { ; services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } private static void ConfigureLogging(ILoggingBuilder builder) { diff --git a/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/VTubeStudioSettings.axaml b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/VTubeStudioSettings.axaml new file mode 100644 index 00000000..c8fc8952 --- /dev/null +++ b/SubathonManager.UI/Views/SettingsViews/ExternalSoftware/VTubeStudioSettings.axaml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs b/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs index 673fe033..c109c7d5 100644 --- a/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs +++ b/SubathonManager.UI/Views/WheelSpin/WheelEditor.axaml.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Text; using Avalonia; using Avalonia.Controls; @@ -17,20 +17,29 @@ using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; +using SubathonManager.Core.Objects; using SubathonManager.Data; +using SubathonManager.Integration; using SubathonManager.UI.Controls; +using SubathonManager.UI.Services; using SubathonManager.UI.UiUtils; +// ReSharper disable NullableWarningSuppressionIsUsed + namespace SubathonManager.UI.Views.WheelSpin; public partial class WheelEditor : UserControl { private const int HistoryPageSize = 10; private static readonly SolidColorBrush SelectedRowBrush = new(Color.FromArgb(30, 100, 149, 237)); + private readonly IConfig _config; private readonly IDbContextFactory _factory; private readonly ILogger _logger; private WheelSet? _activeWheel; + private readonly Dictionary _vtsHotkeyIdsByDisplay = + new(StringComparer.OrdinalIgnoreCase); + private WheelSpinHistoryStatus? _historyFilter; private bool _historyLoading; private int _historyOffset; @@ -48,6 +57,7 @@ public WheelEditor() { _logger = AppServices.Provider.GetRequiredService>(); InitializeComponent(); PopulateActionTypeComboBox(); + PopulateVtsComboBoxes(); LoadActiveWheel(); LoadGlobalState(); @@ -62,15 +72,22 @@ public WheelEditor() { SubathonEvents.SubathonDataUpdate += OnSubathonDataUpdate; WheelEvents.OnSpinsOwedUpdateFromEvent += AdjustSpinsBoxByEvent; WheelEvents.WheelSpinStatusChanged += OnWheelSpinStatusChanged; + ServiceManager.VTubeStudio.ModelDataChanged += OnVtsModelDataChanged; + IntegrationEvents.ConnectionUpdated += OnVtsConnectionUpdated; RefreshSpinsOwed(); }; Unloaded += (_, _) => { SubathonEvents.SubathonDataUpdate -= OnSubathonDataUpdate; WheelEvents.OnSpinsOwedUpdateFromEvent -= AdjustSpinsBoxByEvent; WheelEvents.WheelSpinStatusChanged -= OnWheelSpinStatusChanged; + ServiceManager.VTubeStudio.ModelDataChanged -= OnVtsModelDataChanged; + IntegrationEvents.ConnectionUpdated -= OnVtsConnectionUpdated; }; } + private VtsTargetKind SelectedVtsKind => + (VtsKindBox.SelectedItem as ComboBoxItem)?.Tag as VtsTargetKind? ?? VtsTargetKind.Expression; + private void LoadGlobalState() { int delay = int.TryParse(_config.Get("WheelSpin", "SpinDelaySeconds", "4"), out int d) ? Math.Max(0, d) : 4; SuppressChanges(() => SpinDelayBox.Text = delay.ToString()); @@ -503,6 +520,9 @@ private void SelectItem(WheelItem item, Grid? clickedRow = null) { case WheelSpinActionType.Reroll: ParseRerollParameter(item.Action!.Parameter); break; + case WheelSpinActionType.VTubeStudio: + ParseVtsParameter(item.Action!.Parameter); + break; default: ActionParameterBox.Text = item.Action!.Parameter; break; @@ -515,6 +535,7 @@ private void SelectItem(WheelItem item, Grid? clickedRow = null) { MultiplierTimeCheck.IsChecked = false; MultiplierPointsCheck.IsChecked = false; RerollCountBox.Text = ""; + ResetVtsBoxes(); } }); @@ -548,10 +569,16 @@ private void ShowActionPanelsFor(WheelSpinActionType type) { bool isTime = type is WheelSpinActionType.AddTime or WheelSpinActionType.SubtractTime; bool isMult = type == WheelSpinActionType.SetMultiplier; bool isReroll = type == WheelSpinActionType.Reroll; + bool isVts = type == WheelSpinActionType.VTubeStudio; TimeParamPanel.IsVisible = isTime; MultiplierParamPanel.IsVisible = isMult; RerollParamPanel.IsVisible = isReroll; + VtsParamPanel.IsVisible = isVts; if (isTime) UpdateActionHint(type); + if (isVts) { + RefreshVtsKindDependentBoxes(); + LoadVtsTargetSuggestions(); + } } private void UpdateActionHint(WheelSpinActionType type) { @@ -681,6 +708,7 @@ private async Task SaveAsync(object? sender, RoutedEventArgs? e) { string param = desiredActionType!.Value switch { WheelSpinActionType.SetMultiplier => BuildMultiplierParameter(), WheelSpinActionType.Reroll => BuildRerollParameter(), + WheelSpinActionType.VTubeStudio => BuildVtsParameter(), _ => (ActionParameterBox.Text ?? "").Trim() }; @@ -861,6 +889,10 @@ private async Task PerformSpinAsync() { histEntry.LinkedWheel = _activeWheel; await Dispatcher.UIThread.InvokeAsync(() => PrependHistoryRow(histEntry)); WheelEvents.RaiseWheelSpinResult(_activeWheel, item, histEntry, _spinsOwed); + + if (item.Action is { ActionType: WheelSpinActionType.VTubeStudio } vtsAction) + await TryRunVtsActionAsync(histEntry, vtsAction); + if (item.Action?.ActionType.IsDoneImmediately() ?? false) switch (item.Action.ActionType) { case WheelSpinActionType.AddTime: @@ -948,13 +980,23 @@ private void SuppressChanges(Action action) { private void AttachChangeHandlers() { foreach (TextBox? box in new[] { ItemTextBox, ItemWeightBox, ItemQuantityBox, ActionParameterBox, - MultiplierAmountBox, MultiplierDurationBox, RerollCountBox + MultiplierAmountBox, MultiplierDurationBox, RerollCountBox, + VtsValueBox, VtsDurationBox, VtsAfterValueBox }) { box.TextChanged += OnFieldChanged; DirtySaveGuard.Rebase(box); } + VtsTargetBox.TextChanged += (s, _) => { + if (!DirtySaveGuard.Consume(s)) return; + MarkPendingChanges(); + }; + DirtySaveGuard.Rebase(VtsTargetBox); + DirtySaveGuard.Rebase(ActionTypeBox); + DirtySaveGuard.Rebase(VtsKindBox); + DirtySaveGuard.Rebase(VtsToggleActionBox); + DirtySaveGuard.Rebase(VtsAfterBox); DirtySaveGuard.Rebase(MultiplierTimeCheck); DirtySaveGuard.Rebase(MultiplierPointsCheck); DirtySaveGuard.Rebase(ItemInfiniteCheck); @@ -994,6 +1036,17 @@ private bool IsCurrentUiActionValid(out string error) { return true; } + if (selected == WheelSpinActionType.VTubeStudio) { + VTSWheelAction action = ReadVtsBoxes(); + if (!action.IsValid(out string vtsError)) { + error = vtsError; + return false; + } + + error = ""; + return true; + } + if (selected == WheelSpinActionType.SetMultiplier) { if (!double.TryParse((MultiplierAmountBox.Text ?? "").Trim(), NumberStyles.Float, @@ -1051,6 +1104,15 @@ private static bool IsItemActionValid(WheelItem item, out string error) { return true; } + if (type == WheelSpinActionType.VTubeStudio) { + if (!VTSWheelAction.TryParse(param, out VTSWheelAction? vtsAction)) { + error = "VTube Studio action parameters are incomplete"; + return false; + } + + return vtsAction.IsValid(out error); + } + if (type == WheelSpinActionType.SetMultiplier) { string[] parts = param.Split('|'); if (parts.Length < 4) { @@ -1192,11 +1254,9 @@ void AddBtn(WheelSpinHistoryStatus target, string glyph, string tip) { WheelSpinActionType? actionType = h.LinkedItem?.Action?.ActionType; bool hasPlayBtn = h.Status == WheelSpinHistoryStatus.Pending - && actionType.HasValue && actionType.Value.IsCommand(); + && actionType.HasValue && actionType.Value.HasPlayAction(); - string actionStr = h.LinkedItem?.Action == null - ? "Manual" - : $"{h.LinkedItem.Action.ActionType}: {h.LinkedItem.Action.Parameter}"; + string actionStr = DescribeHistoryAction(h.LinkedItem?.Action); var actionCell = new Grid { Margin = new Thickness(4, 0, 2, 0), @@ -1217,6 +1277,7 @@ void AddBtn(WheelSpinHistoryStatus target, string glyph, string tip) { if (hasPlayBtn) { bool isMultiplier = actionType == WheelSpinActionType.SetMultiplier; + bool isVts = actionType == WheelSpinActionType.VTubeStudio; var playBtn = new Button { Content = new SymIcon { Glyph = "Play16" }, Width = 20, Height = 20, @@ -1225,7 +1286,11 @@ void AddBtn(WheelSpinHistoryStatus target, string glyph, string tip) { IsEnabled = !isMultiplier || !_multiplierActive, Tag = isMultiplier ? "MultiplierPlayBtn" : null }; - ToolTip.SetTip(playBtn, isMultiplier ? "Apply multiplier (disabled while one is active)" : "Apply command"); + ToolTip.SetTip(playBtn, isMultiplier + ? "Apply multiplier (disabled while one is active)" + : isVts + ? "Run the VTube Studio action (needs VTube Studio connected)" + : "Apply command"); playBtn.Click += async (_, _) => await ExecuteHistoryAction(h, playBtn); Grid.SetColumn(playBtn, 1); actionCell.Children.Add(playBtn); @@ -1292,6 +1357,11 @@ private void OnWheelSpinStatusChanged(WheelSpinHistory history, int spinsOwed) { if (hoverBtns != null) foreach (Button btn in hoverBtns.Children.OfType + + + + + + + + + + - + + + + + + From 4749b04e49bc39e411276a46f2ecfb2670368bb6 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:45:22 -0300 Subject: [PATCH 35/39] fix tests --- .../GoAffProServiceTests.cs | 8 ++++---- .../Utility/MockWebServerHost.cs | 20 ++++++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs index 266bfb53..350c1645 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs @@ -232,8 +232,8 @@ public async Task StartClient_Test(string email, string password) { Assert.NotNull(status); Assert.True(status); await service.StopAsync(TestContext.Current.CancellationToken); - Assert.Equal(1, webserver.PostCallCount); - Assert.Equal(1, webserver.GetCallCount); + Assert.Equal(1, webserver.CallCount("POST", "/user/login")); + Assert.Equal(1, webserver.CallCount("GET", "/user/sites")); } [Fact] @@ -307,8 +307,8 @@ public async Task ErrorFromSitesEndpoint_Test() { Assert.NotNull(status); Assert.False(status); await service.StopAsync(TestContext.Current.CancellationToken); - Assert.Equal(1, webserver.PostCallCount); - Assert.Equal(2, webserver.GetCallCount); + Assert.Equal(1, webserver.CallCount("POST", "/user/login")); + Assert.Equal(2, webserver.CallCount("GET", "/user/sites")); } [Fact] diff --git a/SubathonManager.Tests/Utility/MockWebServerHost.cs b/SubathonManager.Tests/Utility/MockWebServerHost.cs index 5ab4ad2b..b5b904d2 100644 --- a/SubathonManager.Tests/Utility/MockWebServerHost.cs +++ b/SubathonManager.Tests/Utility/MockWebServerHost.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.Collections.Concurrent; +using System.Net; using System.Net.Sockets; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -9,6 +10,10 @@ namespace SubathonManager.Tests.Utility; public class MockWebServerHost : IAsyncDisposable { private readonly WebApplication _app; + private readonly ConcurrentDictionary<(string method, string path), int> _callCounts = new(); + private int _getCallCount; + private int _postCallCount; + private readonly Dictionary<(string method, string path), Func> _dynamicRoutes = new(); @@ -23,8 +28,9 @@ public MockWebServerHost(int port = 0) { _app = builder.Build(); _app.Use(async (context, next) => { - if (context.Request.Method == "GET") GetCallCount += 1; - else if (context.Request.Method == "POST") PostCallCount += 1; + if (context.Request.Method == "GET") Interlocked.Increment(ref _getCallCount); + else if (context.Request.Method == "POST") Interlocked.Increment(ref _postCallCount); + _callCounts.AddOrUpdate((context.Request.Method, context.Request.Path.Value ?? "/"), 1, (_, c) => c + 1); // useful for breakpoint checking Console.WriteLine( $"[MockServer] Incoming: {context.Request.Method} {context.Request.Path}{context.Request.QueryString}"); @@ -63,8 +69,12 @@ public MockWebServerHost(int port = 0) { } public string BaseUrl { get; } - public int GetCallCount { get; private set; } - public int PostCallCount { get; private set; } + public int GetCallCount => Volatile.Read(ref _getCallCount); + public int PostCallCount => Volatile.Read(ref _postCallCount); + + public int CallCount(string method, string path) { + return _callCounts.GetValueOrDefault((method, path)); + } public async ValueTask DisposeAsync() { await _app.StopAsync(); From f692cd84497bea739152d27fec0c7d0d8a2758ad Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:14:17 -0300 Subject: [PATCH 36/39] add better hints for devtunnel connected status --- .../Views/SettingsViews/External/KoFi/KoFiWebhookSettings.axaml | 1 + .../Views/SettingsViews/External/ThroneSettings.axaml | 1 + .../SettingsViews/ExternalSoftware/DevTunnelsSettings.axaml | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiWebhookSettings.axaml b/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiWebhookSettings.axaml index 5eaca50c..ec09f300 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiWebhookSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/External/KoFi/KoFiWebhookSettings.axaml @@ -11,6 +11,7 @@ + + + Text="Install the Azure DevTunnels CLI from Microsoft. Once installed, click Check to confirm. If it is installed but not found, try restarting SubathonManager" /> From 412e616e429b65cd9afa140c48a50ab594fc9d83 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:43:40 -0300 Subject: [PATCH 37/39] adjust window opening behaviour --- SubathonManager.UI/MainWindow.Routes.cs | 1 + SubathonManager.UI/UiUtils/UiHelpers.cs | 27 +++++++++++++++++++ .../Views/SettingsView.Settings.cs | 25 ++++++++++++++--- .../Views/SubathonSummaryWindow.Breakdown.cs | 4 +-- .../Views/SubathonSummaryWindow.axaml | 2 +- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/SubathonManager.UI/MainWindow.Routes.cs b/SubathonManager.UI/MainWindow.Routes.cs index d6c5cad1..6ae8b148 100644 --- a/SubathonManager.UI/MainWindow.Routes.cs +++ b/SubathonManager.UI/MainWindow.Routes.cs @@ -302,6 +302,7 @@ internal void OpenRouteEditor(Route route) { _editWindow = null; }; + UiHelpers.CenterOver(_editWindow, this); _editWindow.Show(); _editWindow.Activate(); } diff --git a/SubathonManager.UI/UiUtils/UiHelpers.cs b/SubathonManager.UI/UiUtils/UiHelpers.cs index a1fe14c8..9af166c8 100644 --- a/SubathonManager.UI/UiUtils/UiHelpers.cs +++ b/SubathonManager.UI/UiUtils/UiHelpers.cs @@ -12,6 +12,33 @@ namespace SubathonManager.UI.UiUtils; public static class UiHelpers { + + public static void CenterOver(Window window, Window? anchor) { + if (anchor == null || anchor.WindowState == WindowState.Minimized) { + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + return; + } + + double scale = anchor.DesktopScaling; + Size anchorSize = anchor.FrameSize ?? anchor.ClientSize; + double width = double.IsNaN(window.Width) ? window.MinWidth : window.Width; + double height = double.IsNaN(window.Height) ? window.MinHeight : window.Height; + + var x = (int)(anchor.Position.X + (anchorSize.Width - width) * scale / 2); + var y = (int)(anchor.Position.Y + (anchorSize.Height - height) * scale / 2); + + PixelRect? area = anchor.Screens.ScreenFromWindow(anchor)?.WorkingArea; + if (area is { } a) { + var pixelWidth = (int)(width * scale); + var pixelHeight = (int)(height * scale); + x = Math.Max(a.X, Math.Min(x, a.Right - pixelWidth)); + y = Math.Max(a.Y, Math.Min(y, a.Bottom - pixelHeight)); + } + + window.WindowStartupLocation = WindowStartupLocation.Manual; + window.Position = new PixelPoint(x, y); + } + public static async Task TrySetClipboardTextAsync(string text) { IClipboard? clipboard = GetClipboard(); if (clipboard == null) return false; diff --git a/SubathonManager.UI/Views/SettingsView.Settings.cs b/SubathonManager.UI/Views/SettingsView.Settings.cs index 6db9b299..3a315c4d 100644 --- a/SubathonManager.UI/Views/SettingsView.Settings.cs +++ b/SubathonManager.UI/Views/SettingsView.Settings.cs @@ -31,10 +31,29 @@ private void OpenDataFolder_Click(object? sender, RoutedEventArgs e) { UiHelpers.OpenFolder(Config.DataFolder); } - private async void EventsSummary_Click(object? sender, RoutedEventArgs e) { + private SubathonSummaryWindow? _summaryWindow; + + private void EventsSummary_Click(object? sender, RoutedEventArgs e) { + if (_summaryWindow != null) { + if (_summaryWindow.WindowState == WindowState.Minimized) _summaryWindow.WindowState = WindowState.Normal; + _summaryWindow.Activate(); + return; + } + var window = new SubathonSummaryWindow(); - if (TopLevel.GetTopLevel(this) is Window owner) await window.ShowDialog(owner); - else window.Show(); + _summaryWindow = window; + + Window? main = TopLevel.GetTopLevel(this) as Window; + void CloseWithMain(object? s, EventArgs a) => window.Close(); + if (main != null) main.Closed += CloseWithMain; + + window.Closed += (_, _) => { + if (main != null) main.Closed -= CloseWithMain; + _summaryWindow = null; + }; + + UiHelpers.CenterOver(window, main); + window.Show(); } private async void ExportEvents_Click(object? sender, RoutedEventArgs e) { diff --git a/SubathonManager.UI/Views/SubathonSummaryWindow.Breakdown.cs b/SubathonManager.UI/Views/SubathonSummaryWindow.Breakdown.cs index 80ac94e2..75d752d4 100644 --- a/SubathonManager.UI/Views/SubathonSummaryWindow.Breakdown.cs +++ b/SubathonManager.UI/Views/SubathonSummaryWindow.Breakdown.cs @@ -106,7 +106,7 @@ private void Render(BreakdownAggregate agg) { Add(source, count.ToString("N0"), 1); Add("Event Types", "", 0, true); - foreach ((SubathonEventType? type, Tally tally) in agg.Types.OrderByDescending(t => t.Value.Count)) { + foreach ((SubathonEventType type, Tally tally) in agg.Types.OrderByDescending(t => t.Value.Count)) { Add($"{type.GetSource()} {type.GetLabel()}", $"{tally.Count:N0} ({tally.Amount:N0} amt)", 1); List> metas = agg.Metas @@ -135,7 +135,7 @@ private async Task ConvertMoneyTotalAsync(BreakdownAggregate agg) { var total = 0.0; List unconverted = []; - var service = AppServices.Provider?.GetService(); + var service = AppServices.Provider.GetService(); foreach ((string currency, double sum) in agg.Currencies) { if (string.Equals(currency, target, StringComparison.OrdinalIgnoreCase)) { diff --git a/SubathonManager.UI/Views/SubathonSummaryWindow.axaml b/SubathonManager.UI/Views/SubathonSummaryWindow.axaml index 5c214e41..b0ba50a9 100644 --- a/SubathonManager.UI/Views/SubathonSummaryWindow.axaml +++ b/SubathonManager.UI/Views/SubathonSummaryWindow.axaml @@ -9,7 +9,7 @@ Title="Subathon Summary" Width="1280" Height="820" MinWidth="940" MinHeight="600" - WindowStartupLocation="CenterOwner" + WindowStartupLocation="CenterScreen" Background="{DynamicResource AppGeneralBackground}"> From dea1c39b852eba80a7f630eec280300a68a5b9d2 Mon Sep 17 00:00:00 2001 From: WolfwithSword <12175651+WolfwithSword@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:17:55 -0300 Subject: [PATCH 38/39] allow main window to be resized --- SubathonManager.UI/MainWindow.axaml | 11 +++-- SubathonManager.UI/Views/GoalsEditor.axaml | 43 ++++++++++--------- SubathonManager.UI/Views/GoalsEditor.axaml.cs | 22 ++++++---- SubathonManager.UI/Views/GoalsView.axaml | 10 +++-- 4 files changed, 48 insertions(+), 38 deletions(-) diff --git a/SubathonManager.UI/MainWindow.axaml b/SubathonManager.UI/MainWindow.axaml index eed72618..0cb536da 100644 --- a/SubathonManager.UI/MainWindow.axaml +++ b/SubathonManager.UI/MainWindow.axaml @@ -12,7 +12,6 @@ Title="Subathon Manager" Width="1200" Height="800" MinWidth="1200" MinHeight="800" - CanResize="False" WindowStartupLocation="CenterScreen" Background="{DynamicResource AppGeneralBackground}"> @@ -21,7 +20,7 @@ - + - + @@ -262,8 +261,8 @@ - - + + - + diff --git a/SubathonManager.UI/Views/GoalsEditor.axaml b/SubathonManager.UI/Views/GoalsEditor.axaml index 0c173490..cb3bf7e8 100644 --- a/SubathonManager.UI/Views/GoalsEditor.axaml +++ b/SubathonManager.UI/Views/GoalsEditor.axaml @@ -8,27 +8,30 @@ - - - + + + HorizontalAlignment="Stretch" LostFocus="GoalSetNameBox_LostFocus" /> - - - - - - + + + + + + + - + @@ -50,20 +53,20 @@ - - - - - + Margin="8,0,0,0" SelectionChanged="GoalSetType_SelectionChanged" /> + { _suppressCount--; @@ -239,12 +239,14 @@ private async void LoadGoals() { }, DispatcherPriority.Background); } - private StackPanel AddGoalRow(SubathonGoal goal, bool isUnsaved) { - var panel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(4, 0, 4, 8) }; + private Grid AddGoalRow(SubathonGoal goal, bool isUnsaved) { + var panel = new Grid { + Margin = new Thickness(4, 0, 4, 8), + ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto") + }; var textBox = new TextBox { Text = goal.Text, - Width = 522, Margin = new Thickness(0, 0, 8, 0), PlaceholderText = "Goal Description...", VerticalContentAlignment = VerticalAlignment.Center @@ -279,6 +281,10 @@ private StackPanel AddGoalRow(SubathonGoal goal, bool isUnsaved) { ToolTip.SetTip(deleteBtn, "Remove"); deleteBtn.Click += (_, _) => DeleteGoal_Click(goal); + Grid.SetColumn(textBox, 0); + Grid.SetColumn(pointsBox, 1); + Grid.SetColumn(deleteBtn, 2); + panel.Children.Add(textBox); panel.Children.Add(pointsBox); panel.Children.Add(deleteBtn); @@ -293,7 +299,7 @@ private void AppendBlankGoalRow() { if (_activeGoalSet == null) return; var goal = new SubathonGoal { Text = "", Points = 0, GoalSetId = _activeGoalSet.Id }; - StackPanel panel = AddGoalRow(goal, true); + Grid panel = AddGoalRow(goal, true); Dispatcher.UIThread.Post(() => { panel.BringIntoView(); @@ -302,7 +308,7 @@ private void AppendBlankGoalRow() { } private bool IsInLastGoalRow(object? source) { - StackPanel? last = GoalsStack.Children.OfType().LastOrDefault(); + Grid? last = GoalsStack.Children.OfType().LastOrDefault(); if (last == null) return false; var visual = source as Visual; @@ -316,7 +322,7 @@ private bool IsInLastGoalRow(object? source) { private async void DeleteGoal_Click(SubathonGoal goal) { if (_unsavedGoals.Remove(goal)) { - StackPanel? row = GoalsStack.Children.OfType() + Grid? row = GoalsStack.Children.OfType() .FirstOrDefault(p => ReferenceEquals(p.Tag, goal)); if (row != null) GoalsStack.Children.Remove(row); return; @@ -369,7 +375,7 @@ private async Task SaveGoalsAsync(object? sender, RoutedEventArgs? e) { await using AppDbContext db = await _factory.CreateDbContextAsync(); db.Update(_activeGoalSet); - foreach (StackPanel panel in GoalsStack.Children.OfType()) { + foreach (Grid panel in GoalsStack.Children.OfType()) { if (panel.Tag is not SubathonGoal goal) continue; var textBox = panel.Children[0] as TextBox; var pointsBox = panel.Children[1] as TextBox; diff --git a/SubathonManager.UI/Views/GoalsView.axaml b/SubathonManager.UI/Views/GoalsView.axaml index ea26c3c9..acf768b4 100644 --- a/SubathonManager.UI/Views/GoalsView.axaml +++ b/SubathonManager.UI/Views/GoalsView.axaml @@ -5,10 +5,12 @@ mc:Ignorable="d" d:DesignHeight="400" d:DesignWidth="600" x:CompileBindings="False" x:Class="SubathonManager.UI.Views.GoalsView"> - - - + + + + Date: Sat, 19 Sep 2026 20:10:33 -0300 Subject: [PATCH 39/39] allow drag ordering goals --- SubathonManager.Core/Models/StateValue.cs | 2 + SubathonManager.UI/Views/GoalsEditor.axaml | 18 +- SubathonManager.UI/Views/GoalsEditor.axaml.cs | 196 ++++++++++++++++-- 3 files changed, 199 insertions(+), 17 deletions(-) diff --git a/SubathonManager.Core/Models/StateValue.cs b/SubathonManager.Core/Models/StateValue.cs index c71f0374..ed7c0971 100644 --- a/SubathonManager.Core/Models/StateValue.cs +++ b/SubathonManager.Core/Models/StateValue.cs @@ -17,4 +17,6 @@ public static class StateKeys { public const string WidgetBrowserSkipDeleteConfirm = "WidgetBrowserSkipDeleteConfirm"; public const string WidgetBrowserAllVersions = "WidgetBrowserAllVersions"; public const string EditorPreviewLightBg = "EditorPreviewLightBg"; + + public const string GoalAutoIncrement = "GoalAutoIncrement"; } \ No newline at end of file diff --git a/SubathonManager.UI/Views/GoalsEditor.axaml b/SubathonManager.UI/Views/GoalsEditor.axaml index cb3bf7e8..d439eaef 100644 --- a/SubathonManager.UI/Views/GoalsEditor.axaml +++ b/SubathonManager.UI/Views/GoalsEditor.axaml @@ -20,10 +20,11 @@ - - - - + + + + + + + + + + diff --git a/SubathonManager.UI/Views/GoalsEditor.axaml.cs b/SubathonManager.UI/Views/GoalsEditor.axaml.cs index a0ba52fc..24033976 100644 --- a/SubathonManager.UI/Views/GoalsEditor.axaml.cs +++ b/SubathonManager.UI/Views/GoalsEditor.axaml.cs @@ -28,6 +28,11 @@ public partial class GoalsEditor : UserControl { private readonly HashSet _unsavedGoals = new(); private SubathonGoalSet? _activeGoalSet; private bool _appendRowAfterSave; + private int _autoIncrement = 1; + private List _dragPoints = []; + private Grid? _dragRow; + private int _dragStartIndex = -1; + private CancellationTokenSource? _incrementSaveCts; private bool _initialized; private int _suppressCount; @@ -35,6 +40,8 @@ public GoalsEditor() { _factory = AppServices.Provider.GetRequiredService>(); InitializeComponent(); GoalSetType.ItemsSource = Enum.GetNames().ToList(); + NumericInputBehaviour.SetMode(AutoIncrementBox, NumericInputBehaviour.NumericMode.Integer); + LoadAutoIncrement(); LoadAllSets(); SubathonEvents.SubathonDataUpdate += UpdatePointsCount; @@ -68,7 +75,7 @@ private void LoadAllSets() { }); if (allSets.Count == 0) { - StatusText.Text = "No goal sets found. Create a new one."; + StatusText.Text = "No goal sets found. Create a new one"; DeleteGoalSetBtn.IsEnabled = false; return; } @@ -92,7 +99,7 @@ private void LoadSetById(Guid setId) { .FirstOrDefault(gs => gs.Id == setId); if (_activeGoalSet == null) { - StatusText.Text = "Set not found."; + StatusText.Text = "Set not found"; return; } @@ -231,7 +238,6 @@ private async void LoadGoals() { AppendBlankGoalRow(); } - // no explicit Height: the scroller fills its grid row, so the list stays pinned to the top GoalsEditorScroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto; Dispatcher.UIThread.Post(() => { _suppressCount--; @@ -242,7 +248,7 @@ private async void LoadGoals() { private Grid AddGoalRow(SubathonGoal goal, bool isUnsaved) { var panel = new Grid { Margin = new Thickness(4, 0, 4, 8), - ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto") + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto") }; var textBox = new TextBox { @@ -281,13 +287,17 @@ private Grid AddGoalRow(SubathonGoal goal, bool isUnsaved) { ToolTip.SetTip(deleteBtn, "Remove"); deleteBtn.Click += (_, _) => DeleteGoal_Click(goal); - Grid.SetColumn(textBox, 0); - Grid.SetColumn(pointsBox, 1); - Grid.SetColumn(deleteBtn, 2); + Border grip = CreateDragGrip(panel); + + Grid.SetColumn(textBox, 1); + Grid.SetColumn(pointsBox, 2); + Grid.SetColumn(deleteBtn, 3); + Grid.SetColumn(grip, 0); panel.Children.Add(textBox); panel.Children.Add(pointsBox); panel.Children.Add(deleteBtn); + panel.Children.Add(grip); panel.Tag = goal; if (isUnsaved) _unsavedGoals.Add(goal); @@ -295,15 +305,128 @@ private Grid AddGoalRow(SubathonGoal goal, bool isUnsaved) { return panel; } + private static TextBox? RowTextBox(Grid row) { + return row.Children[0] as TextBox; + } + + private static TextBox? RowPointsBox(Grid row) { + return row.Children[1] as TextBox; + } + + private List GoalRows() { + return GoalsStack.Children.OfType().ToList(); + } + + private Border CreateDragGrip(Grid row) { + var grip = new Border { + Width = 18, + Margin = new Thickness(0, 0, 4, 0), + Background = Brushes.Transparent, + VerticalAlignment = VerticalAlignment.Stretch, + Cursor = new Cursor(StandardCursorType.SizeAll), + Child = new SymIcon { + Glyph = "ReOrderDotsVertical20", + Opacity = 0.6, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }; + ToolTip.SetTip(grip, "Drag to reorder. Points stay where they were"); + + grip.PointerPressed += (_, e) => BeginRowDrag(grip, row, e); + grip.PointerMoved += (_, e) => RowDragMove(e); + grip.PointerReleased += (_, e) => { + e.Pointer.Capture(null); + FinishRowDrag(); + }; + grip.PointerCaptureLost += (_, _) => FinishRowDrag(); + return grip; + } + + private void BeginRowDrag(Border grip, Grid row, PointerPressedEventArgs e) { + if (!e.GetCurrentPoint(grip).Properties.IsLeftButtonPressed) return; + + List rows = GoalRows(); + int index = rows.IndexOf(row); + if (index < 0 || rows.Count < 2) return; + + if (rows.Any(r => !long.TryParse(RowPointsBox(r)?.Text, out _))) { + StatusText.Text = "Every goal needs a points value before reordering"; + return; + } + + StatusText.Text = ""; + _dragRow = row; + _dragStartIndex = index; + _dragPoints = rows.Select(r => RowPointsBox(r)?.Text ?? "").ToList(); + row.Opacity = 0.6; + e.Pointer.Capture(grip); + e.Handled = true; + } + + private void RowDragMove(PointerEventArgs e) { + if (_dragRow == null) return; + + List rows = GoalRows(); + int current = rows.IndexOf(_dragRow); + if (current < 0 || rows.Count < 2) return; + + double y = e.GetPosition(GoalsStack).Y; + int target = current; + if (y <= rows[0].Bounds.Top) { + target = 0; + } + else if (y >= rows[^1].Bounds.Bottom) { + target = rows.Count - 1; + } + else { + for (var i = 0; i < rows.Count; i++) { + if (y < rows[i].Bounds.Top || y > rows[i].Bounds.Bottom) continue; + target = i; + break; + } + } + + if (target == current) return; + GoalsStack.Children.Move(current, target); + } + + private void FinishRowDrag() { + if (_dragRow == null) return; + + Grid row = _dragRow; + _dragRow = null; + row.Opacity = 1; + + List rows = GoalRows(); + int index = rows.IndexOf(row); + int startIndex = _dragStartIndex; + _dragStartIndex = -1; + if (index < 0 || index == startIndex) return; + + for (var i = 0; i < rows.Count && i < _dragPoints.Count; i++) { + TextBox? pointsBox = RowPointsBox(rows[i]); + if (pointsBox == null || pointsBox.Text == _dragPoints[i]) continue; + pointsBox.Text = _dragPoints[i]; + } + + UpdateSaveButtonBorder(true); + } + private void AppendBlankGoalRow() { if (_activeGoalSet == null) return; - var goal = new SubathonGoal { Text = "", Points = 0, GoalSetId = _activeGoalSet.Id }; + var goal = new SubathonGoal { Text = "", Points = NextGoalPoints(), GoalSetId = _activeGoalSet.Id }; Grid panel = AddGoalRow(goal, true); + TextBox? pointsBox = RowPointsBox(panel); + if (pointsBox != null) { + pointsBox.Text = goal.Points.ToString(); + DirtySaveGuard.Rebase(pointsBox); + } Dispatcher.UIThread.Post(() => { panel.BringIntoView(); - (panel.Children[0] as TextBox)?.Focus(); + RowTextBox(panel)?.Focus(); }, DispatcherPriority.Background); } @@ -344,8 +467,7 @@ private async void AddGoal_Click(object? sender, RoutedEventArgs e) { await using AppDbContext db = await _factory.CreateDbContextAsync(); - long maxPoints = _activeGoalSet.Goals.Count > 0 ? _activeGoalSet.Goals.Max(g => g.Points) : 0; - var newGoal = new SubathonGoal { Points = maxPoints + 1, GoalSetId = _activeGoalSet.Id }; + var newGoal = new SubathonGoal { Points = NextGoalPoints(), GoalSetId = _activeGoalSet.Id }; db.SubathonGoals.Add(newGoal); await db.SaveChangesAsync(); @@ -377,8 +499,8 @@ private async Task SaveGoalsAsync(object? sender, RoutedEventArgs? e) { foreach (Grid panel in GoalsStack.Children.OfType()) { if (panel.Tag is not SubathonGoal goal) continue; - var textBox = panel.Children[0] as TextBox; - var pointsBox = panel.Children[1] as TextBox; + TextBox? textBox = RowTextBox(panel); + TextBox? pointsBox = RowPointsBox(panel); bool hasPoints = long.TryParse(pointsBox?.Text, out long pts); if (string.IsNullOrWhiteSpace(textBox?.Text) || !hasPoints) { @@ -433,6 +555,54 @@ private void Grid_PointerPressed(object? sender, PointerPressedEventArgs e) { (sender as Control)?.Focus(); } + private void LoadAutoIncrement() { + using AppDbContext db = _factory.CreateDbContext(); + _autoIncrement = Math.Max(1, StateValueHelper.Get(db, StateKeys.GoalAutoIncrement, 1)); + SuppressChanges(() => AutoIncrementBox.Text = _autoIncrement.ToString()); + DirtySaveGuard.Rebase(AutoIncrementBox); + } + + private long NextGoalPoints() { + long max = 0; + var any = false; + foreach (Grid row in GoalRows()) { + if (!long.TryParse(RowPointsBox(row)?.Text, out long pts)) continue; + max = any ? Math.Max(max, pts) : pts; + any = true; + } + + if (!any && _activeGoalSet is { Goals.Count: > 0 }) { + max = _activeGoalSet.Goals.Max(g => g.Points); + any = true; + } + + return any ? max + _autoIncrement : _autoIncrement; + } + + private void AutoIncrement_OnChanged(object? sender, TextChangedEventArgs e) { + if (!int.TryParse(AutoIncrementBox.Text, out int value) || value < 1) return; + if (value == _autoIncrement) return; + _autoIncrement = value; + + _incrementSaveCts?.Cancel(); + var cts = new CancellationTokenSource(); + _incrementSaveCts = cts; + _ = Task.Run(async () => { + try { + await Task.Delay(400, cts.Token); + await StateValueHelper.SetAsync(_factory, StateKeys.GoalAutoIncrement, value); + } + catch (OperationCanceledException) { + /**/ + } + }, cts.Token); + } + + private void AutoIncrement_OnLostFocus(object? sender, RoutedEventArgs e) { + if (int.TryParse(AutoIncrementBox.Text, out int value) && value >= 1) return; + AutoIncrementBox.Text = _autoIncrement.ToString(); + } + private void Value_OnChanged(object? sender, TextChangedEventArgs e) { bool realChange = DirtySaveGuard.Consume(sender); if (_suppressCount > 0 || !realChange) return;