1.12.1 #43

Merged
defnotken merged 32 commits from 1.12.1 into master 2025-10-08 17:57:23 +00:00
30 changed files with 1538 additions and 225 deletions

View File

@@ -41,9 +41,9 @@ jobs:
- name: Get version - name: Get version
id: package_version id: package_version
uses: KageKirin/get-csproj-version@v0 run: |
with: version=$(grep -oPm1 "(?<=<Version>)[^<]+" LightlessSync/LightlessSync.csproj)
file: LightlessSync/LightlessSync.csproj echo "version=$version" >> $GITHUB_OUTPUT
- name: Display version - name: Display version
run: | run: |
@@ -153,8 +153,11 @@ jobs:
}' \ }' \
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases" "https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases"
) )
echo "API response: $response"
release_id=$(echo "$response" | jq -r .id) release_id=$(echo "$response" | jq -r .id)
echo "release_id=$release_id" >> "$GITHUB_OUTPUT" echo "release_id=$release_id"
echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id"
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
- name: Check asset exists - name: Check asset exists
run: | run: |

View File

@@ -1,4 +1,4 @@
using K4os.Compression.LZ4.Legacy; using K4os.Compression.LZ4.Legacy;
using LightlessSync.Interop.Ipc; using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
@@ -19,7 +19,8 @@ public sealed class FileCacheManager : IHostedService
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly string _csvPath; private readonly string _csvPath;
private readonly ConcurrentDictionary<string, List<FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1); private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
private readonly Lock _fileWriteLock = new(); private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager; private readonly IpcManager _ipcManager;
@@ -37,6 +38,57 @@ public sealed class FileCacheManager : IHostedService
private string CsvBakPath => _csvPath + ".bak"; private string CsvBakPath => _csvPath + ".bak";
private static string NormalizeSeparators(string path)
{
return path.Replace("/", "\\", StringComparison.Ordinal)
.Replace("\\\\", "\\", StringComparison.Ordinal);
}
private static string NormalizePrefixedPathKey(string prefixedPath)
{
if (string.IsNullOrEmpty(prefixedPath))
{
return string.Empty;
}
return NormalizeSeparators(prefixedPath).ToLowerInvariant();
}
private string NormalizeToPrefixedPath(string path)
{
if (string.IsNullOrEmpty(path)) return string.Empty;
var normalized = NormalizeSeparators(path);
if (normalized.StartsWith(CachePrefix, StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith(PenumbraPrefix, StringComparison.OrdinalIgnoreCase))
{
return NormalizePrefixedPathKey(normalized);
}
var penumbraDir = _ipcManager.Penumbra.ModDirectory;
if (!string.IsNullOrEmpty(penumbraDir))
{
var normalizedPenumbra = NormalizeSeparators(penumbraDir);
var replacement = normalizedPenumbra.EndsWith("\\", StringComparison.Ordinal)
? PenumbraPrefix + "\\"
: PenumbraPrefix;
normalized = normalized.Replace(normalizedPenumbra, replacement, StringComparison.OrdinalIgnoreCase);
}
var cacheFolder = _configService.Current.CacheFolder;
if (!string.IsNullOrEmpty(cacheFolder))
{
var normalizedCache = NormalizeSeparators(cacheFolder);
var replacement = normalizedCache.EndsWith("\\", StringComparison.Ordinal)
? CachePrefix + "\\"
: CachePrefix;
normalized = normalized.Replace(normalizedCache, replacement, StringComparison.OrdinalIgnoreCase);
}
return NormalizePrefixedPathKey(normalized);
}
public FileCacheEntity? CreateCacheEntry(string path) public FileCacheEntity? CreateCacheEntry(string path)
{ {
FileInfo fi = new(path); FileInfo fi = new(path);
@@ -61,20 +113,26 @@ public sealed class FileCacheManager : IHostedService
return CreateFileCacheEntity(fi, prefixedPath); return CreateFileCacheEntity(fi, prefixedPath);
} }
public List<FileCacheEntity> GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v).ToList(); public List<FileCacheEntity> GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).ToList();
public List<FileCacheEntity> GetAllFileCachesByHash(string hash, bool ignoreCacheEntries = false, bool validate = true) public List<FileCacheEntity> GetAllFileCachesByHash(string hash, bool ignoreCacheEntries = false, bool validate = true)
{ {
List<FileCacheEntity> output = []; List<FileCacheEntity> output = [];
if (_fileCaches.TryGetValue(hash, out var fileCacheEntities)) if (_fileCaches.TryGetValue(hash, out var fileCacheEntities))
{ {
foreach (var fileCache in fileCacheEntities.Where(c => !ignoreCacheEntries || !c.IsCacheEntry).ToList()) foreach (var fileCache in fileCacheEntities.Values.Where(c => !ignoreCacheEntries || !c.IsCacheEntry).ToList())
{ {
if (!validate) output.Add(fileCache); if (!validate)
{
output.Add(fileCache);
}
else else
{ {
var validated = GetValidatedFileCache(fileCache); var validated = GetValidatedFileCache(fileCache);
if (validated != null) output.Add(validated); if (validated != null)
{
output.Add(validated);
}
} }
} }
} }
@@ -86,7 +144,7 @@ public sealed class FileCacheManager : IHostedService
{ {
_lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity))); _lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity)));
_logger.LogInformation("Validating local storage"); _logger.LogInformation("Validating local storage");
var cacheEntries = _fileCaches.SelectMany(v => v.Value).Where(v => v.IsCacheEntry).ToList(); var cacheEntries = _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).Where(v => v.IsCacheEntry).ToList();
List<FileCacheEntity> brokenEntities = []; List<FileCacheEntity> brokenEntities = [];
int i = 0; int i = 0;
foreach (var fileCache in cacheEntries) foreach (var fileCache in cacheEntries)
@@ -151,29 +209,40 @@ public sealed class FileCacheManager : IHostedService
public FileCacheEntity? GetFileCacheByHash(string hash) public FileCacheEntity? GetFileCacheByHash(string hash)
{ {
if (_fileCaches.TryGetValue(hash, out var hashes)) if (_fileCaches.TryGetValue(hash, out var entries))
{ {
var item = hashes.OrderBy(p => p.PrefixedFilePath.Contains(PenumbraPrefix, StringComparison.Ordinal) ? 0 : 1).FirstOrDefault(); var item = entries.Values
if (item != null) return GetValidatedFileCache(item); .OrderBy(p => p.PrefixedFilePath.Contains(PenumbraPrefix, StringComparison.Ordinal) ? 0 : 1)
.FirstOrDefault();
if (item != null)
{
return GetValidatedFileCache(item);
}
} }
return null; return null;
} }
private FileCacheEntity? GetFileCacheByPath(string path) private FileCacheEntity? GetFileCacheByPath(string path)
{ {
var cleanedPath = path.Replace("/", "\\", StringComparison.OrdinalIgnoreCase).ToLowerInvariant() var normalizedPrefixedPath = NormalizeToPrefixedPath(path);
.Replace(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), "", StringComparison.OrdinalIgnoreCase); if (string.IsNullOrEmpty(normalizedPrefixedPath))
var entry = _fileCaches.SelectMany(v => v.Value).FirstOrDefault(f => f.ResolvedFilepath.EndsWith(cleanedPath, StringComparison.OrdinalIgnoreCase));
if (entry == null)
{ {
_logger.LogDebug("Found no entries for {path}", cleanedPath); return null;
return CreateFileEntry(path);
} }
var validatedCacheEntry = GetValidatedFileCache(entry); if (_fileCachesByPrefixedPath.TryGetValue(normalizedPrefixedPath, out var entry))
{
return GetValidatedFileCache(entry);
}
return validatedCacheEntry; _logger.LogDebug("Found no entries for {path}", normalizedPrefixedPath);
if (normalizedPrefixedPath.Contains(CachePrefix, StringComparison.Ordinal))
{
return CreateCacheEntry(path);
}
return CreateFileEntry(path) ?? CreateCacheEntry(path);
} }
public Dictionary<string, FileCacheEntity?> GetFileCachesByPaths(string[] paths) public Dictionary<string, FileCacheEntity?> GetFileCachesByPaths(string[] paths)
@@ -182,73 +251,55 @@ public sealed class FileCacheManager : IHostedService
try try
{ {
var allEntities = _fileCaches.SelectMany(f => f.Value).ToArray(); var result = new Dictionary<string, FileCacheEntity?>(StringComparer.OrdinalIgnoreCase);
var seenNormalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var cacheDict = new ConcurrentDictionary<string, FileCacheEntity>( foreach (var originalPath in paths)
StringComparer.OrdinalIgnoreCase); {
if (string.IsNullOrEmpty(originalPath))
{
result[originalPath] = null;
continue;
}
Parallel.ForEach(allEntities, entity => var normalized = NormalizeToPrefixedPath(originalPath);
if (seenNormalized.Add(normalized))
{ {
if (entity != null && entity.PrefixedFilePath != null) if (!string.IsNullOrEmpty(normalized))
{ {
cacheDict[entity.PrefixedFilePath] = entity; _logger.LogDebug("Normalized path {cleaned}", normalized);
}
}
else if (!string.IsNullOrEmpty(normalized))
{
_logger.LogWarning("Duplicate normalized path detected: {cleaned}", normalized);
}
if (_fileCachesByPrefixedPath.TryGetValue(normalized, out var entity))
{
result[originalPath] = GetValidatedFileCache(entity);
continue;
}
FileCacheEntity? created = null;
if (normalized.Contains(CachePrefix, StringComparison.Ordinal))
{
created = CreateCacheEntry(originalPath);
}
else if (normalized.Contains(PenumbraPrefix, StringComparison.Ordinal))
{
created = CreateFileEntry(originalPath);
} }
else else
{ {
_logger.LogWarning("Null FileCacheEntity or PrefixedFilePath encountered in cache population: {entity}", entity); created = CreateFileEntry(originalPath) ?? CreateCacheEntry(originalPath);
} }
});
var cleanedPaths = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase); result[originalPath] = created;
var seenCleaned = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
Parallel.ForEach(paths, p =>
{
var cleaned = p.Replace("/", "\\", StringComparison.OrdinalIgnoreCase)
.Replace(
_ipcManager.Penumbra.ModDirectory!,
_ipcManager.Penumbra.ModDirectory!.EndsWith('\\')
? PenumbraPrefix + '\\' : PenumbraPrefix,
StringComparison.OrdinalIgnoreCase)
.Replace(
_configService.Current.CacheFolder,
_configService.Current.CacheFolder.EndsWith('\\')
? CachePrefix + '\\' : CachePrefix,
StringComparison.OrdinalIgnoreCase)
.Replace("\\\\", "\\", StringComparison.Ordinal);
if (seenCleaned.TryAdd(cleaned, 0))
{
_logger.LogDebug("Adding to cleanedPaths: {cleaned}", cleaned);
cleanedPaths[p] = cleaned;
} }
else
{
_logger.LogWarning("Duplicate found: {cleaned}", cleaned);
}
});
var result = new ConcurrentDictionary<string, FileCacheEntity?>(StringComparer.OrdinalIgnoreCase); return result;
Parallel.ForEach(cleanedPaths, entry =>
{
_logger.LogDebug("Checking if in cache: {path}", entry.Value);
if (cacheDict.TryGetValue(entry.Value, out var entity))
{
var validatedCache = GetValidatedFileCache(entity);
result[entry.Key] = validatedCache;
}
else
{
if (!entry.Value.Contains(CachePrefix, StringComparison.Ordinal))
result[entry.Key] = CreateFileEntry(entry.Key);
else
result[entry.Key] = CreateCacheEntry(entry.Key);
}
});
return new Dictionary<string, FileCacheEntity?>(result, StringComparer.OrdinalIgnoreCase);
} }
finally finally
{ {
@@ -258,17 +309,24 @@ public sealed class FileCacheManager : IHostedService
public void RemoveHashedFile(string hash, string prefixedFilePath) public void RemoveHashedFile(string hash, string prefixedFilePath)
{ {
var normalizedPath = NormalizePrefixedPathKey(prefixedFilePath);
if (_fileCaches.TryGetValue(hash, out var caches)) if (_fileCaches.TryGetValue(hash, out var caches))
{ {
_logger.LogTrace("Removing from DB: {hash} => {path}", hash, prefixedFilePath); _logger.LogTrace("Removing from DB: {hash} => {path}", hash, prefixedFilePath);
var removedCount = caches?.RemoveAll(c => string.Equals(c.PrefixedFilePath, prefixedFilePath, StringComparison.Ordinal));
_logger.LogTrace("Removed from DB: {count} file(s) with hash {hash} and file cache {path}", removedCount, hash, prefixedFilePath);
if (caches?.Count == 0) if (caches.TryRemove(normalizedPath, out var removedEntity))
{ {
_fileCaches.Remove(hash, out var entity); _logger.LogTrace("Removed from DB: {hash} => {path}", hash, removedEntity.PrefixedFilePath);
}
if (caches.IsEmpty)
{
_fileCaches.TryRemove(hash, out _);
} }
} }
_fileCachesByPrefixedPath.TryRemove(normalizedPath, out _);
} }
public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true) public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true)
@@ -309,7 +367,7 @@ public sealed class FileCacheManager : IHostedService
lock (_fileWriteLock) lock (_fileWriteLock)
{ {
StringBuilder sb = new(); StringBuilder sb = new();
foreach (var entry in _fileCaches.SelectMany(k => k.Value).OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase)) foreach (var entry in _fileCaches.Values.SelectMany(k => k.Values).OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase))
{ {
sb.AppendLine(entry.CsvEntry); sb.AppendLine(entry.CsvEntry);
} }
@@ -354,16 +412,11 @@ public sealed class FileCacheManager : IHostedService
private void AddHashedFile(FileCacheEntity fileCache) private void AddHashedFile(FileCacheEntity fileCache)
{ {
if (!_fileCaches.TryGetValue(fileCache.Hash, out var entries) || entries is null) var normalizedPath = NormalizePrefixedPathKey(fileCache.PrefixedFilePath);
{ var entries = _fileCaches.GetOrAdd(fileCache.Hash, _ => new ConcurrentDictionary<string, FileCacheEntity>(StringComparer.OrdinalIgnoreCase));
_fileCaches[fileCache.Hash] = entries = [];
}
if (!entries.Exists(u => string.Equals(u.PrefixedFilePath, fileCache.PrefixedFilePath, StringComparison.OrdinalIgnoreCase))) entries[normalizedPath] = fileCache;
{ _fileCachesByPrefixedPath[normalizedPath] = fileCache;
//_logger.LogTrace("Adding to DB: {hash} => {path}", fileCache.Hash, fileCache.PrefixedFilePath);
entries.Add(fileCache);
}
} }
private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null) private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null)

