578 lines
21 KiB
C#
578 lines
21 KiB
C#
using Dalamud.Interface;
|
|
using LightlessSync.LightlessConfiguration.Models;
|
|
using LightlessSync.UI;
|
|
using LightlessSync.UI.Models;
|
|
using LightlessSync.API.Dto.Group;
|
|
using LightlessSync.API.Dto.User;
|
|
using LightlessSync.LightlessConfiguration;
|
|
using LightlessSync.Services.Mediator;
|
|
using LightlessSync.Utils;
|
|
using LightlessSync.WebAPI;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Threading;
|
|
|
|
namespace LightlessSync.Services;
|
|
public class BroadcastService : IHostedService, IMediatorSubscriber
|
|
{
|
|
private readonly ILogger<BroadcastService> _logger;
|
|
private readonly ApiController _apiController;
|
|
private readonly LightlessMediator _mediator;
|
|
private readonly LightlessConfigService _config;
|
|
private readonly DalamudUtilService _dalamudUtil;
|
|
private CancellationTokenSource? _lightfinderCancelTokens;
|
|
private Action? _connectedHandler;
|
|
public LightlessMediator Mediator => _mediator;
|
|
|
|
public bool IsLightFinderAvailable { get; private set; } = false;
|
|
|
|
public bool IsBroadcasting => _config.Current.BroadcastEnabled;
|
|
private bool _syncedOnStartup = false;
|
|
private bool _waitingForTtlFetch = false;
|
|
private TimeSpan? _remainingTtl = null;
|
|
private DateTime _lastTtlCheck = DateTime.MinValue;
|
|
private DateTime _lastForcedDisableTime = DateTime.MinValue;
|
|
private static readonly TimeSpan _disableCooldown = TimeSpan.FromSeconds(5);
|
|
public TimeSpan? RemainingTtl => _remainingTtl;
|
|
public TimeSpan? RemainingCooldown
|
|
{
|
|
get
|
|
{
|
|
var elapsed = DateTime.UtcNow - _lastForcedDisableTime;
|
|
if (elapsed >= _disableCooldown) return null;
|
|
return _disableCooldown - elapsed;
|
|
}
|
|
}
|
|
|
|
public BroadcastService(ILogger<BroadcastService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
|
|
{
|
|
_logger = logger;
|
|
_mediator = mediator;
|
|
_config = config;
|
|
_dalamudUtil = dalamudUtil;
|
|
_apiController = apiController;
|
|
}
|
|
|
|
private async Task RequireConnectionAsync(string context, Func<Task> action)
|
|
{
|
|
if (!_apiController.IsConnected)
|
|
{
|
|
_logger.LogDebug(context + " skipped, not connected");
|
|
return;
|
|
}
|
|
await action().ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<string?> GetLocalHashedCidAsync(string context)
|
|
{
|
|
try
|
|
{
|
|
var cid = await _dalamudUtil.GetCIDAsync().ConfigureAwait(false);
|
|
return cid.ToString().GetHash256();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to resolve CID for {Context}", context);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private void ApplyBroadcastDisabled(bool forcePublish = false)
|
|
{
|
|
bool wasEnabled = _config.Current.BroadcastEnabled;
|
|
bool hadExpiry = _config.Current.BroadcastTtl != DateTime.MinValue;
|
|
bool hadRemaining = _remainingTtl.HasValue;
|
|
|
|
_config.Current.BroadcastEnabled = false;
|
|
_config.Current.BroadcastTtl = DateTime.MinValue;
|
|
|
|
if (wasEnabled || hadExpiry)
|
|
_config.Save();
|
|
|
|
_remainingTtl = null;
|
|
_waitingForTtlFetch = false;
|
|
_syncedOnStartup = false;
|
|
|
|
if (forcePublish || wasEnabled || hadRemaining)
|
|
_mediator.Publish(new BroadcastStatusChangedMessage(false, null));
|
|
}
|
|
|
|
private bool TryApplyBroadcastEnabled(TimeSpan? ttl, string context)
|
|
{
|
|
if (ttl is not { } validTtl || validTtl <= TimeSpan.Zero)
|
|
{
|
|
_logger.LogWarning("Lightfinder enable skipped ({Context}): invalid TTL ({TTL})", context, ttl);
|
|
return false;
|
|
}
|
|
|
|
bool wasEnabled = _config.Current.BroadcastEnabled;
|
|
TimeSpan? previousRemaining = _remainingTtl;
|
|
DateTime previousExpiry = _config.Current.BroadcastTtl;
|
|
|
|
var newExpiry = DateTime.UtcNow + validTtl;
|
|
|
|
_config.Current.BroadcastEnabled = true;
|
|
_config.Current.BroadcastTtl = newExpiry;
|
|
|
|
if (!wasEnabled || previousExpiry != newExpiry)
|
|
_config.Save();
|
|
|
|
_remainingTtl = validTtl;
|
|
_waitingForTtlFetch = false;
|
|
|
|
if (!wasEnabled || previousRemaining != validTtl)
|
|
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl));
|
|
|
|
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
|
|
return true;
|
|
}
|
|
|
|
private void HandleLightfinderUnavailable(string message, Exception? ex = null)
|
|
{
|
|
if (ex != null)
|
|
_logger.LogWarning(ex, message);
|
|
else
|
|
_logger.LogWarning(message);
|
|
|
|
IsLightFinderAvailable = false;
|
|
ApplyBroadcastDisabled(forcePublish: true);
|
|
}
|
|
|
|
private void OnDisconnected()
|
|
{
|
|
IsLightFinderAvailable = false;
|
|
ApplyBroadcastDisabled(forcePublish: true);
|
|
_logger.LogDebug("Cleared Lightfinder state due to disconnect.");
|
|
|
|
}
|
|
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
_mediator.Subscribe<EnableBroadcastMessage>(this, OnEnableBroadcast);
|
|
_mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
|
|
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
|
|
_mediator.Subscribe<DisconnectedMessage>(this, _ => OnDisconnected());
|
|
|
|
IsLightFinderAvailable = false;
|
|
|
|
_lightfinderCancelTokens?.Cancel();
|
|
_lightfinderCancelTokens?.Dispose();
|
|
_lightfinderCancelTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
_connectedHandler = () => _ = CheckLightfinderSupportAsync(_lightfinderCancelTokens.Token);
|
|
_apiController.OnConnected += _connectedHandler;
|
|
|
|
if (_apiController.IsConnected)
|
|
_ = CheckLightfinderSupportAsync(_lightfinderCancelTokens.Token);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
_lightfinderCancelTokens?.Cancel();
|
|
_lightfinderCancelTokens?.Dispose();
|
|
_lightfinderCancelTokens = null;
|
|
|
|
if (_connectedHandler is not null)
|
|
{
|
|
_apiController.OnConnected -= _connectedHandler;
|
|
_connectedHandler = null;
|
|
}
|
|
|
|
_mediator.UnsubscribeAll(this);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task CheckLightfinderSupportAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
while (!_apiController.IsConnected && !cancellationToken.IsCancellationRequested)
|
|
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (cancellationToken.IsCancellationRequested)
|
|
return;
|
|
|
|
var hashedCid = await GetLocalHashedCidAsync("Lightfinder state check").ConfigureAwait(false);
|
|
if (string.IsNullOrEmpty(hashedCid))
|
|
return;
|
|
|
|
BroadcastStatusInfoDto? status = null;
|
|
try
|
|
{
|
|
status = await _apiController.IsUserBroadcasting(hashedCid).ConfigureAwait(false);
|
|
}
|
|
catch (HubException ex) when (ex.Message.Contains("Method does not exist", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
HandleLightfinderUnavailable("Lightfinder unavailable on server (required method missing).", ex);
|
|
}
|
|
|
|
if (!IsLightFinderAvailable)
|
|
_logger.LogInformation("Lightfinder is available.");
|
|
|
|
IsLightFinderAvailable = true;
|
|
|
|
bool isBroadcasting = status?.IsBroadcasting == true;
|
|
TimeSpan? ttl = status?.TTL;
|
|
|
|
if (isBroadcasting)
|
|
{
|
|
if (ttl is not { } remaining || remaining <= TimeSpan.Zero)
|
|
ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false);
|
|
|
|
if (TryApplyBroadcastEnabled(ttl, "server handshake"))
|
|
{
|
|
_syncedOnStartup = true;
|
|
}
|
|
else
|
|
{
|
|
isBroadcasting = false;
|
|
}
|
|
}
|
|
|
|
if (!isBroadcasting)
|
|
{
|
|
ApplyBroadcastDisabled(forcePublish: true);
|
|
_logger.LogInformation("Lightfinder is available but no active broadcast was found.");
|
|
}
|
|
|
|
if (_config.Current.LightfinderAutoEnableOnConnect && !isBroadcasting)
|
|
{
|
|
_logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect.");
|
|
_mediator.Publish(new EnableBroadcastMessage(hashedCid, true));
|
|
|
|
_mediator.Publish(new NotificationMessage(
|
|
"Broadcast Auto-Enabled",
|
|
"Your Lightfinder broadcast has been automatically enabled.",
|
|
NotificationType.Info));
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_logger.LogInformation("Lightfinder check was canceled.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
HandleLightfinderUnavailable("Lightfinder check failed.", ex);
|
|
}
|
|
}
|
|
|
|
private void OnEnableBroadcast(EnableBroadcastMessage msg)
|
|
{
|
|
_ = RequireConnectionAsync(nameof(OnEnableBroadcast), async () =>
|
|
{
|
|
try
|
|
{
|
|
GroupBroadcastRequestDto? groupDto = null;
|
|
if (_config.Current.SyncshellFinderEnabled && _config.Current.SelectedFinderSyncshell != null)
|
|
{
|
|
groupDto = new GroupBroadcastRequestDto
|
|
{
|
|
HashedCID = msg.HashedCid,
|
|
GID = _config.Current.SelectedFinderSyncshell,
|
|
Enabled = msg.Enabled,
|
|
};
|
|
}
|
|
|
|
await _apiController.SetBroadcastStatus(msg.Enabled, groupDto).ConfigureAwait(false);
|
|
|
|
_logger.LogDebug("Broadcast {Status} for {Cid}", msg.Enabled ? "enabled" : "disabled", msg.HashedCid);
|
|
|
|
if (!msg.Enabled)
|
|
{
|
|
ApplyBroadcastDisabled(forcePublish: true);
|
|
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}")));
|
|
return;
|
|
}
|
|
|
|
_waitingForTtlFetch = true;
|
|
|
|
try
|
|
{
|
|
TimeSpan? ttl = await GetBroadcastTtlAsync(msg.HashedCid).ConfigureAwait(false);
|
|
|
|
if (TryApplyBroadcastEnabled(ttl, "client request"))
|
|
{
|
|
_logger.LogDebug("Fetched TTL from server: {TTL}", ttl);
|
|
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}")));
|
|
}
|
|
else
|
|
{
|
|
ApplyBroadcastDisabled(forcePublish: true);
|
|
_logger.LogWarning("No valid TTL returned after enabling broadcast. Disabling.");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_waitingForTtlFetch = false;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to toggle broadcast for {Cid}", msg.HashedCid);
|
|
_waitingForTtlFetch = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
|
|
{
|
|
_config.Current.BroadcastEnabled = msg.Enabled;
|
|
_config.Save();
|
|
}
|
|
|
|
public async Task<bool> CheckIfBroadcastingAsync(string targetCid)
|
|
{
|
|
bool result = false;
|
|
await RequireConnectionAsync(nameof(CheckIfBroadcastingAsync), async () =>
|
|
{
|
|
try
|
|
{
|
|
_logger.LogDebug("[BroadcastCheck] Checking CID: {cid}", targetCid);
|
|
|
|
var info = await _apiController.IsUserBroadcasting(targetCid).ConfigureAwait(false);
|
|
result = info?.TTL > TimeSpan.Zero;
|
|
|
|
|
|
_logger.LogDebug("[BroadcastCheck] Result for {cid}: {result} (TTL: {ttl}, GID: {gid})", targetCid, result, info?.TTL, info?.GID);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to check broadcast status for {cid}", targetCid);
|
|
}
|
|
}).ConfigureAwait(false);
|
|
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<TimeSpan?> GetBroadcastTtlAsync(string? cidForLog = null)
|
|
{
|
|
TimeSpan? ttl = null;
|
|
await RequireConnectionAsync(nameof(GetBroadcastTtlAsync), async () => {
|
|
try
|
|
{
|
|
ttl = await _apiController.GetBroadcastTtl().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (cidForLog is { Length: > 0 })
|
|
{
|
|
_logger.LogWarning(ex, "Failed to fetch broadcast TTL for {Cid}", cidForLog);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(ex, "Failed to fetch broadcast TTL");
|
|
}
|
|
}
|
|
}).ConfigureAwait(false);
|
|
return ttl;
|
|
}
|
|
|
|
public async Task<Dictionary<string, BroadcastStatusInfoDto?>> AreUsersBroadcastingAsync(List<string> hashedCids)
|
|
{
|
|
Dictionary<string, BroadcastStatusInfoDto?> result = new();
|
|
|
|
await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () =>
|
|
{
|
|
try
|
|
{
|
|
var batch = await _apiController.AreUsersBroadcasting(hashedCids).ConfigureAwait(false);
|
|
|
|
if (batch?.Results != null)
|
|
{
|
|
foreach (var kv in batch.Results)
|
|
result[kv.Key] = kv.Value;
|
|
}
|
|
|
|
_logger.LogTrace("Batch broadcast status check complete for {Count} CIDs", hashedCids.Count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to batch check broadcast status");
|
|
}
|
|
}).ConfigureAwait(false);
|
|
|
|
return result;
|
|
}
|
|
|
|
|
|
|
|
public async void ToggleBroadcast()
|
|
{
|
|
|
|
if (!IsLightFinderAvailable)
|
|
{
|
|
_logger.LogWarning("ToggleBroadcast - Lightfinder is not available.");
|
|
_mediator.Publish(new NotificationMessage(
|
|
"Broadcast Unavailable",
|
|
"Lightfinder is not available on this server.",
|
|
NotificationType.Error));
|
|
return;
|
|
}
|
|
|
|
await RequireConnectionAsync(nameof(ToggleBroadcast), async () =>
|
|
{
|
|
var cooldown = RemainingCooldown;
|
|
if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero)
|
|
{
|
|
_logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds);
|
|
_mediator.Publish(new NotificationMessage(
|
|
"Broadcast Cooldown",
|
|
$"Please wait {cd.TotalSeconds:F0} seconds before re-enabling broadcast.",
|
|
NotificationType.Warning));
|
|
return;
|
|
}
|
|
|
|
var hashedCid = await GetLocalHashedCidAsync(nameof(ToggleBroadcast)).ConfigureAwait(false);
|
|
if (string.IsNullOrEmpty(hashedCid))
|
|
{
|
|
_logger.LogWarning("ToggleBroadcast - unable to resolve CID.");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var isCurrentlyBroadcasting = await CheckIfBroadcastingAsync(hashedCid).ConfigureAwait(false);
|
|
var newStatus = !isCurrentlyBroadcasting;
|
|
|
|
if (!newStatus)
|
|
{
|
|
_lastForcedDisableTime = DateTime.UtcNow;
|
|
_logger.LogDebug("Manual disable: cooldown timer started.");
|
|
}
|
|
|
|
_logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus);
|
|
|
|
_mediator.Publish(new EnableBroadcastMessage(hashedCid, newStatus));
|
|
|
|
_mediator.Publish(new NotificationMessage(
|
|
newStatus ? "Broadcast Enabled" : "Broadcast Disabled",
|
|
newStatus ? "Your Lightfinder broadcast has been enabled." : "Your Lightfinder broadcast has been disabled.",
|
|
NotificationType.Info));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to determine current broadcast status for toggle");
|
|
_mediator.Publish(new NotificationMessage(
|
|
"Broadcast Toggle Failed",
|
|
$"Failed to toggle broadcast: {ex.Message}",
|
|
NotificationType.Error));
|
|
}
|
|
}).ConfigureAwait(false);
|
|
}
|
|
|
|
private async void OnTick(PriorityFrameworkUpdateMessage _)
|
|
{
|
|
if (!IsLightFinderAvailable)
|
|
return;
|
|
|
|
if (_config?.Current == null)
|
|
return;
|
|
|
|
if ((DateTime.UtcNow - _lastTtlCheck).TotalSeconds < 1)
|
|
return;
|
|
|
|
_lastTtlCheck = DateTime.UtcNow;
|
|
|
|
await RequireConnectionAsync(nameof(OnTick), async () => {
|
|
if (!_syncedOnStartup && _config.Current.BroadcastEnabled)
|
|
{
|
|
try
|
|
{
|
|
var hashedCid = await GetLocalHashedCidAsync("startup TTL refresh").ConfigureAwait(false);
|
|
if (string.IsNullOrEmpty(hashedCid))
|
|
{
|
|
_logger.LogDebug("Skipping TTL refresh; hashed CID unavailable.");
|
|
return;
|
|
}
|
|
|
|
TimeSpan? ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false);
|
|
if (TryApplyBroadcastEnabled(ttl, "startup TTL refresh"))
|
|
{
|
|
_syncedOnStartup = true;
|
|
_logger.LogDebug("Refreshed broadcast TTL from server on first OnTick: {TTL}", ttl);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("No valid TTL found on OnTick. Disabling broadcast state.");
|
|
ApplyBroadcastDisabled(forcePublish: true);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to refresh TTL in OnTick");
|
|
_syncedOnStartup = false;
|
|
}
|
|
}
|
|
if (_config.Current.BroadcastEnabled)
|
|
{
|
|
if (_waitingForTtlFetch)
|
|
{
|
|
_logger.LogDebug("OnTick skipped: waiting for TTL fetch");
|
|
return;
|
|
}
|
|
|
|
DateTime expiry = _config.Current.BroadcastTtl;
|
|
TimeSpan remaining = expiry - DateTime.UtcNow;
|
|
_remainingTtl = remaining > TimeSpan.Zero ? remaining : null;
|
|
if (_remainingTtl == null)
|
|
{
|
|
_logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally.");
|
|
ApplyBroadcastDisabled(forcePublish: true);
|
|
ShowBroadcastExpiredNotification();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_remainingTtl = null;
|
|
}
|
|
}).ConfigureAwait(false);
|
|
}
|
|
|
|
private void ShowBroadcastExpiredNotification()
|
|
{
|
|
var notification = new LightlessNotification
|
|
{
|
|
Id = "broadcast_expired",
|
|
Title = "Broadcast Expired",
|
|
Message = "Your Lightfinder broadcast has expired after 3 hours. Would you like to re-enable it?",
|
|
Type = NotificationType.PairRequest,
|
|
Duration = TimeSpan.FromSeconds(180),
|
|
Actions = new List<LightlessNotificationAction>
|
|
{
|
|
new()
|
|
{
|
|
Id = "re_enable",
|
|
Label = "Re-enable",
|
|
Icon = FontAwesomeIcon.Plus,
|
|
Color = UIColors.Get("PairBlue"),
|
|
IsPrimary = true,
|
|
OnClick = (n) =>
|
|
{
|
|
_logger.LogInformation("Re-enabling broadcast from notification");
|
|
ToggleBroadcast();
|
|
n.IsDismissed = true;
|
|
n.IsAnimatingOut = true;
|
|
}
|
|
},
|
|
new()
|
|
{
|
|
Id = "close",
|
|
Label = "Close",
|
|
Icon = FontAwesomeIcon.Times,
|
|
Color = UIColors.Get("DimRed"),
|
|
OnClick = (n) =>
|
|
{
|
|
_logger.LogInformation("Broadcast expiration notification dismissed");
|
|
n.IsDismissed = true;
|
|
n.IsAnimatingOut = true;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
_mediator.Publish(new LightlessNotificationMessage(notification));
|
|
}
|
|
} |