using System; using LightlessSync.API.Data; using LightlessSync.API.Data.Comparer; using LightlessSync.PlayerData.Pairs; using LightlessSync.Utils; using LightlessSync.Services.Mediator; using LightlessSync.Services; using LightlessSync.WebAPI; using LightlessSync.WebAPI.Files; using Microsoft.Extensions.Logging; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace LightlessSync.PlayerData.Pairs; public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase { private readonly ApiController _apiController; private readonly DalamudUtilService _dalamudUtil; private readonly FileUploadManager _fileTransferManager; private readonly PairLedger _pairLedger; private CharacterData? _lastCreatedData; private CharacterData? _uploadingCharacterData = null; private readonly List _previouslyVisiblePlayers = []; private Task? _fileUploadTask = null; private readonly HashSet _usersToPushDataTo = new(UserDataComparer.Instance); private readonly SemaphoreSlim _pushDataSemaphore = new(1, 1); private readonly CancellationTokenSource _runtimeCts = new(); private readonly Dictionary _lastPushedHashes = new(StringComparer.Ordinal); private readonly object _pushSync = new(); public VisibleUserDataDistributor(ILogger logger, ApiController apiController, DalamudUtilService dalamudUtil, PairLedger pairLedger, LightlessMediator mediator, FileUploadManager fileTransferManager) : base(logger, mediator) { _apiController = apiController; _dalamudUtil = dalamudUtil; _pairLedger = pairLedger; _fileTransferManager = fileTransferManager; Mediator.Subscribe(this, (_) => FrameworkOnUpdate()); Mediator.Subscribe(this, (msg) => { var newData = msg.CharacterData; if (_lastCreatedData == null || (!string.Equals(newData.DataHash.Value, _lastCreatedData.DataHash.Value, StringComparison.Ordinal))) { _lastCreatedData = newData; Logger.LogTrace("Storing new data hash {hash}", newData.DataHash.Value); PushToAllVisibleUsers(forced: true); } else { Logger.LogTrace("Data hash {hash} equal to stored data", newData.DataHash.Value); } }); Mediator.Subscribe(this, (_) => PushToAllVisibleUsers()); Mediator.Subscribe(this, (_) => HandleDisconnected()); } protected override void Dispose(bool disposing) { if (disposing) { _runtimeCts.Cancel(); _runtimeCts.Dispose(); } base.Dispose(disposing); } private void PushToAllVisibleUsers(bool forced = false) { lock (_pushSync) { foreach (var user in GetVisibleUsers()) { _usersToPushDataTo.Add(user); } if (_usersToPushDataTo.Count > 0) { Logger.LogDebug("Pushing data {hash} for {count} visible players", _lastCreatedData?.DataHash.Value ?? "UNKNOWN", _usersToPushDataTo.Count); PushCharacterData_internalLocked(forced); } } } private void FrameworkOnUpdate() { if (!_dalamudUtil.GetIsPlayerPresent() || !_apiController.IsConnected) return; var allVisibleUsers = GetVisibleUsers(); var newVisibleUsers = allVisibleUsers .Except(_previouslyVisiblePlayers, UserDataComparer.Instance) .ToList(); _previouslyVisiblePlayers.Clear(); _previouslyVisiblePlayers.AddRange(allVisibleUsers); if (newVisibleUsers.Count == 0) return; Logger.LogDebug("Scheduling character data push of {data} to {users}", _lastCreatedData?.DataHash.Value ?? string.Empty, string.Join(", ", newVisibleUsers.Select(k => k.AliasOrUID))); lock (_pushSync) { foreach (var user in newVisibleUsers) { _usersToPushDataTo.Add(user); } PushCharacterData_internalLocked(); } } private void PushCharacterData(bool forced = false) { lock (_pushSync) { PushCharacterData_internalLocked(forced); } } private void PushCharacterData_internalLocked(bool forced = false) { if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return; if (!_apiController.IsConnected || !_fileTransferManager.IsReady) { Logger.LogTrace("Skipping character push. Connected: {connected}, UploadManagerReady: {ready}", _apiController.IsConnected, _fileTransferManager.IsReady); return; } _ = Task.Run(async () => { try { Task? uploadTask; bool forcedPush; lock (_pushSync) { if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return; forcedPush = forced | (_uploadingCharacterData?.DataHash != _lastCreatedData.DataHash); if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forcedPush) { _uploadingCharacterData = _lastCreatedData.DeepClone(); Logger.LogDebug("Starting UploadTask for {hash}, Reason: TaskIsNull: {task}, TaskIsCompleted: {taskCpl}, Forced: {frc}", _lastCreatedData.DataHash, _fileUploadTask == null, _fileUploadTask?.IsCompleted ?? false, forcedPush); _fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, [.. _usersToPushDataTo]); } uploadTask = _fileUploadTask; } var dataToSend = await uploadTask.ConfigureAwait(false); var dataHash = dataToSend.DataHash.Value; await _pushDataSemaphore.WaitAsync(_runtimeCts.Token).ConfigureAwait(false); try { List recipients; bool shouldSkip = false; lock (_pushSync) { if (_usersToPushDataTo.Count == 0) return; recipients = forcedPush ? _usersToPushDataTo.ToList() : _usersToPushDataTo .Where(user => !_lastPushedHashes.TryGetValue(user.UID, out var sentHash) || !string.Equals(sentHash, dataHash, StringComparison.Ordinal)) .ToList(); if (recipients.Count == 0 && !forcedPush) { Logger.LogTrace("All recipients already have character data hash {hash}, skipping push.", dataHash); _usersToPushDataTo.Clear(); shouldSkip = true; } } if (shouldSkip) return; Logger.LogDebug("Pushing {data} to {users}", dataHash, string.Join(", ", recipients.Select(k => k.AliasOrUID))); await _apiController.PushCharacterData(dataToSend, recipients).ConfigureAwait(false); lock (_pushSync) { foreach (var user in recipients) { _lastPushedHashes[user.UID] = dataHash; _usersToPushDataTo.Remove(user); } if (!forcedPush && _usersToPushDataTo.Count > 0) { foreach (var satisfied in _usersToPushDataTo .Where(user => _lastPushedHashes.TryGetValue(user.UID, out var sentHash) && string.Equals(sentHash, dataHash, StringComparison.Ordinal)) .ToList()) { _usersToPushDataTo.Remove(satisfied); } } if (forcedPush) { _usersToPushDataTo.Clear(); } } } finally { _pushDataSemaphore.Release(); } } catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested) { Logger.LogDebug("PushCharacterData cancelled"); } catch (Exception ex) { Logger.LogError(ex, "Failed to push character data"); } }); } private void HandleDisconnected() { _fileTransferManager.CancelUpload(); _previouslyVisiblePlayers.Clear(); lock (_pushSync) { _usersToPushDataTo.Clear(); _lastPushedHashes.Clear(); _uploadingCharacterData = null; _fileUploadTask = null; } } private List GetVisibleUsers() { return _pairLedger.GetVisiblePairs() .Select(connection => connection.User) .ToList(); } }