View File

@@ -1,3 +1,5 @@
using Dalamud.Game.Text;
using LightlessSync.UtilsEnum.Enum;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI; using LightlessSync.UI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -33,6 +35,9 @@ public class LightlessConfig : ILightlessConfiguration
public bool OpenGposeImportOnGposeStart { get; set; } = false; public bool OpenGposeImportOnGposeStart { get; set; } = false;
public bool OpenPopupOnAdd { get; set; } = true; public bool OpenPopupOnAdd { get; set; } = true;
public int ParallelDownloads { get; set; } = 10; public int ParallelDownloads { get; set; } = 10;
public int ParallelUploads { get; set; } = 8;
public bool EnablePairProcessingLimiter { get; set; } = true;
public int MaxConcurrentPairApplications { get; set; } = 3;
public int DownloadSpeedLimitInBytes { get; set; } = 0; public int DownloadSpeedLimitInBytes { get; set; } = 0;
public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps; public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps;
public bool PreferNotesOverNamesForVisible { get; set; } = false; public bool PreferNotesOverNamesForVisible { get; set; } = false;
@@ -70,6 +75,15 @@ public class LightlessConfig : ILightlessConfiguration
public bool overrideFcTagColor { get; set; } = false; public bool overrideFcTagColor { get; set; } = false;
public bool useColoredUIDs { get; set; } = true; public bool useColoredUIDs { get; set; } = true;
public bool BroadcastEnabled { get; set; } = false; public bool BroadcastEnabled { get; set; } = false;
public short LightfinderLabelOffsetX { get; set; } = 0;
public short LightfinderLabelOffsetY { get; set; } = 0;
public bool LightfinderLabelUseIcon { get; set; } = false;
public bool LightfinderLabelShowOwn { get; set; } = true;
public bool LightfinderLabelShowPaired { get; set; } = true;
public string LightfinderLabelIconGlyph { get; set; } = SeIconCharExtensions.ToIconString(SeIconChar.Hyadelyn);
public float LightfinderLabelScale { get; set; } = 1.0f;
public bool LightfinderAutoAlign { get; set; } = true;
public LabelAlignment LabelAlignment { get; set; } = LabelAlignment.Left;
public DateTime BroadcastTtl { get; set; } = DateTime.MinValue; public DateTime BroadcastTtl { get; set; } = DateTime.MinValue;
public bool SyncshellFinderEnabled { get; set; } = false; public bool SyncshellFinderEnabled { get; set; } = false;
public string? SelectedFinderSyncshell { get; set; } = null; public string? SelectedFinderSyncshell { get; set; } = null;

View File

@@ -0,0 +1,9 @@
using LightlessSync.LightlessConfiguration.Models;
namespace LightlessSync.LightlessConfiguration.Configurations;
public class ServerTagConfig : ILightlessConfiguration
{
public Dictionary<string, ServerTagStorage> ServerTagStorage { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int Version { get; set; } = 0;
}

View File

@@ -0,0 +1,9 @@
namespace LightlessSync.LightlessConfiguration.Models;
[Serializable]
public class ServerTagStorage
{
public HashSet<string> OpenPairTags { get; set; } = new(StringComparer.Ordinal);
public HashSet<string> ServerAvailablePairTags { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, List<string>> UidServerPairedUserTags { get; set; } = new(StringComparer.Ordinal);
}

View File

@@ -0,0 +1,14 @@
using LightlessSync.LightlessConfiguration.Configurations;
namespace LightlessSync.LightlessConfiguration;
public class ServerTagConfigService : ConfigurationServiceBase<ServerTagConfig>
{
public const string ConfigName = "servertags.json";
public ServerTagConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<Authors></Authors> <Authors></Authors>
<Company></Company> <Company></Company>
<Version>1.12.0</Version> <Version>1.12.1</Version>
<Description></Description> <Description></Description>
<Copyright></Copyright> <Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl> <PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>

View File

@@ -1,4 +1,4 @@
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc; using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
@@ -21,6 +21,7 @@ public class PairHandlerFactory
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly PlayerPerformanceService _playerPerformanceService; private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager; private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager; private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
@@ -28,6 +29,7 @@ public class PairHandlerFactory
FileDownloadManagerFactory fileDownloadManagerFactory, DalamudUtilService dalamudUtilService, FileDownloadManagerFactory fileDownloadManagerFactory, DalamudUtilService dalamudUtilService,
PluginWarningNotificationService pluginWarningNotificationManager, IHostApplicationLifetime hostApplicationLifetime, PluginWarningNotificationService pluginWarningNotificationManager, IHostApplicationLifetime hostApplicationLifetime,
FileCacheManager fileCacheManager, LightlessMediator lightlessMediator, PlayerPerformanceService playerPerformanceService, FileCacheManager fileCacheManager, LightlessMediator lightlessMediator, PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager) ServerConfigurationManager serverConfigManager)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
@@ -40,6 +42,7 @@ public class PairHandlerFactory
_fileCacheManager = fileCacheManager; _fileCacheManager = fileCacheManager;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
_playerPerformanceService = playerPerformanceService; _playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager; _serverConfigManager = serverConfigManager;
} }
@@ -47,6 +50,6 @@ public class PairHandlerFactory
{ {
return new PairHandler(_loggerFactory.CreateLogger<PairHandler>(), pair, _gameObjectHandlerFactory, return new PairHandler(_loggerFactory.CreateLogger<PairHandler>(), pair, _gameObjectHandlerFactory,
_ipcManager, _fileDownloadManagerFactory.Create(), _pluginWarningNotificationManager, _dalamudUtilService, _hostApplicationLifetime, _ipcManager, _fileDownloadManagerFactory.Create(), _pluginWarningNotificationManager, _dalamudUtilService, _hostApplicationLifetime,
_fileCacheManager, _lightlessMediator, _playerPerformanceService, _serverConfigManager); _fileCacheManager, _lightlessMediator, _playerPerformanceService, _pairProcessingLimiter, _serverConfigManager);
} }
} }

View File

@@ -1,4 +1,4 @@
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc; using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Factories;
@@ -28,6 +28,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
private readonly IpcManager _ipcManager; private readonly IpcManager _ipcManager;
private readonly IHostApplicationLifetime _lifetime; private readonly IHostApplicationLifetime _lifetime;
private readonly PlayerPerformanceService _playerPerformanceService; private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager; private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager; private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
private CancellationTokenSource? _applicationCancellationTokenSource = new(); private CancellationTokenSource? _applicationCancellationTokenSource = new();
@@ -50,6 +51,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
DalamudUtilService dalamudUtil, IHostApplicationLifetime lifetime, DalamudUtilService dalamudUtil, IHostApplicationLifetime lifetime,
FileCacheManager fileDbManager, LightlessMediator mediator, FileCacheManager fileDbManager, LightlessMediator mediator,
PlayerPerformanceService playerPerformanceService, PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager) : base(logger, mediator) ServerConfigurationManager serverConfigManager) : base(logger, mediator)
{ {
Pair = pair; Pair = pair;
@@ -61,6 +63,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
_lifetime = lifetime; _lifetime = lifetime;
_fileDbManager = fileDbManager; _fileDbManager = fileDbManager;
_playerPerformanceService = playerPerformanceService; _playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager; _serverConfigManager = serverConfigManager;
_penumbraCollection = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(logger, Pair.UserData.UID).ConfigureAwait(false).GetAwaiter().GetResult(); _penumbraCollection = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(logger, Pair.UserData.UID).ConfigureAwait(false).GetAwaiter().GetResult();
@@ -420,6 +423,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
bool updateModdedPaths, bool updateManip, CancellationToken downloadToken) bool updateModdedPaths, bool updateManip, CancellationToken downloadToken)
{ {
await using var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false);
Dictionary<(string GamePath, string? Hash), string> moddedPaths = []; Dictionary<(string GamePath, string? Hash), string> moddedPaths = [];
if (updateModdedPaths) if (updateModdedPaths)
@@ -737,6 +741,11 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase
} }
} }
} }
catch (OperationCanceledException)
{
Logger.LogTrace("[BASE-{appBase}] Modded path calculation cancelled", applicationBase);
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase); Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase);

View File

@@ -1,4 +1,4 @@
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Data.Comparer; using LightlessSync.API.Data.Comparer;
using LightlessSync.API.Data.Extensions; using LightlessSync.API.Data.Extensions;
@@ -7,10 +7,14 @@ using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Factories;
using LightlessSync.Services;
using LightlessSync.Services.Events; using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace LightlessSync.PlayerData.Pairs; namespace LightlessSync.PlayerData.Pairs;
@@ -24,14 +28,19 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
private Lazy<List<Pair>> _directPairsInternal; private Lazy<List<Pair>> _directPairsInternal;
private Lazy<Dictionary<GroupFullInfoDto, List<Pair>>> _groupPairsInternal; private Lazy<Dictionary<GroupFullInfoDto, List<Pair>>> _groupPairsInternal;
private Lazy<Dictionary<Pair, List<GroupFullInfoDto>>> _pairsWithGroupsInternal; private Lazy<Dictionary<Pair, List<GroupFullInfoDto>>> _pairsWithGroupsInternal;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ConcurrentQueue<(Pair Pair, OnlineUserIdentDto? Ident)> _pairCreationQueue = new();
private CancellationTokenSource _pairCreationCts = new();
private int _pairCreationProcessorRunning;
public PairManager(ILogger<PairManager> logger, PairFactory pairFactory, public PairManager(ILogger<PairManager> logger, PairFactory pairFactory,
LightlessConfigService configurationService, LightlessMediator mediator, LightlessConfigService configurationService, LightlessMediator mediator,
IContextMenu dalamudContextMenu) : base(logger, mediator) IContextMenu dalamudContextMenu, PairProcessingLimiter pairProcessingLimiter) : base(logger, mediator)
{ {
_pairFactory = pairFactory; _pairFactory = pairFactory;
_configurationService = configurationService; _configurationService = configurationService;
_dalamudContextMenu = dalamudContextMenu; _dalamudContextMenu = dalamudContextMenu;
_pairProcessingLimiter = pairProcessingLimiter;
Mediator.Subscribe<DisconnectedMessage>(this, (_) => ClearPairs()); Mediator.Subscribe<DisconnectedMessage>(this, (_) => ClearPairs());
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => ReapplyPairData()); Mediator.Subscribe<CutsceneEndMessage>(this, (_) => ReapplyPairData());
_directPairsInternal = DirectPairsLazy(); _directPairsInternal = DirectPairsLazy();
@@ -112,6 +121,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
public void ClearPairs() public void ClearPairs()
{ {
Logger.LogDebug("Clearing all Pairs"); Logger.LogDebug("Clearing all Pairs");
ResetPairCreationQueue();
DisposePairs(); DisposePairs();
_allClientPairs.Clear(); _allClientPairs.Clear();
_allGroups.Clear(); _allGroups.Clear();
@@ -161,7 +171,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
Mediator.Publish(new NotificationMessage("User online", msg, NotificationType.Info, TimeSpan.FromSeconds(5))); Mediator.Publish(new NotificationMessage("User online", msg, NotificationType.Info, TimeSpan.FromSeconds(5)));
} }
pair.CreateCachedPlayer(dto); QueuePairCreation(pair, dto);
RecreateLazy(); RecreateLazy();
} }
@@ -332,6 +342,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
{ {
base.Dispose(disposing); base.Dispose(disposing);
ResetPairCreationQueue();
_dalamudContextMenu.OnMenuOpened -= DalamudContextMenuOnOnOpenGameObjectContextMenu; _dalamudContextMenu.OnMenuOpened -= DalamudContextMenuOnOnOpenGameObjectContextMenu;
DisposePairs(); DisposePairs();
@@ -390,6 +401,84 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
}); });
} }
private void QueuePairCreation(Pair pair, OnlineUserIdentDto? dto)
{
if (pair.HasCachedPlayer)
{
RecreateLazy();
return;
}
_pairCreationQueue.Enqueue((pair, dto));
StartPairCreationProcessor();
}
private void StartPairCreationProcessor()
{
if (_pairCreationCts.IsCancellationRequested)
{
return;
}
if (Interlocked.CompareExchange(ref _pairCreationProcessorRunning, 1, 0) == 0)
{
_ = Task.Run(ProcessPairCreationQueueAsync);
}
}
private async Task ProcessPairCreationQueueAsync()
{
try
{
while (!_pairCreationCts.IsCancellationRequested)
{
if (!_pairCreationQueue.TryDequeue(out var work))
{
break;
}
try
{
await using var lease = await _pairProcessingLimiter.AcquireAsync(_pairCreationCts.Token).ConfigureAwait(false);
if (!work.Pair.HasCachedPlayer)
{
work.Pair.CreateCachedPlayer(work.Ident);
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.LogError(ex, "Error creating cached player for {uid}", work.Pair.UserData.UID);
}
RecreateLazy();
await Task.Yield();
}
}
finally
{
Interlocked.Exchange(ref _pairCreationProcessorRunning, 0);
if (!_pairCreationQueue.IsEmpty && !_pairCreationCts.IsCancellationRequested)
{
StartPairCreationProcessor();
}
}
}
private void ResetPairCreationQueue()
{
_pairCreationCts.Cancel();
while (_pairCreationQueue.TryDequeue(out _))
{
}
_pairCreationCts.Dispose();
_pairCreationCts = new CancellationTokenSource();
Interlocked.Exchange(ref _pairCreationProcessorRunning, 0);
}
private void ReapplyPairData() private void ReapplyPairData()
{ {
foreach (var pair in _allClientPairs.Select(k => k.Value)) foreach (var pair in _allClientPairs.Select(k => k.Value))

View File

@@ -1,4 +1,4 @@
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Utils; using LightlessSync.Utils;
@@ -100,6 +100,8 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return; if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return;
_ = Task.Run(async () => _ = Task.Run(async () =>
{
try
{ {
forced |= _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash; forced |= _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash;
@@ -127,6 +129,15 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
_pushDataSemaphore.Release(); _pushDataSemaphore.Release();
} }
} }
}
catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested)
{
Logger.LogDebug("PushCharacterData cancelled");
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to push character data");
}
}); });
} }
} }

View File

@@ -106,6 +106,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<GameObjectHandlerFactory>(); collection.AddSingleton<GameObjectHandlerFactory>();
collection.AddSingleton<FileDownloadManagerFactory>(); collection.AddSingleton<FileDownloadManagerFactory>();
collection.AddSingleton<PairHandlerFactory>(); collection.AddSingleton<PairHandlerFactory>();
collection.AddSingleton<PairProcessingLimiter>();
collection.AddSingleton<PairFactory>(); collection.AddSingleton<PairFactory>();
collection.AddSingleton<XivDataAnalyzer>(); collection.AddSingleton<XivDataAnalyzer>();
collection.AddSingleton<CharacterAnalyzer>(); collection.AddSingleton<CharacterAnalyzer>();
@@ -144,7 +145,7 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton((s) => new DtrEntry(s.GetRequiredService<ILogger<DtrEntry>>(), dtrBar, s.GetRequiredService<LightlessConfigService>(), collection.AddSingleton((s) => new DtrEntry(s.GetRequiredService<ILogger<DtrEntry>>(), dtrBar, s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<ServerConfigurationManager>())); s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<ServerConfigurationManager>()));
collection.AddSingleton(s => new PairManager(s.GetRequiredService<ILogger<PairManager>>(), s.GetRequiredService<PairFactory>(), collection.AddSingleton(s => new PairManager(s.GetRequiredService<ILogger<PairManager>>(), s.GetRequiredService<PairFactory>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), contextMenu)); s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), contextMenu, s.GetRequiredService<PairProcessingLimiter>()));
collection.AddSingleton<RedrawManager>(); collection.AddSingleton<RedrawManager>();
collection.AddSingleton<BroadcastService>(); collection.AddSingleton<BroadcastService>();
collection.AddSingleton(addonLifecycle); collection.AddSingleton(addonLifecycle);
@@ -207,7 +208,6 @@ public sealed class Plugin : IDalamudPlugin
collection.AddSingleton<ConfigurationMigrator>(); collection.AddSingleton<ConfigurationMigrator>();
collection.AddSingleton<ConfigurationSaveService>(); collection.AddSingleton<ConfigurationSaveService>();
collection.AddSingleton<HubFactory>(); collection.AddSingleton<HubFactory>();
collection.AddSingleton<NameplateHandler>();
collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService<ILogger<BroadcastScannerService>>(), clientState, objectTable, framework, s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<NameplateHandler>(), s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessConfigService>())); collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService<ILogger<BroadcastScannerService>>(), clientState, objectTable, framework, s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<NameplateHandler>(), s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessConfigService>()));
@@ -252,6 +252,8 @@ public sealed class Plugin : IDalamudPlugin
s.GetRequiredService<LightlessMediator>())); s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new NameplateService(s.GetRequiredService<ILogger<NameplateService>>(), s.GetRequiredService<LightlessConfigService>(), namePlateGui, clientState, collection.AddScoped((s) => new NameplateService(s.GetRequiredService<ILogger<NameplateService>>(), s.GetRequiredService<LightlessConfigService>(), namePlateGui, clientState,
s.GetRequiredService<PairManager>(), s.GetRequiredService<LightlessMediator>())); s.GetRequiredService<PairManager>(), s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService<ILogger<NameplateHandler>>(), addonLifecycle, gameGui, s.GetRequiredService<DalamudUtilService>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), clientState, s.GetRequiredService<PairManager>()));
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>()); collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>());
collection.AddHostedService(p => p.GetRequiredService<LightlessMediator>()); collection.AddHostedService(p => p.GetRequiredService<LightlessMediator>());

View File

@@ -42,7 +42,8 @@ public sealed class CommandManagerService : IDisposable
"\t /light toggle on|off - Connects or disconnects to Lightless respectively" + Environment.NewLine + "\t /light toggle on|off - Connects or disconnects to Lightless respectively" + Environment.NewLine +
"\t /light gpose - Opens the Lightless Character Data Hub window" + Environment.NewLine + "\t /light gpose - Opens the Lightless Character Data Hub window" + Environment.NewLine +
"\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine + "\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine +
"\t /light settings - Opens the Lightless Settings window" "\t /light settings - Opens the Lightless Settings window" + Environment.NewLine +
"\t /light lightfinder - Opens the Lightfinder window"
}); });
} }
@@ -122,5 +123,9 @@ public sealed class CommandManagerService : IDisposable
{ {
_mediator.Publish(new UiToggleMessage(typeof(SettingsUi))); _mediator.Publish(new UiToggleMessage(typeof(SettingsUi)));
} }
else if (string.Equals(splitArgs[0], "lightfinder", StringComparison.OrdinalIgnoreCase))
{
_mediator.Publish(new UiToggleMessage(typeof(BroadcastUI)));
}
} }
} }

View File

@@ -24,6 +24,7 @@ internal class ContextMenuService : IHostedService
private readonly PairRequestService _pairRequestService; private readonly PairRequestService _pairRequestService;
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly IObjectTable _objectTable; private readonly IObjectTable _objectTable;
private readonly LightlessConfigService _configService;
public ContextMenuService( public ContextMenuService(
IContextMenu contextMenu, IContextMenu contextMenu,
@@ -45,6 +46,7 @@ internal class ContextMenuService : IHostedService
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
_apiController = apiController; _apiController = apiController;
_objectTable = objectTable; _objectTable = objectTable;
_configService = configService;
_pairManager = pairManager; _pairManager = pairManager;
_pairRequestService = pairRequestService; _pairRequestService = pairRequestService;
_clientState = clientState; _clientState = clientState;
@@ -109,6 +111,9 @@ internal class ContextMenuService : IHostedService
if (!IsWorldValid(world)) if (!IsWorldValid(world))
return; return;
if (!_configService.Current.EnableRightClickMenus)
return;
args.AddMenuItem(new MenuItem args.AddMenuItem(new MenuItem
{ {
Name = "Send Pair Request", Name = "Send Pair Request",
@@ -153,6 +158,7 @@ internal class ContextMenuService : IHostedService
_logger.LogError(ex, "Error sending pair request."); _logger.LogError(ex, "Error sending pair request.");
} }
} }
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs() private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)]; .Select(u => (ulong)u.PlayerCharacterId)];

View File

@@ -1,4 +1,4 @@
using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Game.ClientState.Objects.Types;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Dto; using LightlessSync.API.Dto;
using LightlessSync.API.Dto.CharaData; using LightlessSync.API.Dto.CharaData;
@@ -77,6 +77,7 @@ public record OpenCensusPopupMessage() : MessageBase;
public record OpenSyncshellAdminPanel(GroupFullInfoDto GroupInfo) : MessageBase; public record OpenSyncshellAdminPanel(GroupFullInfoDto GroupInfo) : MessageBase;
public record OpenPermissionWindow(Pair Pair) : MessageBase; public record OpenPermissionWindow(Pair Pair) : MessageBase;
public record DownloadLimitChangedMessage() : SameThreadMessage; public record DownloadLimitChangedMessage() : SameThreadMessage;
public record PairProcessingLimitChangedMessage : SameThreadMessage;
public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase; public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase;
public record TargetPairMessage(Pair Pair) : MessageBase; public record TargetPairMessage(Pair Pair) : MessageBase;
public record CombatStartMessage : MessageBase; public record CombatStartMessage : MessageBase;

View File

@@ -1,15 +1,22 @@
using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.Text;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI; using FFXIVClientStructs.FFXIV.Component.GUI;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.UI; using LightlessSync.UI;
using LightlessSync.Utils; using LightlessSync.Utils;
using LightlessSync.UtilsEnum.Enum;
// Created using https://github.com/PunishedPineapple/Distance as a reference, thank you! // Created using https://github.com/PunishedPineapple/Distance as a reference, thank you!
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Globalization;
using System.Text;
namespace LightlessSync.Services; namespace LightlessSync.Services;
@@ -18,7 +25,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private readonly ILogger<NameplateHandler> _logger; private readonly ILogger<NameplateHandler> _logger;
private readonly IAddonLifecycle _addonLifecycle; private readonly IAddonLifecycle _addonLifecycle;
private readonly IGameGui _gameGui; private readonly IGameGui _gameGui;
private readonly IClientState _clientState;
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessConfigService _configService;
private readonly PairManager _pairManager;
private readonly LightlessMediator _mediator; private readonly LightlessMediator _mediator;
public LightlessMediator Mediator => _mediator; public LightlessMediator Mediator => _mediator;
@@ -26,18 +36,31 @@ public unsafe class NameplateHandler : IMediatorSubscriber
private bool _needsLabelRefresh = false; private bool _needsLabelRefresh = false;
private AddonNamePlate* mpNameplateAddon = null; private AddonNamePlate* mpNameplateAddon = null;
private readonly AtkTextNode*[] mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects]; private readonly AtkTextNode*[] mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
internal const uint mNameplateNodeIDBase = 0x7D99D500; internal const uint mNameplateNodeIDBase = 0x7D99D500;
private const string DefaultLabelText = "LightFinder";
private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn;
private const int ContainerOffsetX = 50;
private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon);
private volatile HashSet<string> _activeBroadcastingCids = new(); private volatile HashSet<string> _activeBroadcastingCids = [];
public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessMediator mediator) public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager)
{ {
_logger = logger; _logger = logger;
_addonLifecycle = addonLifecycle; _addonLifecycle = addonLifecycle;
_gameGui = gameGui; _gameGui = gameGui;
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
_configService = configService;
_mediator = mediator; _mediator = mediator;
_clientState = clientState;
_pairManager = pairManager;
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
} }
internal void Init() internal void Init()
@@ -96,6 +119,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber
if (mpNameplateAddon != pNameplateAddon) if (mpNameplateAddon != pNameplateAddon)
{ {
for (int i = 0; i < mTextNodes.Length; ++i) mTextNodes[i] = null; for (int i = 0; i < mTextNodes.Length; ++i) mTextNodes[i] = null;
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
mpNameplateAddon = pNameplateAddon; mpNameplateAddon = pNameplateAddon;
if (mpNameplateAddon != null) CreateNameplateNodes(); if (mpNameplateAddon != null) CreateNameplateNodes();
} }
@@ -156,6 +183,11 @@ public unsafe class NameplateHandler : IMediatorSubscriber
} }
} }
} }
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
} }
private void HideAllNameplateNodes() private void HideAllNameplateNodes()
@@ -177,6 +209,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber
for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i) for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i)
{ {
var objectInfo = ui3DModule->NamePlateObjectInfoPointers[i].Value; var objectInfo = ui3DModule->NamePlateObjectInfoPointers[i].Value;
if (objectInfo == null || objectInfo->GameObject == null) if (objectInfo == null || objectInfo->GameObject == null)
continue; continue;
@@ -190,30 +223,202 @@ public unsafe class NameplateHandler : IMediatorSubscriber
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject); var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject);
//_logger.LogInformation($"checking cid: {cid}", cid);
if (cid == null || !_activeBroadcastingCids.Contains(cid)) if (cid == null || !_activeBroadcastingCids.Contains(cid))
{ {
pNode->AtkResNode.ToggleVisibility(false); pNode->AtkResNode.ToggleVisibility(false);
continue; continue;
} }
pNode->AtkResNode.ToggleVisibility(true); if (!_configService.Current.LightfinderLabelShowOwn && (objectInfo->GameObject->GetGameObjectId() == _clientState.LocalPlayer.GameObjectId))
{
pNode->AtkResNode.ToggleVisibility(false);
continue;
}
if (!_configService.Current.LightfinderLabelShowPaired && VisibleUserIds.Any(u => u == objectInfo->GameObject->GetGameObjectId()))
{
pNode->AtkResNode.ToggleVisibility(false);
continue;
}
var nameplateObject = mpNameplateAddon->NamePlateObjectArray[nameplateIndex]; var nameplateObject = mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
nameplateObject.RootComponentNode->Component->UldManager.UpdateDrawNodeList(); nameplateObject.RootComponentNode->Component->UldManager.UpdateDrawNodeList();
var pNameplateIconNode = nameplateObject.MarkerIcon;
var pNameplateResNode = nameplateObject.NameContainer;
var pNameplateTextNode = nameplateObject.NameText;
bool IsVisible = pNameplateIconNode->AtkResNode.IsVisible() || (pNameplateResNode->IsVisible() && pNameplateTextNode->AtkResNode.IsVisible());
pNode->AtkResNode.ToggleVisibility(IsVisible);
var nameContainer = nameplateObject.NameContainer; var nameContainer = nameplateObject.NameContainer;
var nameText = nameplateObject.NameText; var nameText = nameplateObject.NameText;
if (nameContainer == null || nameText == null)
{
pNode->AtkResNode.ToggleVisibility(false);
continue;
}
var labelColor = UIColors.Get("LightlessPurple"); var labelColor = UIColors.Get("LightlessPurple");
var edgeColor = UIColors.Get("FullBlack"); var edgeColor = UIColors.Get("FullBlack");
var config = _configService.Current;
var labelY = nameContainer->Height - nameplateObject.TextH - (int)(24 * nameText->AtkResNode.ScaleY); var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
var effectiveScale = baseScale * scaleMultiplier;
var labelContent = config.LightfinderLabelUseIcon
? NormalizeIconGlyph(config.LightfinderLabelIconGlyph)
: DefaultLabelText;
pNode->AtkResNode.SetPositionShort(58, (short)labelY); pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
pNode->AtkResNode.SetScale(effectiveScale, effectiveScale);
var nodeWidth = (int)pNode->AtkResNode.GetWidth();
if (nodeWidth <= 0)
nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f;
var computedFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier);
pNode->FontSize = (byte)System.Math.Clamp(computedFontSize, 1, 255);
AlignmentType alignment;
var textScaleY = nameText->AtkResNode.ScaleY;
if (textScaleY <= 0f)
textScaleY = 1f;
var blockHeight = System.Math.Abs((int)nameplateObject.TextH);
if (blockHeight > 0)
{
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
}
else
{
blockHeight = _cachedNameplateTextHeights[nameplateIndex];
}
if (blockHeight <= 0)
{
blockHeight = GetScaledTextHeight(nameText);
if (blockHeight <= 0)
blockHeight = nodeHeight;
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
}
var containerHeight = (int)nameContainer->Height;
if (containerHeight > 0)
{
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
}
else
{
containerHeight = _cachedNameplateContainerHeights[nameplateIndex];
}
if (containerHeight <= 0)
{
containerHeight = blockHeight + (int)System.Math.Round(8 * textScaleY);
if (containerHeight <= blockHeight)
containerHeight = blockHeight + 1;
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
}
var blockTop = containerHeight - blockHeight;
if (blockTop < 0)
blockTop = 0;
var verticalPadding = (int)System.Math.Round(4 * effectiveScale);
var positionY = blockTop - verticalPadding - nodeHeight;
var textWidth = System.Math.Abs((int)nameplateObject.TextW);
if (textWidth <= 0)
{
textWidth = GetScaledTextWidth(nameText);
if (textWidth <= 0)
textWidth = nodeWidth;
}
if (textWidth > 0)
{
_cachedNameplateTextWidths[nameplateIndex] = textWidth;
}
var textOffset = (int)System.Math.Round(nameText->AtkResNode.X);
var hasValidOffset = true;
if (System.Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0)
{
_cachedNameplateTextOffsets[nameplateIndex] = textOffset;
}
else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue)
{
textOffset = _cachedNameplateTextOffsets[nameplateIndex];
}
else
{
hasValidOffset = false;
}
int positionX;
if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset)
{
var nameplateWidth = (int)nameContainer->Width;
if (!config.LightfinderLabelUseIcon)
{
pNode->TextFlags &= ~TextFlags.AutoAdjustNodeSize;
pNode->AtkResNode.Width = 0;
pNode->SetText(labelContent);
nodeWidth = (int)pNode->AtkResNode.GetWidth();
if (nodeWidth <= 0)
nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
if (nodeWidth > nameplateWidth)
nodeWidth = nameplateWidth;
pNode->AtkResNode.Width = (ushort)nodeWidth;
}
else
{
pNode->TextFlags |= TextFlags.AutoAdjustNodeSize;
pNode->AtkResNode.Width = 0;
pNode->SetText(labelContent);
nodeWidth = (int)pNode->AtkResNode.GetWidth();
}
int leftPos = nameplateWidth / 8;
int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8);
int centrePos = (nameplateWidth - nodeWidth) / 2;
int staticMargin = 24;
int calcMargin = (int)(nameplateWidth * 0.08f);
switch (config.LabelAlignment)
{
case LabelAlignment.Left:
positionX = config.LightfinderLabelUseIcon ? leftPos + staticMargin : leftPos;
alignment = AlignmentType.BottomLeft;
break;
case LabelAlignment.Right:
positionX = config.LightfinderLabelUseIcon ? rightPos - staticMargin : nameplateWidth - nodeWidth + calcMargin;
alignment = AlignmentType.BottomRight;
break;
default:
positionX = config.LightfinderLabelUseIcon ? centrePos : centrePos + calcMargin;
alignment = AlignmentType.Bottom;
break;
}
}
else
{
positionX = 58 + config.LightfinderLabelOffsetX;
alignment = AlignmentType.Bottom;
}
positionY += config.LightfinderLabelOffsetY;
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
pNode->AtkResNode.SetUseDepthBasedPriority(true); pNode->AtkResNode.SetUseDepthBasedPriority(true);
pNode->AtkResNode.SetScale(0.5f, 0.5f);
pNode->AtkResNode.Color.A = 255; pNode->AtkResNode.Color.A = 255;
@@ -227,18 +432,98 @@ public unsafe class NameplateHandler : IMediatorSubscriber
pNode->EdgeColor.B = (byte)(edgeColor.Z * 255); pNode->EdgeColor.B = (byte)(edgeColor.Z * 255);
pNode->EdgeColor.A = (byte)(edgeColor.W * 255); pNode->EdgeColor.A = (byte)(edgeColor.W * 255);
pNode->FontSize = 24;
pNode->AlignmentType = AlignmentType.Center; if(!config.LightfinderLabelUseIcon)
pNode->FontType = FontType.MiedingerMed; {
pNode->LineSpacing = 24; pNode->AlignmentType = AlignmentType.Bottom;
}
else
{
pNode->AlignmentType = alignment;
}
pNode->AtkResNode.SetPositionShort(
(short)System.Math.Clamp(positionX, short.MinValue, short.MaxValue),
(short)System.Math.Clamp(positionY, short.MinValue, short.MaxValue)
);
var computedLineSpacing = (int)System.Math.Round(24 * scaleMultiplier);
pNode->LineSpacing = (byte)System.Math.Clamp(computedLineSpacing, 0, byte.MaxValue);
pNode->CharSpacing = 1; pNode->CharSpacing = 1;
pNode->TextFlags = config.LightfinderLabelUseIcon
pNode->TextFlags = TextFlags.Edge | TextFlags.Glare; ? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize
: TextFlags.Edge | TextFlags.Glare;
pNode->SetText("Lightfinder");
} }
} }
private static unsafe int GetScaledTextHeight(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawHeight = (int)resNode->GetHeight();
if (rawHeight <= 0 && node->LineSpacing > 0)
rawHeight = node->LineSpacing;
if (rawHeight <= 0)
rawHeight = AtkNodeHelpers.DefaultTextNodeHeight;
var scale = resNode->ScaleY;
if (scale <= 0f)
scale = 1f;
var computed = (int)System.Math.Round(rawHeight * scale);
return System.Math.Max(1, computed);
}
private static unsafe int GetScaledTextWidth(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawWidth = (int)resNode->GetWidth();
if (rawWidth <= 0)
rawWidth = AtkNodeHelpers.DefaultTextNodeWidth;
var scale = resNode->ScaleX;
if (scale <= 0f)
scale = 1f;
var computed = (int)System.Math.Round(rawWidth * scale);
return System.Math.Max(1, computed);
}
internal static string NormalizeIconGlyph(string? rawInput)
{
if (string.IsNullOrWhiteSpace(rawInput))
return DefaultIconGlyph;
var trimmed = rawInput.Trim();
if (Enum.TryParse<SeIconChar>(trimmed, true, out var iconEnum))
return SeIconCharExtensions.ToIconString(iconEnum);
var hexCandidate = trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? trimmed[2..]
: trimmed;
if (ushort.TryParse(hexCandidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hexValue))
return char.ConvertFromUtf32(hexValue);
var enumerator = trimmed.EnumerateRunes();
if (enumerator.MoveNext())
return enumerator.Current.ToString();
return DefaultIconGlyph;
}
internal static string ToIconEditorString(string? rawInput)
{
var normalized = NormalizeIconGlyph(rawInput);
var runeEnumerator = normalized.EnumerateRunes();
return runeEnumerator.MoveNext()
? runeEnumerator.Current.Value.ToString("X4", CultureInfo.InvariantCulture)
: DefaultIconGlyph;
}
private void HideNameplateTextNode(int i) private void HideNameplateTextNode(int i)
{ {
var pNode = mTextNodes[i]; var pNode = mTextNodes[i];
@@ -267,6 +552,9 @@ public unsafe class NameplateHandler : IMediatorSubscriber
var nameplateObject = GetNameplateObject(i); var nameplateObject = GetNameplateObject(i);
return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null; return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null;
} }
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];
public void FlagRefresh() public void FlagRefresh()
{ {
@@ -298,4 +586,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber
FlagRefresh(); FlagRefresh();
} }
public void ClearNameplateCaches()
{
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
}
} }

View File

@@ -35,7 +35,6 @@ public class NameplateService : DisposableMediatorSubscriberBase
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate; _namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate;
_namePlateGui.RequestRedraw(); _namePlateGui.RequestRedraw();
Mediator.Subscribe<VisibilityChange>(this, (_) => _namePlateGui.RequestRedraw()); Mediator.Subscribe<VisibilityChange>(this, (_) => _namePlateGui.RequestRedraw());
} }
private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers) private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers)

View File

@@ -0,0 +1,220 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{
private const int HardLimit = 32;
private readonly LightlessConfigService _configService;
private readonly object _limitLock = new();
private readonly SemaphoreSlim _semaphore;
private int _currentLimit;
private int _pendingReductions;
private int _waiting;
private int _inFlight;
public PairProcessingLimiter(ILogger<PairProcessingLimiter> logger, LightlessMediator mediator, LightlessConfigService configService)
: base(logger, mediator)
{
_configService = configService;
_currentLimit = CalculateLimit();
var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : HardLimit;
_semaphore = new SemaphoreSlim(initialCount, HardLimit);
Mediator.Subscribe<PairProcessingLimitChangedMessage>(this, _ => UpdateSemaphoreLimit());
}
public ValueTask<IAsyncDisposable> AcquireAsync(CancellationToken cancellationToken)
{
return WaitInternalAsync(cancellationToken);
}
public PairProcessingLimiterSnapshot GetSnapshot()
{
lock (_limitLock)
{
var enabled = IsEnabled;
var limit = enabled ? _currentLimit : CalculateLimit();
var waiting = Math.Max(0, Volatile.Read(ref _waiting));
var inFlight = Math.Max(0, Volatile.Read(ref _inFlight));
return new PairProcessingLimiterSnapshot(enabled, limit, inFlight, waiting);
}
}
private bool IsEnabled => _configService.Current.EnablePairProcessingLimiter;
private async ValueTask<IAsyncDisposable> WaitInternalAsync(CancellationToken token)
{
if (!IsEnabled)
{
return NoopReleaser.Instance;
}
Interlocked.Increment(ref _waiting);
try
{
await _semaphore.WaitAsync(token).ConfigureAwait(false);
}
catch
{
Interlocked.Decrement(ref _waiting);
throw;
}
Interlocked.Decrement(ref _waiting);
if (!IsEnabled)
{
_semaphore.Release();
return NoopReleaser.Instance;
}
Interlocked.Increment(ref _inFlight);
return new Releaser(this);
}
private void UpdateSemaphoreLimit()
{
lock (_limitLock)
{
var enabled = IsEnabled;
var desiredLimit = CalculateLimit();
if (!enabled)
{
var releaseAmount = HardLimit - _semaphore.CurrentCount;
if (releaseAmount > 0)
{
try
{
_semaphore.Release(releaseAmount);
}
catch (SemaphoreFullException)
{
// ignore, already at max
}
}
_currentLimit = desiredLimit;
_pendingReductions = 0;
return;
}
if (desiredLimit == _currentLimit)
{
return;
}
if (desiredLimit > _currentLimit)
{
var increment = desiredLimit - _currentLimit;
var allowed = Math.Min(increment, HardLimit - _semaphore.CurrentCount);
if (allowed > 0)
{
_semaphore.Release(allowed);
}
}
else
{
var decrement = _currentLimit - desiredLimit;
var removed = 0;
while (removed < decrement && _semaphore.Wait(0))
{
removed++;
}
var remaining = decrement - removed;
if (remaining > 0)
{
_pendingReductions += remaining;
}
}
_currentLimit = desiredLimit;
Logger.LogDebug("Pair processing concurrency updated to {limit} (pending reductions: {pending})", _currentLimit, _pendingReductions);
}
}
private int CalculateLimit()
{
var configured = _configService.Current.MaxConcurrentPairApplications;
return Math.Clamp(configured, 1, HardLimit);
}
private void ReleaseOne()
{
var inFlight = Interlocked.Decrement(ref _inFlight);
if (inFlight < 0)
{
Interlocked.Exchange(ref _inFlight, 0);
}
if (!IsEnabled)
{
return;
}
lock (_limitLock)
{
if (_pendingReductions > 0)
{
_pendingReductions--;
return;
}
}
_semaphore.Release();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing)
{
return;
}
_semaphore.Dispose();
}
private sealed class Releaser : IAsyncDisposable
{
private PairProcessingLimiter? _owner;
public Releaser(PairProcessingLimiter owner)
{
_owner = owner;
}
public ValueTask DisposeAsync()
{
var owner = Interlocked.Exchange(ref _owner, null);
owner?.ReleaseOne();
return ValueTask.CompletedTask;
}
}
private sealed class NoopReleaser : IAsyncDisposable
{
public static readonly NoopReleaser Instance = new();
private NoopReleaser()
{
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
}
public readonly record struct PairProcessingLimiterSnapshot(bool IsEnabled, int Limit, int InFlight, int Waiting)
{
public int Remaining => Math.Max(0, Limit - InFlight);
}

View File

@@ -504,7 +504,7 @@ public class ServerConfigurationManager
internal void RenameTag(Dictionary<string, List<string>> tags, HashSet<string> storage, string oldName, string newName) internal void RenameTag(Dictionary<string, List<string>> tags, HashSet<string> storage, string oldName, string newName)
{ {
if (newName.Length > _maxCharactersFolder) if (newName.Length < _maxCharactersFolder)
{ {
storage.Remove(oldName); storage.Remove(oldName);
storage.Add(newName); storage.Add(newName);

View File

@@ -49,8 +49,6 @@ namespace LightlessSync.UI
MinimumSize = new(600, 465), MinimumSize = new(600, 465),
MaximumSize = new(750, 525) MaximumSize = new(750, 525)
}; };
mediator.Subscribe<RefreshUiMessage>(this, async _ => await RefreshSyncshells().ConfigureAwait(false));
} }
private void RebuildSyncshellDropdownOptions() private void RebuildSyncshellDropdownOptions()
@@ -121,7 +119,7 @@ namespace LightlessSync.UI
public override void OnOpen() public override void OnOpen()
{ {
_userUid = _apiController.UID; _userUid = _apiController.UID;
_ = RefreshSyncshellsInternal(); _ = RefreshSyncshells();
} }
protected override void DrawInternal() protected override void DrawInternal()
@@ -268,6 +266,7 @@ namespace LightlessSync.UI
if (_allSyncshells == null) if (_allSyncshells == null)
{ {
ImGui.Text("Loading Syncshells..."); ImGui.Text("Loading Syncshells...");
_ = RefreshSyncshells();
return; return;
} }
@@ -319,6 +318,7 @@ namespace LightlessSync.UI
{ {
_configService.Current.SelectedFinderSyncshell = gid; _configService.Current.SelectedFinderSyncshell = gid;
_configService.Save(); _configService.Save();
_ = RefreshSyncshells();
} }
if (!available && ImGui.IsItemHovered()) if (!available && ImGui.IsItemHovered())

View File

@@ -1,4 +1,5 @@
using Dalamud.Bindings.ImGui; using System;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
@@ -376,7 +377,7 @@ public class CompactUi : WindowMediatorSubscriberBase
private void DrawTransfers() private void DrawTransfers()
{ {
var currentUploads = _fileTransferManager.CurrentUploads.ToList(); var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
_uiSharedService.IconText(FontAwesomeIcon.Upload); _uiSharedService.IconText(FontAwesomeIcon.Upload);
ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); ImGui.SameLine(35 * ImGuiHelpers.GlobalScale);
@@ -386,10 +387,12 @@ public class CompactUi : WindowMediatorSubscriberBase
var totalUploads = currentUploads.Count; var totalUploads = currentUploads.Count;
var doneUploads = currentUploads.Count(c => c.IsTransferred); var doneUploads = currentUploads.Count(c => c.IsTransferred);
var activeUploads = currentUploads.Count(c => !c.IsTransferred);
var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8);
var totalUploaded = currentUploads.Sum(c => c.Transferred); var totalUploaded = currentUploads.Sum(c => c.Transferred);
var totalToUpload = currentUploads.Sum(c => c.Total); var totalToUpload = currentUploads.Sum(c => c.Total);
ImGui.TextUnformatted($"{doneUploads}/{totalUploads}"); ImGui.TextUnformatted($"{doneUploads}/{totalUploads} (slots {activeUploads}/{uploadSlotLimit})");
var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})"; var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})";
var textSize = ImGui.CalcTextSize(uploadText); var textSize = ImGui.CalcTextSize(uploadText);
ImGui.SameLine(_windowContentWidth - textSize.X); ImGui.SameLine(_windowContentWidth - textSize.X);
@@ -671,7 +674,7 @@ public class CompactUi : WindowMediatorSubscriberBase
UiSharedService.AttachToolTip("Click to copy"); UiSharedService.AttachToolTip("Click to copy");
if (uidFooterClicked) if (uidFooterClicked)
{ {
_lightlessMediator.Publish(new UiToggleMessage(typeof(DataAnalysisUi))); ImGui.SetClipboardText(_apiController.UID);
} }
} }
} }
@@ -825,7 +828,7 @@ public class CompactUi : WindowMediatorSubscriberBase
.Where(u => FilterGroupUsers(u.Value, group))); .Where(u => FilterGroupUsers(u.Value, group)));
filteredGroupPairs = filteredPairs filteredGroupPairs = filteredPairs
.Where(u => FilterGroupUsers( u.Value, group) && FilterOnlineOrPausedSelf(u.Key)) .Where(u => FilterGroupUsers(u.Value, group) && FilterOnlineOrPausedSelf(u.Key))
.OrderByDescending(u => u.Key.IsOnline) .OrderByDescending(u => u.Key.IsOnline)
.ThenBy(u => .ThenBy(u =>
{ {

View File

@@ -313,7 +313,7 @@ public class DrawUserPair
using (ImRaii.PushColor(ImGuiCol.Text, roleColor)) using (ImRaii.PushColor(ImGuiCol.Text, roleColor))
{ {
ImGui.TextUnformatted(_pair.UserData.IsAdmin ImGui.TextUnformatted(_pair.UserData.IsAdmin
? "Official Lightless Admin" ? "Official Lightless Developer"
: "Official Lightless Moderator"); : "Official Lightless Moderator");
} }
ImGui.EndTooltip(); ImGui.EndTooltip();

View File

@@ -1,4 +1,5 @@
using Dalamud.Bindings.ImGui; using System;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Colors; using Dalamud.Interface.Colors;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
@@ -19,14 +20,16 @@ public class DownloadUi : WindowMediatorSubscriberBase
private readonly DalamudUtilService _dalamudUtilService; private readonly DalamudUtilService _dalamudUtilService;
private readonly FileUploadManager _fileTransferManager; private readonly FileUploadManager _fileTransferManager;
private readonly UiSharedService _uiShared; private readonly UiSharedService _uiShared;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new(); private readonly ConcurrentDictionary<GameObjectHandler, bool> _uploadingPlayers = new();
public DownloadUi(ILogger<DownloadUi> logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService, public DownloadUi(ILogger<DownloadUi> logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService,
FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, PerformanceCollectorService performanceCollectorService) PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, PerformanceCollectorService performanceCollectorService)
: base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService) : base(logger, mediator, "Lightless Sync Downloads", performanceCollectorService)
{ {
_dalamudUtilService = dalamudUtilService; _dalamudUtilService = dalamudUtilService;
_configService = configService; _configService = configService;
_pairProcessingLimiter = pairProcessingLimiter;
_fileTransferManager = fileTransferManager; _fileTransferManager = fileTransferManager;
_uiShared = uiShared; _uiShared = uiShared;
@@ -73,11 +76,25 @@ public class DownloadUi : WindowMediatorSubscriberBase
{ {
if (_configService.Current.ShowTransferWindow) if (_configService.Current.ShowTransferWindow)
{ {
var limiterSnapshot = _pairProcessingLimiter.GetSnapshot();
if (limiterSnapshot.IsEnabled)
{
var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey;
var queueText = $"Pair queue {limiterSnapshot.InFlight}/{limiterSnapshot.Limit}";
queueText += limiterSnapshot.Waiting > 0 ? $" ({limiterSnapshot.Waiting} waiting, {limiterSnapshot.Remaining} free)" : $" ({limiterSnapshot.Remaining} free)";
UiSharedService.DrawOutlinedFont(queueText, queueColor, new Vector4(0, 0, 0, 255), 1);
ImGui.NewLine();
}
else
{
UiSharedService.DrawOutlinedFont("Pair apply limiter disabled", ImGuiColors.DalamudGrey, new Vector4(0, 0, 0, 255), 1);
ImGui.NewLine();
}
try try
{ {
if (_fileTransferManager.CurrentUploads.Any()) if (_fileTransferManager.IsUploading)
{ {
var currentUploads = _fileTransferManager.CurrentUploads.ToList(); var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
var totalUploads = currentUploads.Count; var totalUploads = currentUploads.Count;
var doneUploads = currentUploads.Count(c => c.IsTransferred); var doneUploads = currentUploads.Count(c => c.IsTransferred);
@@ -214,7 +231,7 @@ public class DownloadUi : WindowMediatorSubscriberBase
{ {
if (_uiShared.EditTrackerPosition) return true; if (_uiShared.EditTrackerPosition) return true;
if (!_configService.Current.ShowTransferWindow && !_configService.Current.ShowTransferBars) return false; if (!_configService.Current.ShowTransferWindow && !_configService.Current.ShowTransferBars) return false;
if (!_currentDownloads.Any() && !_fileTransferManager.CurrentUploads.Any() && !_uploadingPlayers.Any()) return false; if (!_currentDownloads.Any() && !_fileTransferManager.IsUploading && !_uploadingPlayers.Any()) return false;
if (!IsOpen) return false; if (!IsOpen) return false;
return true; return true;
} }

View File

@@ -1,4 +1,5 @@
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Colors; using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
@@ -10,6 +11,7 @@ using LightlessSync.API.Routes;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc; using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
@@ -17,15 +19,18 @@ using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils; using LightlessSync.Utils;
using LightlessSync.UtilsEnum.Enum;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.Files.Models;
using LightlessSync.WebAPI.SignalR.Utils; using LightlessSync.WebAPI.SignalR.Utils;
using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Http.Connections;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Numerics; using System.Numerics;
@@ -50,10 +55,12 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly PairManager _pairManager; private readonly PairManager _pairManager;
private readonly PerformanceCollectorService _performanceCollector; private readonly PerformanceCollectorService _performanceCollector;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigurationManager; private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly UiSharedService _uiShared; private readonly UiSharedService _uiShared;
private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress; private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress;
private readonly NameplateService _nameplateService; private readonly NameplateService _nameplateService;
private readonly NameplateHandler _nameplateHandler;
private (int, int, FileCacheEntity) _currentProgress; private (int, int, FileCacheEntity) _currentProgress;
private bool _deleteAccountPopupModalShown = false; private bool _deleteAccountPopupModalShown = false;
private bool _deleteFilesPopupModalShown = false; private bool _deleteFilesPopupModalShown = false;
@@ -63,6 +70,23 @@ public class SettingsUi : WindowMediatorSubscriberBase
private bool _readClearCache = false; private bool _readClearCache = false;
private int _selectedEntry = -1; private int _selectedEntry = -1;
private string _uidToAddForIgnore = string.Empty; private string _uidToAddForIgnore = string.Empty;
private string _lightfinderIconInput = string.Empty;
private bool _lightfinderIconInputInitialized = false;
private int _lightfinderIconPresetIndex = -1;
private static readonly (string Label, SeIconChar Icon)[] LightfinderIconPresets = new[]
{
("Link Marker", SeIconChar.LinkMarker),
("Hyadelyn", SeIconChar.Hyadelyn),
("Gil", SeIconChar.Gil),
("Quest Sync", SeIconChar.QuestSync),
("Glamoured", SeIconChar.Glamoured),
("Glamoured (Dyed)", SeIconChar.GlamouredDyed),
("Auto-Translate Open", SeIconChar.AutoTranslateOpen),
("Auto-Translate Close", SeIconChar.AutoTranslateClose),
("Boxed Star", SeIconChar.BoxedStar),
("Boxed Plus", SeIconChar.BoxedPlus)
};
private CancellationTokenSource? _validationCts; private CancellationTokenSource? _validationCts;
private Task<List<FileCacheEntity>>? _validationTask; private Task<List<FileCacheEntity>>? _validationTask;
private bool _wasOpen = false; private bool _wasOpen = false;
@@ -72,6 +96,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
PairManager pairManager, PairManager pairManager,
ServerConfigurationManager serverConfigurationManager, ServerConfigurationManager serverConfigurationManager,
PlayerPerformanceConfigService playerPerformanceConfigService, PlayerPerformanceConfigService playerPerformanceConfigService,
PairProcessingLimiter pairProcessingLimiter,
LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessMediator mediator, PerformanceCollectorService performanceCollector,
FileUploadManager fileTransferManager, FileUploadManager fileTransferManager,
FileTransferOrchestrator fileTransferOrchestrator, FileTransferOrchestrator fileTransferOrchestrator,
@@ -79,12 +104,14 @@ public class SettingsUi : WindowMediatorSubscriberBase
FileCompactor fileCompactor, ApiController apiController, FileCompactor fileCompactor, ApiController apiController,
IpcManager ipcManager, CacheMonitor cacheMonitor, IpcManager ipcManager, CacheMonitor cacheMonitor,
DalamudUtilService dalamudUtilService, HttpClient httpClient, DalamudUtilService dalamudUtilService, HttpClient httpClient,
NameplateService nameplateService) : base(logger, mediator, "Lightless Sync Settings", performanceCollector) NameplateService nameplateService,
NameplateHandler nameplateHandler) : base(logger, mediator, "Lightless Sync Settings", performanceCollector)
{ {
_configService = configService; _configService = configService;
_pairManager = pairManager; _pairManager = pairManager;
_serverConfigurationManager = serverConfigurationManager; _serverConfigurationManager = serverConfigurationManager;
_playerPerformanceConfigService = playerPerformanceConfigService; _playerPerformanceConfigService = playerPerformanceConfigService;
_pairProcessingLimiter = pairProcessingLimiter;
_performanceCollector = performanceCollector; _performanceCollector = performanceCollector;
_fileTransferManager = fileTransferManager; _fileTransferManager = fileTransferManager;
_fileTransferOrchestrator = fileTransferOrchestrator; _fileTransferOrchestrator = fileTransferOrchestrator;
@@ -97,6 +124,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
_fileCompactor = fileCompactor; _fileCompactor = fileCompactor;
_uiShared = uiShared; _uiShared = uiShared;
_nameplateService = nameplateService; _nameplateService = nameplateService;
_nameplateHandler = nameplateHandler;
AllowClickthrough = false; AllowClickthrough = false;
AllowPinning = true; AllowPinning = true;
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
@@ -218,6 +246,9 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGuiHelpers.ScaledDummy(5); ImGuiHelpers.ScaledDummy(5);
int maxParallelDownloads = _configService.Current.ParallelDownloads; int maxParallelDownloads = _configService.Current.ParallelDownloads;
int maxParallelUploads = _configService.Current.ParallelUploads;
int maxPairApplications = _configService.Current.MaxConcurrentPairApplications;
bool limitPairApplications = _configService.Current.EnablePairProcessingLimiter;
bool useAlternativeUpload = _configService.Current.UseAlternativeFileUpload; bool useAlternativeUpload = _configService.Current.UseAlternativeFileUpload;
int downloadSpeedLimit = _configService.Current.DownloadSpeedLimitInBytes; int downloadSpeedLimit = _configService.Current.DownloadSpeedLimitInBytes;
@@ -254,7 +285,60 @@ public class SettingsUi : WindowMediatorSubscriberBase
{ {
_configService.Current.ParallelDownloads = maxParallelDownloads; _configService.Current.ParallelDownloads = maxParallelDownloads;
_configService.Save(); _configService.Save();
Mediator.Publish(new DownloadLimitChangedMessage());
} }
_uiShared.DrawHelpText("Controls how many download slots can be active at once.");
if (ImGui.SliderInt("Maximum Parallel Uploads", ref maxParallelUploads, 1, 8))
{
_configService.Current.ParallelUploads = maxParallelUploads;
_configService.Save();
}
_uiShared.DrawHelpText("Controls how many uploads can run at once.");
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f);
if (ImGui.Checkbox("Enable Pair Download Limiter", ref limitPairApplications))
{
_configService.Current.EnablePairProcessingLimiter = limitPairApplications;
_configService.Save();
Mediator.Publish(new PairProcessingLimitChangedMessage());
}
_uiShared.DrawHelpText("When enabled we stagger pair downloads to avoid large network and game lag caused by attempting to download everyone at once.");
var limiterDisabledScope = !limitPairApplications;
if (limiterDisabledScope)
{
ImGui.BeginDisabled();
}
if (ImGui.SliderInt("Maximum Concurrent Pair Downloads", ref maxPairApplications, 1, 6))
{
_configService.Current.MaxConcurrentPairApplications = maxPairApplications;
_configService.Save();
Mediator.Publish(new PairProcessingLimitChangedMessage());
}
_uiShared.DrawHelpText("How many pair downloads/applications can run simultaneously when the limit is on.");
if (limiterDisabledScope)
{
ImGui.EndDisabled();
}
var limiterSnapshot = _pairProcessingLimiter.GetSnapshot();
if (limiterSnapshot.IsEnabled)
{
var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey;
var queueText = $"Pair queue {limiterSnapshot.InFlight}/{limiterSnapshot.Limit}";
queueText += limiterSnapshot.Waiting > 0 ? $" ({limiterSnapshot.Waiting} waiting, {limiterSnapshot.Remaining} free)" : $" ({limiterSnapshot.Remaining} free)";
ImGui.TextColored(queueColor, queueText);
}
else
{
ImGui.TextColored(ImGuiColors.DalamudGrey, "Pair apply limiter is disabled.");
}
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f);
if (ImGui.Checkbox("Use Alternative Upload Method", ref useAlternativeUpload)) if (ImGui.Checkbox("Use Alternative Upload Method", ref useAlternativeUpload))
{ {
@@ -409,25 +493,33 @@ public class SettingsUi : WindowMediatorSubscriberBase
{ {
if (ApiController.ServerState is ServerState.Connected && ImGui.BeginTabItem("Transfers")) if (ApiController.ServerState is ServerState.Connected && ImGui.BeginTabItem("Transfers"))
{ {
ImGui.TextUnformatted("Uploads"); var uploadsSnapshot = _fileTransferManager.GetCurrentUploadsSnapshot();
var activeUploads = uploadsSnapshot.Count(c => !c.IsTransferred);
var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8);
ImGui.TextUnformatted($"Uploads (slots {activeUploads}/{uploadSlotLimit})");
if (ImGui.BeginTable("UploadsTable", 3)) if (ImGui.BeginTable("UploadsTable", 3))
{ {
ImGui.TableSetupColumn("File"); ImGui.TableSetupColumn("File");
ImGui.TableSetupColumn("Uploaded"); ImGui.TableSetupColumn("Uploaded");
ImGui.TableSetupColumn("Size"); ImGui.TableSetupColumn("Size");
ImGui.TableHeadersRow(); ImGui.TableHeadersRow();
foreach (var transfer in _fileTransferManager.CurrentUploads.ToArray()) foreach (var transfer in uploadsSnapshot)
{ {
var color = UiSharedService.UploadColor((transfer.Transferred, transfer.Total)); var color = UiSharedService.UploadColor((transfer.Transferred, transfer.Total));
var col = ImRaii.PushColor(ImGuiCol.Text, color); using var col = ImRaii.PushColor(ImGuiCol.Text, color);
ImGui.TableNextColumn(); ImGui.TableNextColumn();
if (transfer is UploadFileTransfer uploadTransfer)
{
ImGui.TextUnformatted(uploadTransfer.LocalFile);
}
else
{
ImGui.TextUnformatted(transfer.Hash); ImGui.TextUnformatted(transfer.Hash);
}
ImGui.TableNextColumn(); ImGui.TableNextColumn();
ImGui.TextUnformatted(UiSharedService.ByteToString(transfer.Transferred)); ImGui.TextUnformatted(UiSharedService.ByteToString(transfer.Transferred));
ImGui.TableNextColumn(); ImGui.TableNextColumn();
ImGui.TextUnformatted(UiSharedService.ByteToString(transfer.Total)); ImGui.TextUnformatted(UiSharedService.ByteToString(transfer.Total));
col.Dispose();
ImGui.TableNextRow();
} }
ImGui.EndTable(); ImGui.EndTable();
@@ -940,7 +1032,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
_configService.Current.EnableRightClickMenus = enableRightClickMenu; _configService.Current.EnableRightClickMenus = enableRightClickMenu;
_configService.Save(); _configService.Save();
} }
_uiShared.DrawHelpText("This will add Lightless related right click menu entries in the game UI on paired players."); _uiShared.DrawHelpText("This will add all Lightless related right click menu entries in the game UI.");
if (ImGui.Checkbox("Display status and visible pair count in Server Info Bar", ref enableDtrEntry)) if (ImGui.Checkbox("Display status and visible pair count in Server Info Bar", ref enableDtrEntry))
{ {
@@ -972,6 +1064,261 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Separator(); ImGui.Separator();
if (_uiShared.MediumTreeNode("Lightfinder", UIColors.Get("LightlessPurple")))
{
var autoAlign = _configService.Current.LightfinderAutoAlign;
var offsetX = (int)_configService.Current.LightfinderLabelOffsetX;
var offsetY = (int)_configService.Current.LightfinderLabelOffsetY;
var labelScale = _configService.Current.LightfinderLabelScale;
ImGui.TextUnformatted("Alignment");
ImGui.BeginDisabled(autoAlign);
if (ImGui.SliderInt("Label Offset X", ref offsetX, -200, 200))
{
_configService.Current.LightfinderLabelOffsetX = (short)offsetX;
_configService.Save();
_nameplateHandler.ClearNameplateCaches();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
{
_configService.Current.LightfinderLabelOffsetX = 0;
_configService.Save();
_nameplateHandler.ClearNameplateCaches();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Right click to reset to default.");
ImGui.EndDisabled();
_uiShared.DrawHelpText("Moves the Lightfinder label horizontally on player nameplates.\nUnavailable when automatic alignment is enabled.");
if (ImGui.SliderInt("Label Offset Y", ref offsetY, -200, 200))
{
_configService.Current.LightfinderLabelOffsetY = (short)offsetY;
_configService.Save();
_nameplateHandler.ClearNameplateCaches();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
{
_configService.Current.LightfinderLabelOffsetY = 0;
_configService.Save();
_nameplateHandler.ClearNameplateCaches();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Right click to reset to default.");
_uiShared.DrawHelpText("Moves the Lightfinder label vertically on player nameplates.");
if (ImGui.SliderFloat("Label Size", ref labelScale, 0.5f, 2.0f, "%.2fx"))
{
_configService.Current.LightfinderLabelScale = labelScale;
_configService.Save();
_nameplateHandler.ClearNameplateCaches();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
{
_configService.Current.LightfinderLabelScale = 1.0f;
_configService.Save();
_nameplateHandler.ClearNameplateCaches();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
}
if (ImGui.IsItemHovered())
ImGui.SetTooltip("Right click to reset to default.");
_uiShared.DrawHelpText("Adjusts the Lightfinder label size for both text and icon modes.");
ImGui.Dummy(new Vector2(8));
if (ImGui.Checkbox("Automatically align with nameplate", ref autoAlign))
{
_configService.Current.LightfinderAutoAlign = autoAlign;
_configService.Save();
_nameplateHandler.ClearNameplateCaches();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
}
_uiShared.DrawHelpText("Automatically position the label relative to the in-game nameplate. Turn off to rely entirely on manual offsets.");
if (autoAlign)
{
var alignmentOption = _configService.Current.LabelAlignment;
var alignmentLabel = alignmentOption switch
{
LabelAlignment.Left => "Left",
LabelAlignment.Right => "Right",
_ => "Center",
};
if (ImGui.BeginCombo("Horizontal Alignment", alignmentLabel))
{
foreach (LabelAlignment option in Enum.GetValues<LabelAlignment>())
{
var optionLabel = option switch
{
LabelAlignment.Left => "Left",
LabelAlignment.Right => "Right",
_ => "Center",
};
var selected = option == alignmentOption;
if (ImGui.Selectable(optionLabel, selected))
{
_configService.Current.LabelAlignment = option;
_configService.Save();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
}
if (selected)
ImGui.SetItemDefaultFocus();
}
ImGui.EndCombo();
}
}
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f);
ImGui.TextUnformatted("Visibility");
var showOwn = _configService.Current.LightfinderLabelShowOwn;
if (ImGui.Checkbox("Show your own Lightfinder label", ref showOwn))
{
_configService.Current.LightfinderLabelShowOwn = showOwn;
_configService.Save();
_nameplateHandler.ClearNameplateCaches();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
}
_uiShared.DrawHelpText("Toggles your own Lightfinder label.");
var showPaired = _configService.Current.LightfinderLabelShowPaired;
if (ImGui.Checkbox("Show paired player(s) Lightfinder label", ref showPaired))
{
_configService.Current.LightfinderLabelShowPaired = showPaired;
_configService.Save();
_nameplateHandler.ClearNameplateCaches();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
}
_uiShared.DrawHelpText("Toggles paired player(s) Lightfinder label.");
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f);
ImGui.TextUnformatted("Label");
var useIcon = _configService.Current.LightfinderLabelUseIcon;
if (ImGui.Checkbox("Show icon instead of text", ref useIcon))
{
_configService.Current.LightfinderLabelUseIcon = useIcon;
_configService.Save();
_nameplateHandler.ClearNameplateCaches();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
if (useIcon)
{
RefreshLightfinderIconState();
}
else
{
_lightfinderIconInputInitialized = false;
_lightfinderIconPresetIndex = -1;
}
}
_uiShared.DrawHelpText("Switch between the Lightfinder text label and an icon on nameplates.");
if (useIcon)
{
if (!_lightfinderIconInputInitialized)
{
RefreshLightfinderIconState();
}
var currentPresetLabel = _lightfinderIconPresetIndex >= 0
? $"{GetLightfinderPresetGlyph(_lightfinderIconPresetIndex)} {LightfinderIconPresets[_lightfinderIconPresetIndex].Label}"
: "Custom";
if (ImGui.BeginCombo("Preset Icon", currentPresetLabel))
{
for (int i = 0; i < LightfinderIconPresets.Length; i++)
{
var optionGlyph = GetLightfinderPresetGlyph(i);
var preview = $"{optionGlyph} {LightfinderIconPresets[i].Label}";
var selected = i == _lightfinderIconPresetIndex;
if (ImGui.Selectable(preview, selected))
{
ApplyLightfinderIcon(optionGlyph, i);
}
}
if (ImGui.Selectable("Custom", _lightfinderIconPresetIndex == -1))
{
_lightfinderIconPresetIndex = -1;
}
ImGui.EndCombo();
}
var editorBuffer = _lightfinderIconInput;
if (ImGui.InputText("Icon Glyph", ref editorBuffer, 16))
{
_lightfinderIconInput = editorBuffer;
_lightfinderIconPresetIndex = -1;
}
if (ImGui.Button("Apply Icon"))
{
var normalized = NameplateHandler.NormalizeIconGlyph(_lightfinderIconInput);
ApplyLightfinderIcon(normalized, _lightfinderIconPresetIndex);
}
ImGui.SameLine();
if (ImGui.Button("Reset Icon"))
{
var defaultGlyph = NameplateHandler.NormalizeIconGlyph(null);
var defaultIndex = -1;
for (int i = 0; i < LightfinderIconPresets.Length; i++)
{
if (string.Equals(GetLightfinderPresetGlyph(i), defaultGlyph, StringComparison.Ordinal))
{
defaultIndex = i;
break;
}
}
if (defaultIndex < 0)
{
defaultIndex = 0;
}
ApplyLightfinderIcon(GetLightfinderPresetGlyph(defaultIndex), defaultIndex);
}
var previewGlyph = NameplateHandler.NormalizeIconGlyph(_lightfinderIconInput);
ImGui.SameLine();
ImGui.AlignTextToFramePadding();
ImGui.Text($"Preview: {previewGlyph}");
_uiShared.DrawHelpText("Enter a hex code (e.g. E0BB), pick a preset, or paste an icon character directly.");
}
else
{
_lightfinderIconInputInitialized = false;
_lightfinderIconPresetIndex = -1;
}
_uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f);
ImGui.TreePop();
}
ImGui.Separator();
if (_uiShared.MediumTreeNode("Colors", UIColors.Get("LightlessPurple"))) if (_uiShared.MediumTreeNode("Colors", UIColors.Get("LightlessPurple")))
{ {
ImGui.TextUnformatted("UI Theme Colors"); ImGui.TextUnformatted("UI Theme Colors");
@@ -2216,6 +2563,39 @@ public class SettingsUi : WindowMediatorSubscriberBase
return (true, failedConversions.Count != 0, sb.ToString()); return (true, failedConversions.Count != 0, sb.ToString());
} }
private static string GetLightfinderPresetGlyph(int index)
{
return NameplateHandler.NormalizeIconGlyph(SeIconCharExtensions.ToIconString(LightfinderIconPresets[index].Icon));
}
private void RefreshLightfinderIconState()
{
var normalized = NameplateHandler.NormalizeIconGlyph(_configService.Current.LightfinderLabelIconGlyph);
_lightfinderIconInput = NameplateHandler.ToIconEditorString(normalized);
_lightfinderIconInputInitialized = true;
_lightfinderIconPresetIndex = -1;
for (int i = 0; i < LightfinderIconPresets.Length; i++)
{
if (string.Equals(GetLightfinderPresetGlyph(i), normalized, StringComparison.Ordinal))
{
_lightfinderIconPresetIndex = i;
break;
}
}
}
private void ApplyLightfinderIcon(string normalizedGlyph, int presetIndex)
{
_configService.Current.LightfinderLabelIconGlyph = normalizedGlyph;
_configService.Save();
_nameplateHandler.FlagRefresh();
_nameplateService.RequestRedraw();
_lightfinderIconInput = NameplateHandler.ToIconEditorString(normalizedGlyph);
_lightfinderIconPresetIndex = presetIndex;
_lightfinderIconInputInitialized = true;
}
private void DrawSettingsContent() private void DrawSettingsContent()
{ {
if (_apiController.ServerState is ServerState.Connected) if (_apiController.ServerState is ServerState.Connected)

View File

@@ -29,6 +29,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
private readonly List<GroupJoinDto> _nearbySyncshells = []; private readonly List<GroupJoinDto> _nearbySyncshells = [];
private List<GroupFullInfoDto> _currentSyncshells = []; private List<GroupFullInfoDto> _currentSyncshells = [];
private int _selectedNearbyIndex = -1; private int _selectedNearbyIndex = -1;
private readonly HashSet<string> _recentlyJoined = new(StringComparer.Ordinal);
private GroupJoinDto? _joinDto; private GroupJoinDto? _joinDto;
private GroupJoinInfoDto? _joinInfo; private GroupJoinInfoDto? _joinInfo;
@@ -120,6 +121,17 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
foreach (var shell in _nearbySyncshells) foreach (var shell in _nearbySyncshells)
{ {
// Check if there is an active broadcast for this syncshell, if not, skipping this syncshell
var broadcast = _broadcastScannerService.GetActiveSyncshellBroadcasts()
.FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal));
if (broadcast == null)
continue; // no active broadcasts
var (Name, Address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID);
if (string.IsNullOrEmpty(Name))
continue; // broadcaster not found in area, skipping
ImGui.TableNextRow(); ImGui.TableNextRow();
ImGui.TableNextColumn(); ImGui.TableNextColumn();
@@ -127,19 +139,8 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
ImGui.TextUnformatted(displayName); ImGui.TextUnformatted(displayName);
ImGui.TableNextColumn(); ImGui.TableNextColumn();
var broadcasterName = "Unknown";
var broadcast = _broadcastScannerService.GetActiveSyncshellBroadcasts()
.FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal));
if (broadcast != null)
{
var (Name, Address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID);
if (!string.IsNullOrEmpty(Name))
{
var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(Address); var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(Address);
broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{Name} ({worldName})" : Name; var broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{Name} ({worldName})" : Name;
}
}
ImGui.TextUnformatted(broadcasterName); ImGui.TextUnformatted(broadcasterName);
ImGui.TableNextColumn(); ImGui.TableNextColumn();
@@ -149,7 +150,10 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f));
ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f));
if (!_currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal))) var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal));
var isRecentlyJoined = _recentlyJoined.Contains(shell.GID);
if (!isAlreadyMember && !isRecentlyJoined)
{ {
if (ImGui.Button(label)) if (ImGui.Button(label))
{ {
@@ -224,9 +228,11 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX); finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX);
_ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions)); _ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions));
_recentlyJoined.Add(_joinDto.Group.GID);
_joinDto = null; _joinDto = null;
_joinInfo = null; _joinInfo = null;
_ = RefreshSyncshellsAsync();
} }
} }
} }
@@ -262,6 +268,8 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts();
_currentSyncshells = [.. _pairManager.GroupPairs.Select(g => g.Key)]; _currentSyncshells = [.. _pairManager.GroupPairs.Select(g => g.Key)];
_recentlyJoined.RemoveWhere(gid => _currentSyncshells.Any(s => string.Equals(s.GID, gid, StringComparison.Ordinal)));
if (syncshellBroadcasts.Count == 0) if (syncshellBroadcasts.Count == 0)
{ {
ClearSyncshells(); ClearSyncshells();
@@ -284,11 +292,6 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
if (updatedList != null) if (updatedList != null)
{ {
var newGids = updatedList.Select(s => s.Group.GID).ToHashSet(StringComparer.Ordinal);
if (currentGids.SetEquals(newGids))
return;
var previousGid = GetSelectedGid(); var previousGid = GetSelectedGid();
_nearbySyncshells.Clear(); _nearbySyncshells.Clear();

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace LightlessSync.UtilsEnum.Enum
{
public enum LabelAlignment
{
Left,
Center,
Right,
}
}

View File

@@ -1,4 +1,4 @@
using Dalamud.Utility; using Dalamud.Utility;
using K4os.Compression.LZ4.Legacy; using K4os.Compression.LZ4.Legacy;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Dto.Files; using LightlessSync.API.Dto.Files;
@@ -8,6 +8,7 @@ using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -19,7 +20,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private readonly FileCompactor _fileCompactor; private readonly FileCompactor _fileCompactor;
private readonly FileCacheManager _fileDbManager; private readonly FileCacheManager _fileDbManager;
private readonly FileTransferOrchestrator _orchestrator; private readonly FileTransferOrchestrator _orchestrator;
private readonly List<ThrottledStream> _activeDownloadStreams; private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
public FileDownloadManager(ILogger<FileDownloadManager> logger, LightlessMediator mediator, public FileDownloadManager(ILogger<FileDownloadManager> logger, LightlessMediator mediator,
FileTransferOrchestrator orchestrator, FileTransferOrchestrator orchestrator,
@@ -29,14 +30,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
_orchestrator = orchestrator; _orchestrator = orchestrator;
_fileDbManager = fileCacheManager; _fileDbManager = fileCacheManager;
_fileCompactor = fileCompactor; _fileCompactor = fileCompactor;
_activeDownloadStreams = []; _activeDownloadStreams = new();
Mediator.Subscribe<DownloadLimitChangedMessage>(this, (msg) => Mediator.Subscribe<DownloadLimitChangedMessage>(this, (msg) =>
{ {
if (!_activeDownloadStreams.Any()) return; if (_activeDownloadStreams.IsEmpty) return;
var newLimit = _orchestrator.DownloadLimitPerSlot(); var newLimit = _orchestrator.DownloadLimitPerSlot();
Logger.LogTrace("Setting new Download Speed Limit to {newLimit}", newLimit); Logger.LogTrace("Setting new Download Speed Limit to {newLimit}", newLimit);
foreach (var stream in _activeDownloadStreams) foreach (var stream in _activeDownloadStreams.Keys)
{ {
stream.BandwidthLimit = newLimit; stream.BandwidthLimit = newLimit;
} }
@@ -47,7 +48,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
public List<FileTransfer> ForbiddenTransfers => _orchestrator.ForbiddenTransfers; public List<FileTransfer> ForbiddenTransfers => _orchestrator.ForbiddenTransfers;
public bool IsDownloading => !CurrentDownloads.Any(); public bool IsDownloading => CurrentDownloads.Any();
public static void MungeBuffer(Span<byte> buffer) public static void MungeBuffer(Span<byte> buffer)
{ {
@@ -84,7 +85,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
ClearDownload(); ClearDownload();
foreach (var stream in _activeDownloadStreams.ToList()) foreach (var stream in _activeDownloadStreams.Keys.ToList())
{ {
try try
{ {
@@ -95,6 +96,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
// do nothing // do nothing
// //
} }
finally
{
_activeDownloadStreams.TryRemove(stream, out _);
}
} }
base.Dispose(disposing); base.Dispose(disposing);
} }
@@ -142,7 +147,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
await WaitForDownloadReady(fileTransfer, requestId, ct).ConfigureAwait(false); await WaitForDownloadReady(fileTransfer, requestId, ct).ConfigureAwait(false);
_downloadStatus[downloadGroup].DownloadStatus = DownloadStatus.Downloading; if (_downloadStatus.TryGetValue(downloadGroup, out var downloadStatus))
{
downloadStatus.DownloadStatus = DownloadStatus.Downloading;
}
else
{
Logger.LogWarning("Download status missing for {group} when starting download", downloadGroup);
}
const int maxRetries = 3; const int maxRetries = 3;
int retryCount = 0; int retryCount = 0;
@@ -204,7 +216,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
stream = new(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit); stream = new(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit);
_activeDownloadStreams.Add(stream); _activeDownloadStreams.TryAdd(stream, 0);
while ((bytesRead = await stream.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0) while ((bytesRead = await stream.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
{ {
@@ -245,7 +257,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
{ {
if (stream != null) if (stream != null)
{ {
_activeDownloadStreams.Remove(stream); _activeDownloadStreams.TryRemove(stream, out _);
await stream.DisposeAsync().ConfigureAwait(false); await stream.DisposeAsync().ConfigureAwait(false);
} }
} }
@@ -253,11 +265,28 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
public async Task<List<DownloadFileTransfer>> InitiateDownloadList(GameObjectHandler gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct) public async Task<List<DownloadFileTransfer>> InitiateDownloadList(GameObjectHandler gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct)
{ {
Logger.LogDebug("Download start: {id}", gameObjectHandler.Name); var objectName = gameObjectHandler?.Name ?? "Unknown";
Logger.LogDebug("Download start: {id}", objectName);
if (fileReplacement == null || fileReplacement.Count == 0)
{
Logger.LogDebug("{dlName}: No file replacements provided", objectName);
CurrentDownloads = [];
return CurrentDownloads;
}
var hashes = fileReplacement.Where(f => f != null && !string.IsNullOrWhiteSpace(f.Hash)).Select(f => f.Hash).Distinct(StringComparer.Ordinal).ToList();
if (hashes.Count == 0)
{
Logger.LogDebug("{dlName}: No valid hashes to download", objectName);
CurrentDownloads = [];
return CurrentDownloads;
}
List<DownloadFileDto> downloadFileInfoFromService = List<DownloadFileDto> downloadFileInfoFromService =
[ [
.. await FilesGetSizes(fileReplacement.Select(f => f.Hash).Distinct(StringComparer.Ordinal).ToList(), ct).ConfigureAwait(false), .. await FilesGetSizes(hashes, ct).ConfigureAwait(false),
]; ];
Logger.LogDebug("Files with size 0 or less: {files}", string.Join(", ", downloadFileInfoFromService.Where(f => f.Size <= 0).Select(f => f.Hash))); Logger.LogDebug("Files with size 0 or less: {files}", string.Join(", ", downloadFileInfoFromService.Where(f => f.Size <= 0).Select(f => f.Hash)));
@@ -315,16 +344,24 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
FileInfo fi = new(blockFile); FileInfo fi = new(blockFile);
try try
{ {
_downloadStatus[fileGroup.Key].DownloadStatus = DownloadStatus.WaitingForSlot; if (!_downloadStatus.TryGetValue(fileGroup.Key, out var downloadStatus))
{
Logger.LogWarning("Download status missing for {group}, aborting", fileGroup.Key);
return;
}
downloadStatus.DownloadStatus = DownloadStatus.WaitingForSlot;
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false); await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
_downloadStatus[fileGroup.Key].DownloadStatus = DownloadStatus.WaitingForQueue; downloadStatus.DownloadStatus = DownloadStatus.WaitingForQueue;
Progress<long> progress = new((bytesDownloaded) => Progress<long> progress = new((bytesDownloaded) =>
{ {
try try
{ {
if (!_downloadStatus.TryGetValue(fileGroup.Key, out FileDownloadStatus? value)) return; if (_downloadStatus.TryGetValue(fileGroup.Key, out FileDownloadStatus? value))
{
value.TransferredBytes += bytesDownloaded; value.TransferredBytes += bytesDownloaded;
} }
}
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogWarning(ex, "Could not set download progress"); Logger.LogWarning(ex, "Could not set download progress");
@@ -353,6 +390,12 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
status.TransferredFiles = 1; status.TransferredFiles = 1;
status.DownloadStatus = DownloadStatus.Decompressing; status.DownloadStatus = DownloadStatus.Decompressing;
} }
if (!File.Exists(blockFile))
{
Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name);
return;
}
fileBlockStream = File.OpenRead(blockFile); fileBlockStream = File.OpenRead(blockFile);
while (fileBlockStream.Position < fileBlockStream.Length) while (fileBlockStream.Position < fileBlockStream.Length)
{ {

View File

@@ -1,4 +1,4 @@
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Dto.Files; using LightlessSync.API.Dto.Files;
using LightlessSync.API.Routes; using LightlessSync.API.Routes;
using LightlessSync.FileCache; using LightlessSync.FileCache;
@@ -10,6 +10,8 @@ using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Collections.Concurrent;
using System.Threading;
namespace LightlessSync.WebAPI.Files; namespace LightlessSync.WebAPI.Files;
@@ -19,7 +21,9 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
private readonly LightlessConfigService _lightlessConfigService; private readonly LightlessConfigService _lightlessConfigService;
private readonly FileTransferOrchestrator _orchestrator; private readonly FileTransferOrchestrator _orchestrator;
private readonly ServerConfigurationManager _serverManager; private readonly ServerConfigurationManager _serverManager;
private readonly Dictionary<string, DateTime> _verifiedUploadedHashes = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, DateTime> _verifiedUploadedHashes = new(StringComparer.Ordinal);
private readonly object _currentUploadsLock = new();
private readonly Dictionary<string, FileTransfer> _currentUploadsByHash = new(StringComparer.Ordinal);
private CancellationTokenSource? _uploadCancellationTokenSource = new(); private CancellationTokenSource? _uploadCancellationTokenSource = new();
public FileUploadManager(ILogger<FileUploadManager> logger, LightlessMediator mediator, public FileUploadManager(ILogger<FileUploadManager> logger, LightlessMediator mediator,
@@ -40,17 +44,38 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
} }
public List<FileTransfer> CurrentUploads { get; } = []; public List<FileTransfer> CurrentUploads { get; } = [];
public bool IsUploading => CurrentUploads.Count > 0; public bool IsUploading
{
get
{
lock (_currentUploadsLock)
{
return CurrentUploads.Count > 0;
}
}
}
public List<FileTransfer> GetCurrentUploadsSnapshot()
{
lock (_currentUploadsLock)
{
return CurrentUploads.ToList();
}
}
public bool CancelUpload() public bool CancelUpload()
{ {
if (CurrentUploads.Any()) if (IsUploading)
{ {
Logger.LogDebug("Cancelling current upload"); Logger.LogDebug("Cancelling current upload");
_uploadCancellationTokenSource?.Cancel(); _uploadCancellationTokenSource?.Cancel();
_uploadCancellationTokenSource?.Dispose(); _uploadCancellationTokenSource?.Dispose();
_uploadCancellationTokenSource = null; _uploadCancellationTokenSource = null;
lock (_currentUploadsLock)
{
CurrentUploads.Clear(); CurrentUploads.Clear();
_currentUploadsByHash.Clear();
}
return true; return true;
} }
@@ -83,22 +108,44 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
return [.. filesToUpload.Where(f => f.IsForbidden).Select(f => f.Hash)]; return [.. filesToUpload.Where(f => f.IsForbidden).Select(f => f.Hash)];
} }
Task uploadTask = Task.CompletedTask; var cancellationToken = ct ?? CancellationToken.None;
var parallelUploads = Math.Clamp(_lightlessConfigService.Current.ParallelUploads, 1, 8);
using SemaphoreSlim uploadSlots = new(parallelUploads, parallelUploads);
List<Task> uploadTasks = new();
int i = 1; int i = 1;
foreach (var file in filesToUpload) foreach (var file in filesToUpload)
{ {
progress.Report($"Uploading file {i++}/{filesToUpload.Count}. Please wait until the upload is completed."); progress.Report($"Uploading file {i++}/{filesToUpload.Count}. Please wait until the upload is completed.");
Logger.LogDebug("[{hash}] Compressing", file); uploadTasks.Add(UploadSingleFileAsync(file, uploadSlots, cancellationToken));
var data = await _fileDbManager.GetCompressedFileData(file.Hash, ct ?? CancellationToken.None).ConfigureAwait(false);
Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, _fileDbManager.GetFileCacheByHash(data.Item1)!.ResolvedFilepath);
await uploadTask.ConfigureAwait(false);
uploadTask = UploadFile(data.Item2, file.Hash, postProgress: false, ct ?? CancellationToken.None);
(ct ?? CancellationToken.None).ThrowIfCancellationRequested();
} }
await uploadTask.ConfigureAwait(false); await Task.WhenAll(uploadTasks).ConfigureAwait(false);
return []; return [];
async Task UploadSingleFileAsync(UploadFileDto fileDto, SemaphoreSlim gate, CancellationToken token)
{
await gate.WaitAsync(token).ConfigureAwait(false);
try
{
token.ThrowIfCancellationRequested();
Logger.LogDebug("[{hash}] Compressing", fileDto.Hash);
var data = await _fileDbManager.GetCompressedFileData(fileDto.Hash, token).ConfigureAwait(false);
var cacheEntry = _fileDbManager.GetFileCacheByHash(data.Item1);
if (cacheEntry != null)
{
Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, cacheEntry.ResolvedFilepath);
}
await UploadFile(data.Item2, fileDto.Hash, postProgress: false, token).ConfigureAwait(false);
}
finally
{
gate.Release();
}
}
} }
public async Task<CharacterData> UploadFiles(CharacterData data, List<UserData> visiblePlayers) public async Task<CharacterData> UploadFiles(CharacterData data, List<UserData> visiblePlayers)
@@ -167,7 +214,11 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
_uploadCancellationTokenSource?.Cancel(); _uploadCancellationTokenSource?.Cancel();
_uploadCancellationTokenSource?.Dispose(); _uploadCancellationTokenSource?.Dispose();
_uploadCancellationTokenSource = null; _uploadCancellationTokenSource = null;
lock (_currentUploadsLock)
{
CurrentUploads.Clear(); CurrentUploads.Clear();
_currentUploadsByHash.Clear();
}
_verifiedUploadedHashes.Clear(); _verifiedUploadedHashes.Clear();
} }
@@ -211,7 +262,17 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
{ {
try try
{ {
CurrentUploads.Single(f => string.Equals(f.Hash, fileHash, StringComparison.Ordinal)).Transferred = prog.Uploaded; lock (_currentUploadsLock)
{
if (_currentUploadsByHash.TryGetValue(fileHash, out var transfer))
{
transfer.Transferred = prog.Uploaded;
}
else
{
Logger.LogDebug("[{hash}] Could not find upload transfer during progress update", fileHash);
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -240,10 +301,16 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
{ {
try try
{ {
CurrentUploads.Add(new UploadFileTransfer(file) var uploadTransfer = new UploadFileTransfer(file)
{ {
Total = new FileInfo(_fileDbManager.GetFileCacheByHash(file.Hash)!.ResolvedFilepath).Length, Total = new FileInfo(_fileDbManager.GetFileCacheByHash(file.Hash)!.ResolvedFilepath).Length,
}); };
lock (_currentUploadsLock)
{
CurrentUploads.Add(uploadTransfer);
_currentUploadsByHash[file.Hash] = uploadTransfer;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -264,33 +331,75 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase
_verifiedUploadedHashes[file.Hash] = DateTime.UtcNow; _verifiedUploadedHashes[file.Hash] = DateTime.UtcNow;
} }
var totalSize = CurrentUploads.Sum(c => c.Total); long totalSize;
List<FileTransfer> pendingUploads;
lock (_currentUploadsLock)
{
totalSize = CurrentUploads.Sum(c => c.Total);
pendingUploads = CurrentUploads.Where(f => f.CanBeTransferred && !f.IsTransferred).ToList();
}
var parallelUploads = Math.Clamp(_lightlessConfigService.Current.ParallelUploads, 1, 8);
using SemaphoreSlim uploadSlots = new(parallelUploads, parallelUploads);
Logger.LogDebug("Compressing and uploading files"); Logger.LogDebug("Compressing and uploading files");
Task uploadTask = Task.CompletedTask; List<Task> uploadTasks = new();
foreach (var file in CurrentUploads.Where(f => f.CanBeTransferred && !f.IsTransferred).ToList())
foreach (var transfer in pendingUploads)
{ {
Logger.LogDebug("[{hash}] Compressing", file); uploadTasks.Add(UploadPendingFileAsync(transfer, uploadSlots, uploadToken));
var data = await _fileDbManager.GetCompressedFileData(file.Hash, uploadToken).ConfigureAwait(false);
CurrentUploads.Single(e => string.Equals(e.Hash, data.Item1, StringComparison.Ordinal)).Total = data.Item2.Length;
Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, _fileDbManager.GetFileCacheByHash(data.Item1)!.ResolvedFilepath);
await uploadTask.ConfigureAwait(false);
uploadTask = UploadFile(data.Item2, file.Hash, true, uploadToken);
uploadToken.ThrowIfCancellationRequested();
} }
if (CurrentUploads.Any()) await Task.WhenAll(uploadTasks).ConfigureAwait(false);
{
await uploadTask.ConfigureAwait(false); long compressedSize;
HashSet<string> uploadedHashes;
lock (_currentUploadsLock)
{
compressedSize = CurrentUploads.Sum(c => c.Total);
uploadedHashes = CurrentUploads.Select(u => u.Hash).ToHashSet(StringComparer.Ordinal);
}
var compressedSize = CurrentUploads.Sum(c => c.Total);
Logger.LogDebug("Upload complete, compressed {size} to {compressed}", UiSharedService.ByteToString(totalSize), UiSharedService.ByteToString(compressedSize)); Logger.LogDebug("Upload complete, compressed {size} to {compressed}", UiSharedService.ByteToString(totalSize), UiSharedService.ByteToString(compressedSize));
}
foreach (var file in unverifiedUploadHashes.Where(c => !CurrentUploads.Exists(u => string.Equals(u.Hash, c, StringComparison.Ordinal)))) foreach (var file in unverifiedUploadHashes.Where(c => !uploadedHashes.Contains(c)))
{ {
_verifiedUploadedHashes[file] = DateTime.UtcNow; _verifiedUploadedHashes[file] = DateTime.UtcNow;
} }
lock (_currentUploadsLock)
{
CurrentUploads.Clear(); CurrentUploads.Clear();
_currentUploadsByHash.Clear();
}
async Task UploadPendingFileAsync(FileTransfer transfer, SemaphoreSlim gate, CancellationToken token)
{
await gate.WaitAsync(token).ConfigureAwait(false);
try
{
token.ThrowIfCancellationRequested();
Logger.LogDebug("[{hash}] Compressing", transfer.Hash);
var data = await _fileDbManager.GetCompressedFileData(transfer.Hash, token).ConfigureAwait(false);
lock (_currentUploadsLock)
{
if (_currentUploadsByHash.TryGetValue(data.Item1, out var trackedUpload))
{
trackedUpload.Total = data.Item2.Length;
}
}
var cacheEntry = _fileDbManager.GetFileCacheByHash(data.Item1);
if (cacheEntry != null)
{
Logger.LogDebug("[{hash}] Starting upload for {filePath}", data.Item1, cacheEntry.ResolvedFilepath);
}
await UploadFile(data.Item2, transfer.Hash, true, token).ConfigureAwait(false);
}
finally
{
gate.Release();
}
}
} }
} }