Merge remote-tracking branch 'origin/patch-notes' into patch-notes

This commit is contained in:
choco
2025-10-19 22:43:58 +02:00
28 changed files with 1129 additions and 184 deletions

View File

@@ -27,6 +27,7 @@ public sealed class FileCacheManager : IHostedService
private readonly Lock _fileWriteLock = new(); private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager; private readonly IpcManager _ipcManager;
private readonly ILogger<FileCacheManager> _logger; private readonly ILogger<FileCacheManager> _logger;
private bool _csvHeaderEnsured;
public string CacheFolder => _configService.Current.CacheFolder; public string CacheFolder => _configService.Current.CacheFolder;
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator) public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
@@ -462,6 +463,7 @@ public sealed class FileCacheManager : IHostedService
string[] existingLines = File.ReadAllLines(_csvPath); string[] existingLines = File.ReadAllLines(_csvPath);
if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion) if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion)
{ {
_csvHeaderEnsured = true;
return; return;
} }
@@ -481,6 +483,18 @@ public sealed class FileCacheManager : IHostedService
} }
File.WriteAllText(_csvPath, rebuilt.ToString()); File.WriteAllText(_csvPath, rebuilt.ToString());
_csvHeaderEnsured = true;
}
private void EnsureCsvHeaderLockedCached()
{
if (_csvHeaderEnsured)
{
return;
}
EnsureCsvHeaderLocked();
_csvHeaderEnsured = true;
} }
private void BackupUnsupportedCache(string suffix) private void BackupUnsupportedCache(string suffix)
@@ -540,10 +554,11 @@ public sealed class FileCacheManager : IHostedService
if (!File.Exists(_csvPath)) if (!File.Exists(_csvPath))
{ {
File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry }); File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry });
_csvHeaderEnsured = true;
} }
else else
{ {
EnsureCsvHeaderLocked(); EnsureCsvHeaderLockedCached();
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry }); File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
} }
} }

View File

@@ -2,25 +2,33 @@
using LightlessSync.Services; using LightlessSync.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace LightlessSync.FileCache; namespace LightlessSync.FileCache;
public sealed class FileCompactor public sealed class FileCompactor : IDisposable
{ {
public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U; public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U;
public const ulong WOF_PROVIDER_FILE = 2UL; public const ulong WOF_PROVIDER_FILE = 2UL;
private readonly Dictionary<string, int> _clusterSizes; private readonly Dictionary<string, int> _clusterSizes;
private readonly ConcurrentDictionary<string, byte> _pendingCompactions;
private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo; private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo;
private readonly ILogger<FileCompactor> _logger; private readonly ILogger<FileCompactor> _logger;
private readonly LightlessConfigService _lightlessConfigService; private readonly LightlessConfigService _lightlessConfigService;
private readonly DalamudUtilService _dalamudUtilService; private readonly DalamudUtilService _dalamudUtilService;
private readonly Channel<string> _compactionQueue;
private readonly CancellationTokenSource _compactionCts = new();
private readonly Task _compactionWorker;
public FileCompactor(ILogger<FileCompactor> logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService) public FileCompactor(ILogger<FileCompactor> logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService)
{ {
_clusterSizes = new(StringComparer.Ordinal); _clusterSizes = new(StringComparer.Ordinal);
_pendingCompactions = new(StringComparer.OrdinalIgnoreCase);
_logger = logger; _logger = logger;
_lightlessConfigService = lightlessConfigService; _lightlessConfigService = lightlessConfigService;
_dalamudUtilService = dalamudUtilService; _dalamudUtilService = dalamudUtilService;
@@ -29,6 +37,18 @@ public sealed class FileCompactor
Algorithm = CompressionAlgorithm.XPRESS8K, Algorithm = CompressionAlgorithm.XPRESS8K,
Flags = 0 Flags = 0
}; };
_compactionQueue = Channel.CreateUnbounded<string>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
_compactionWorker = Task.Factory.StartNew(
() => ProcessQueueAsync(_compactionCts.Token),
_compactionCts.Token,
TaskCreationOptions.LongRunning,
TaskScheduler.Default)
.Unwrap();
} }
private enum CompressionAlgorithm private enum CompressionAlgorithm
@@ -87,7 +107,30 @@ public sealed class FileCompactor
return; return;
} }
CompactFile(filePath); EnqueueCompaction(filePath);
}
public void Dispose()
{
_compactionQueue.Writer.TryComplete();
_compactionCts.Cancel();
try
{
if (!_compactionWorker.Wait(TimeSpan.FromSeconds(5)))
{
_logger.LogDebug("Compaction worker did not shut down within timeout");
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Error shutting down compaction worker");
}
finally
{
_compactionCts.Dispose();
}
GC.SuppressFinalize(this);
} }
[DllImport("kernel32.dll")] [DllImport("kernel32.dll")]
@@ -226,4 +269,67 @@ public sealed class FileCompactor
public CompressionAlgorithm Algorithm; public CompressionAlgorithm Algorithm;
public ulong Flags; public ulong Flags;
} }
}
private void EnqueueCompaction(string filePath)
{
if (!_pendingCompactions.TryAdd(filePath, 0))
{
return;
}
if (!_compactionQueue.Writer.TryWrite(filePath))
{
_pendingCompactions.TryRemove(filePath, out _);
_logger.LogDebug("Failed to enqueue compaction job for {file}", filePath);
}
}
private async Task ProcessQueueAsync(CancellationToken token)
{
try
{
while (await _compactionQueue.Reader.WaitToReadAsync(token).ConfigureAwait(false))
{
while (_compactionQueue.Reader.TryRead(out var filePath))
{
try
{
if (token.IsCancellationRequested)
{
return;
}
if (_dalamudUtilService.IsWine || !_lightlessConfigService.Current.UseCompactor)
{
continue;
}
if (!File.Exists(filePath))
{
_logger.LogTrace("Skipping compaction for missing file {file}", filePath);
continue;
}
CompactFile(filePath);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error compacting file {file}", filePath);
}
finally
{
_pendingCompactions.TryRemove(filePath, out _);
}
}
}
}
catch (OperationCanceledException)
{
// expected during shutdown
}
}
}

View File

@@ -67,6 +67,7 @@ public class LightlessConfig : ILightlessConfiguration
public bool ShowUploading { get; set; } = true; public bool ShowUploading { get; set; } = true;
public bool ShowUploadingBigText { get; set; } = true; public bool ShowUploadingBigText { get; set; } = true;
public bool ShowVisibleUsersSeparately { get; set; } = true; public bool ShowVisibleUsersSeparately { get; set; } = true;
public bool EnableDirectDownloads { get; set; } = true;
public int TimeSpanBetweenScansInSeconds { get; set; } = 30; public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
public int TransferBarsHeight { get; set; } = 12; public int TransferBarsHeight { get; set; } = 12;
public bool TransferBarsShowText { get; set; } = true; public bool TransferBarsShowText { get; set; } = true;

View File

@@ -1,4 +1,6 @@
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -10,21 +12,38 @@ public class FileDownloadManagerFactory
private readonly FileCacheManager _fileCacheManager; private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor; private readonly FileCompactor _fileCompactor;
private readonly FileTransferOrchestrator _fileTransferOrchestrator; private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly LightlessConfigService _configService;
public FileDownloadManagerFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, FileTransferOrchestrator fileTransferOrchestrator, public FileDownloadManagerFactory(
FileCacheManager fileCacheManager, FileCompactor fileCompactor) ILoggerFactory loggerFactory,
LightlessMediator lightlessMediator,
FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager,
FileCompactor fileCompactor,
PairProcessingLimiter pairProcessingLimiter,
LightlessConfigService configService)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
_fileTransferOrchestrator = fileTransferOrchestrator; _fileTransferOrchestrator = fileTransferOrchestrator;
_fileCacheManager = fileCacheManager; _fileCacheManager = fileCacheManager;
_fileCompactor = fileCompactor; _fileCompactor = fileCompactor;
_pairProcessingLimiter = pairProcessingLimiter;
_configService = configService;
} }
public FileDownloadManager Create() public FileDownloadManager Create()
{ {
return new FileDownloadManager(_loggerFactory.CreateLogger<FileDownloadManager>(), _lightlessMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor); return new FileDownloadManager(
_loggerFactory.CreateLogger<FileDownloadManager>(),
_lightlessMediator,
_fileTransferOrchestrator,
_fileCacheManager,
_fileCompactor,
_pairProcessingLimiter,
_configService);
} }
} }

View File

@@ -138,7 +138,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
{ {
if (_allClientPairs.TryGetValue(user, out var pair)) if (_allClientPairs.TryGetValue(user, out var pair))
{ {
Mediator.Publish(new ClearProfileDataMessage(pair.UserData)); Mediator.Publish(new ClearProfileUserDataMessage(pair.UserData));
pair.MarkOffline(); pair.MarkOffline();
} }
@@ -149,7 +149,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
{ {
if (!_allClientPairs.ContainsKey(dto.User)) throw new InvalidOperationException("No user found for " + dto); if (!_allClientPairs.ContainsKey(dto.User)) throw new InvalidOperationException("No user found for " + dto);
Mediator.Publish(new ClearProfileDataMessage(dto.User)); Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
var pair = _allClientPairs[dto.User]; var pair = _allClientPairs[dto.User];
if (pair.HasCachedPlayer) if (pair.HasCachedPlayer)
@@ -254,7 +254,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
if (pair.UserPair.OtherPermissions.IsPaused() != dto.Permissions.IsPaused()) if (pair.UserPair.OtherPermissions.IsPaused() != dto.Permissions.IsPaused())
{ {
Mediator.Publish(new ClearProfileDataMessage(dto.User)); Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
} }
pair.UserPair.OtherPermissions = dto.Permissions; pair.UserPair.OtherPermissions = dto.Permissions;
@@ -280,7 +280,7 @@ public sealed class PairManager : DisposableMediatorSubscriberBase
if (pair.UserPair.OwnPermissions.IsPaused() != dto.Permissions.IsPaused()) if (pair.UserPair.OwnPermissions.IsPaused() != dto.Permissions.IsPaused())
{ {
Mediator.Publish(new ClearProfileDataMessage(dto.User)); Mediator.Publish(new ClearProfileUserDataMessage(dto.User));
} }
pair.UserPair.OwnPermissions = dto.Permissions; pair.UserPair.OwnPermissions = dto.Permissions;

View File

@@ -0,0 +1,6 @@
namespace LightlessSync.Services;
public record LightlessGroupProfileData(string Base64ProfilePicture, string Description, int[] Tags, bool IsNsfw, bool IsDisabled)
{
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
}

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
namespace LightlessSync.Services; namespace LightlessSync.Services;
public record LightlessProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description) public record LightlessUserProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
{ {
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture)); public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
public Lazy<byte[]> SupporterImageData { get; } = new Lazy<byte[]>(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture)); public Lazy<byte[]> SupporterImageData { get; } = new Lazy<byte[]>(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture));

View File

@@ -70,7 +70,8 @@ public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<st
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase; public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
public record UiToggleMessage(Type UiType) : MessageBase; public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase; public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
public record ClearProfileDataMessage(UserData? UserData = null) : MessageBase; public record ClearProfileUserDataMessage(UserData? UserData = null) : MessageBase;
public record ClearProfileGroupDataMessage(GroupData? GroupData = null) : MessageBase;
public record CyclePauseMessage(UserData UserData) : MessageBase; public record CyclePauseMessage(UserData UserData) : MessageBase;
public record PauseMessage(UserData UserData) : MessageBase; public record PauseMessage(UserData UserData) : MessageBase;
public record ProfilePopoutToggle(Pair? Pair) : MessageBase; public record ProfilePopoutToggle(Pair? Pair) : MessageBase;

View File

@@ -208,7 +208,13 @@ 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; if (ui3DModule->NamePlateObjectInfoPointers.IsEmpty) continue;
var objectInfoPtr = ui3DModule->NamePlateObjectInfoPointers[i];
if (objectInfoPtr == null) continue;
var objectInfo = objectInfoPtr.Value;
if (objectInfo == null || objectInfo->GameObject == null) if (objectInfo == null || objectInfo->GameObject == null)
continue; continue;

View File

@@ -15,6 +15,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
private readonly SemaphoreSlim _semaphore; private readonly SemaphoreSlim _semaphore;
private int _currentLimit; private int _currentLimit;
private int _pendingReductions; private int _pendingReductions;
private int _pendingIncrements;
private int _waiting; private int _waiting;
private int _inFlight; private int _inFlight;
@@ -70,7 +71,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (!IsEnabled) if (!IsEnabled)
{ {
_semaphore.Release(); TryReleaseSemaphore();
return NoopReleaser.Instance; return NoopReleaser.Instance;
} }
@@ -90,18 +91,12 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
var releaseAmount = HardLimit - _semaphore.CurrentCount; var releaseAmount = HardLimit - _semaphore.CurrentCount;
if (releaseAmount > 0) if (releaseAmount > 0)
{ {
try TryReleaseSemaphore(releaseAmount);
{
_semaphore.Release(releaseAmount);
}
catch (SemaphoreFullException)
{
// ignore, already at max
}
} }
_currentLimit = desiredLimit; _currentLimit = desiredLimit;
_pendingReductions = 0; _pendingReductions = 0;
_pendingIncrements = 0;
return; return;
} }
@@ -113,10 +108,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (desiredLimit > _currentLimit) if (desiredLimit > _currentLimit)
{ {
var increment = desiredLimit - _currentLimit; var increment = desiredLimit - _currentLimit;
var allowed = Math.Min(increment, HardLimit - _semaphore.CurrentCount); _pendingIncrements += increment;
if (allowed > 0)
var available = HardLimit - _semaphore.CurrentCount;
var toRelease = Math.Min(_pendingIncrements, available);
if (toRelease > 0 && TryReleaseSemaphore(toRelease))
{ {
_semaphore.Release(allowed); _pendingIncrements -= toRelease;
} }
} }
else else
@@ -133,6 +131,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{ {
_pendingReductions += remaining; _pendingReductions += remaining;
} }
if (_pendingIncrements > 0)
{
var offset = Math.Min(_pendingIncrements, _pendingReductions);
_pendingIncrements -= offset;
_pendingReductions -= offset;
}
} }
_currentLimit = desiredLimit; _currentLimit = desiredLimit;
@@ -146,6 +151,25 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
return Math.Clamp(configured, 1, HardLimit); return Math.Clamp(configured, 1, HardLimit);
} }
private bool TryReleaseSemaphore(int count = 1)
{
if (count <= 0)
{
return true;
}
try
{
_semaphore.Release(count);
return true;
}
catch (SemaphoreFullException ex)
{
Logger.LogDebug(ex, "Attempted to release {count} pair processing slots but semaphore is already at the hard limit.", count);
return false;
}
}
private void ReleaseOne() private void ReleaseOne()
{ {
var inFlight = Interlocked.Decrement(ref _inFlight); var inFlight = Interlocked.Decrement(ref _inFlight);
@@ -166,9 +190,20 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
_pendingReductions--; _pendingReductions--;
return; return;
} }
if (_pendingIncrements > 0)
{
if (!TryReleaseSemaphore())
{
return;
}
_pendingIncrements--;
return;
}
} }
_semaphore.Release(); TryReleaseSemaphore();
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)

View File

@@ -1,4 +1,5 @@
using LightlessSync.API.Dto.Group; using Dalamud.Interface.ImGuiFileDialog;
using LightlessSync.API.Dto.Group;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
@@ -18,10 +19,11 @@ public class UiFactory
private readonly ServerConfigurationManager _serverConfigManager; private readonly ServerConfigurationManager _serverConfigManager;
private readonly LightlessProfileManager _lightlessProfileManager; private readonly LightlessProfileManager _lightlessProfileManager;
private readonly PerformanceCollectorService _performanceCollectorService; private readonly PerformanceCollectorService _performanceCollectorService;
private readonly FileDialogManager _fileDialogManager;
public UiFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, ApiController apiController, public UiFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager, UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager,
LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService) LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, FileDialogManager fileDialogManager)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
@@ -31,12 +33,13 @@ public class UiFactory
_serverConfigManager = serverConfigManager; _serverConfigManager = serverConfigManager;
_lightlessProfileManager = lightlessProfileManager; _lightlessProfileManager = lightlessProfileManager;
_performanceCollectorService = performanceCollectorService; _performanceCollectorService = performanceCollectorService;
_fileDialogManager = fileDialogManager;
} }
public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto)
{ {
return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _lightlessMediator, return new SyncshellAdminUI(_loggerFactory.CreateLogger<SyncshellAdminUI>(), _lightlessMediator,
_apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService); _apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService, _lightlessProfileManager, _fileDialogManager);
} }
public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair)

View File

@@ -63,7 +63,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
Mediator.Subscribe<GposeStartMessage>(this, (_) => { _wasOpen = IsOpen; IsOpen = false; }); Mediator.Subscribe<GposeStartMessage>(this, (_) => { _wasOpen = IsOpen; IsOpen = false; });
Mediator.Subscribe<GposeEndMessage>(this, (_) => IsOpen = _wasOpen); Mediator.Subscribe<GposeEndMessage>(this, (_) => IsOpen = _wasOpen);
Mediator.Subscribe<DisconnectedMessage>(this, (_) => IsOpen = false); Mediator.Subscribe<DisconnectedMessage>(this, (_) => IsOpen = false);
Mediator.Subscribe<ClearProfileDataMessage>(this, (msg) => Mediator.Subscribe<ClearProfileUserDataMessage>(this, (msg) =>
{ {
if (msg.UserData == null || string.Equals(msg.UserData.UID, _apiController.UID, StringComparison.Ordinal)) if (msg.UserData == null || string.Equals(msg.UserData.UID, _apiController.UID, StringComparison.Ordinal))
{ {
@@ -91,6 +91,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
protected override void DrawInternal() protected override void DrawInternal()
{ {
_uiSharedService.UnderlinedBigText("Notes and Rules for Profiles", UIColors.Get("LightlessYellow")); _uiSharedService.UnderlinedBigText("Notes and Rules for Profiles", UIColors.Get("LightlessYellow"));
ImGui.Dummy(new Vector2(5)); ImGui.Dummy(new Vector2(5));
@@ -108,7 +109,8 @@ public class EditProfileUi : WindowMediatorSubscriberBase
ImGui.Dummy(new Vector2(3)); ImGui.Dummy(new Vector2(3));
var profile = _lightlessProfileManager.GetLightlessProfile(new UserData(_apiController.UID)); var profile = _lightlessProfileManager.GetLightlessUserProfile(new UserData(_apiController.UID));
_logger.LogInformation("Profile fetched for drawing: {profile}", profile);
if (ImGui.BeginTabBar("##EditProfileTabs")) if (ImGui.BeginTabBar("##EditProfileTabs"))
{ {
@@ -204,7 +206,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
} }
_showFileDialogError = false; _showFileDialogError = false;
await _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, Convert.ToBase64String(fileContent), Description: null)) await _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, Convert.ToBase64String(fileContent), Description: null, Tags: null))
.ConfigureAwait(false); .ConfigureAwait(false);
}); });
}); });
@@ -213,7 +215,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
ImGui.SameLine(); ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture")) if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture"))
{ {
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, "", Description: null)); _ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, "", Description: null, Tags: null));
} }
UiSharedService.AttachToolTip("Clear your currently uploaded profile picture"); UiSharedService.AttachToolTip("Clear your currently uploaded profile picture");
if (_showFileDialogError) if (_showFileDialogError)
@@ -223,7 +225,7 @@ public class EditProfileUi : WindowMediatorSubscriberBase
var isNsfw = profile.IsNSFW; var isNsfw = profile.IsNSFW;
if (ImGui.Checkbox("Profile is NSFW", ref isNsfw)) if (ImGui.Checkbox("Profile is NSFW", ref isNsfw))
{ {
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, isNsfw, ProfilePictureBase64: null, Description: null)); _ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, isNsfw, ProfilePictureBase64: null, Description: null, Tags: null));
} }
_uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON"); _uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
var widthTextBox = 400; var widthTextBox = 400;
@@ -262,13 +264,13 @@ public class EditProfileUi : WindowMediatorSubscriberBase
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description")) if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description"))
{ {
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, _descriptionText)); _ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, _descriptionText, Tags: null));
} }
UiSharedService.AttachToolTip("Sets your profile description text"); UiSharedService.AttachToolTip("Sets your profile description text");
ImGui.SameLine(); ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description")) if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
{ {
_ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, "")); _ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, "", Tags: null));
} }
UiSharedService.AttachToolTip("Clears your profile description text"); UiSharedService.AttachToolTip("Clears your profile description text");

View File

@@ -85,7 +85,7 @@ public class PopoutProfileUi : WindowMediatorSubscriberBase
{ {
var spacing = ImGui.GetStyle().ItemSpacing; var spacing = ImGui.GetStyle().ItemSpacing;
var lightlessProfile = _lightlessProfileManager.GetLightlessProfile(_pair.UserData); var lightlessProfile = _lightlessProfileManager.GetLightlessUserProfile(_pair.UserData);
if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture)) if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture))
{ {

View File

@@ -0,0 +1,12 @@
namespace LightlessSync.UI
{
public enum ProfileTags
{
SFW = 0,
NSFW = 1,
RP = 2,
ERP = 3,
Venues = 4,
Gpose = 5
}
}

View File

@@ -610,6 +610,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
bool limitPairApplications = _configService.Current.EnablePairProcessingLimiter; 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;
bool enableDirectDownloads = _configService.Current.EnableDirectDownloads;
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("Global Download Speed Limit"); ImGui.TextUnformatted("Global Download Speed Limit");
@@ -641,6 +642,13 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("0 = No limit/infinite"); ImGui.TextUnformatted("0 = No limit/infinite");
if (ImGui.Checkbox("[BETA] Enable Lightspeed Downloads", ref enableDirectDownloads))
{
_configService.Current.EnableDirectDownloads = enableDirectDownloads;
_configService.Save();
}
_uiShared.DrawHelpText("Uses signed CDN links when available. Disable to force the legacy queued download flow.");
if (ImGui.SliderInt("Maximum Parallel Downloads", ref maxParallelDownloads, 1, 10)) if (ImGui.SliderInt("Maximum Parallel Downloads", ref maxParallelDownloads, 1, 10))
{ {
_configService.Current.ParallelDownloads = maxParallelDownloads; _configService.Current.ParallelDownloads = maxParallelDownloads;
@@ -2313,7 +2321,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
{ {
if (ImGui.Checkbox("Show Lightless Profiles on Hover", ref showProfiles)) if (ImGui.Checkbox("Show Lightless Profiles on Hover", ref showProfiles))
{ {
Mediator.Publish(new ClearProfileDataMessage()); Mediator.Publish(new ClearProfileUserDataMessage());
_configService.Current.ProfilesShow = showProfiles; _configService.Current.ProfilesShow = showProfiles;
_configService.Save(); _configService.Save();
} }
@@ -2340,7 +2348,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Unindent(); ImGui.Unindent();
if (ImGui.Checkbox("Show profiles marked as NSFW", ref showNsfwProfiles)) if (ImGui.Checkbox("Show profiles marked as NSFW", ref showNsfwProfiles))
{ {
Mediator.Publish(new ClearProfileDataMessage()); Mediator.Publish(new ClearProfileUserDataMessage());
_configService.Current.ProfilesAllowNsfw = showNsfwProfiles; _configService.Current.ProfilesAllowNsfw = showNsfwProfiles;
_configService.Save(); _configService.Save();
} }

View File

@@ -51,7 +51,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase
{ {
var spacing = ImGui.GetStyle().ItemSpacing; var spacing = ImGui.GetStyle().ItemSpacing;
var lightlessProfile = _lightlessProfileManager.GetLightlessProfile(Pair.UserData); var lightlessProfile = _lightlessProfileManager.GetLightlessUserProfile(Pair.UserData);
if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture)) if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture))
{ {

View File

@@ -1,17 +1,26 @@
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Colors; using Dalamud.Interface.Colors;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Textures.TextureWraps;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions; using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.UI.Handlers;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Globalization; using System.Globalization;
using System.Linq;
using System.Numerics;
namespace LightlessSync.UI; namespace LightlessSync.UI;
@@ -22,29 +31,51 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
private readonly bool _isOwner = false; private readonly bool _isOwner = false;
private readonly List<string> _oneTimeInvites = []; private readonly List<string> _oneTimeInvites = [];
private readonly PairManager _pairManager; private readonly PairManager _pairManager;
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly FileDialogManager _fileDialogManager;
private readonly UiSharedService _uiSharedService; private readonly UiSharedService _uiSharedService;
private List<BannedGroupUserDto> _bannedUsers = []; private List<BannedGroupUserDto> _bannedUsers = [];
private LightlessGroupProfileData? _profileData = null;
private bool _adjustedForScollBarsLocalProfile = false;
private bool _adjustedForScollBarsOnlineProfile = false;
private string _descriptionText = string.Empty;
private IDalamudTextureWrap? _pfpTextureWrap;
private string _profileDescription = string.Empty;
private byte[] _profileImage = [];
private bool _showFileDialogError = false;
private int _multiInvites; private int _multiInvites;
private string _newPassword; private string _newPassword;
private bool _pwChangeSuccess; private bool _pwChangeSuccess;
private Task<int>? _pruneTestTask; private Task<int>? _pruneTestTask;
private Task<int>? _pruneTask; private Task<int>? _pruneTask;
private int _pruneDays = 14; private int _pruneDays = 14;
private List<int> _selectedTags = [];
public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, LightlessMediator mediator, ApiController apiController, public SyncshellAdminUI(ILogger<SyncshellAdminUI> logger, LightlessMediator mediator, ApiController apiController,
UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService) UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, FileDialogManager fileDialogManager)
: base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService)
{ {
GroupFullInfo = groupFullInfo; GroupFullInfo = groupFullInfo;
_apiController = apiController; _apiController = apiController;
_uiSharedService = uiSharedService; _uiSharedService = uiSharedService;
_pairManager = pairManager; _pairManager = pairManager;
_lightlessProfileManager = lightlessProfileManager;
_fileDialogManager = fileDialogManager;
_isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal); _isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal);
_isModerator = GroupFullInfo.GroupUserInfo.IsModerator(); _isModerator = GroupFullInfo.GroupUserInfo.IsModerator();
_newPassword = string.Empty; _newPassword = string.Empty;
_multiInvites = 30; _multiInvites = 30;
_pwChangeSuccess = true; _pwChangeSuccess = true;
IsOpen = true; IsOpen = true;
Mediator.Subscribe<ClearProfileGroupDataMessage>(this, (msg) =>
{
if (msg.GroupData == null || string.Equals(msg.GroupData.AliasOrGID, GroupFullInfo.Group.AliasOrGID, StringComparison.Ordinal))
{
_pfpTextureWrap?.Dispose();
_pfpTextureWrap = null;
}
});
SizeConstraints = new WindowSizeConstraints() SizeConstraints = new WindowSizeConstraints()
{ {
MinimumSize = new(700, 500), MinimumSize = new(700, 500),
@@ -58,10 +89,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
{ {
if (!_isModerator && !_isOwner) return; if (!_isModerator && !_isOwner) return;
_logger.LogTrace("Drawing Syncshell Admin UI for {group}", GroupFullInfo.GroupAliasOrGID);
GroupFullInfo = _pairManager.Groups[GroupFullInfo.Group]; GroupFullInfo = _pairManager.Groups[GroupFullInfo.Group];
using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID); _profileData = _lightlessProfileManager.GetLightlessGroupProfile(GroupFullInfo.Group);
GetTagsFromProfile();
using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID);
using (_uiSharedService.UidFont.Push()) using (_uiSharedService.UidFont.Push())
_uiSharedService.UnderlinedBigText(GroupFullInfo.GroupAliasOrGID + " Administrative Panel", UIColors.Get("LightlessBlue")); _uiSharedService.UnderlinedBigText(GroupFullInfo.GroupAliasOrGID + " Administrative Panel", UIColors.Get("LightlessBlue"));
@@ -69,14 +103,16 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
var perm = GroupFullInfo.GroupPermissions; var perm = GroupFullInfo.GroupPermissions;
using var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID); using var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID);
if (tabbar) if (tabbar)
{ {
DrawInvites(perm); DrawInvites(perm);
DrawManagement(); DrawManagement();
DrawPermission(perm); DrawPermission(perm);
DrawProfile();
} }
} }
@@ -176,6 +212,184 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
ownerTab.Dispose(); ownerTab.Dispose();
} }
} }
private void DrawProfile()
{
var profileTab = ImRaii.TabItem("Profile");
if (profileTab)
{
if (_uiSharedService.MediumTreeNode("Current Profile", UIColors.Get("LightlessPurple")))
{
ImGui.Dummy(new Vector2(5));
if (!_profileImage.SequenceEqual(_profileData.ImageData.Value))
{
_profileImage = _profileData.ImageData.Value;
_pfpTextureWrap?.Dispose();
_pfpTextureWrap = _uiSharedService.LoadImage(_profileImage);
}
if (!string.Equals(_profileDescription, _profileData.Description, StringComparison.OrdinalIgnoreCase))
{
_profileDescription = _profileData.Description;
_descriptionText = _profileDescription;
}
if (_pfpTextureWrap != null)
{
ImGui.Image(_pfpTextureWrap.Handle, ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height));
}
var spacing = ImGui.GetStyle().ItemSpacing.X;
ImGuiHelpers.ScaledRelativeSameLine(256, spacing);
using (_uiSharedService.GameFont.Push())
{
var descriptionTextSize = ImGui.CalcTextSize(_profileData.Description, wrapWidth: 256f);
var childFrame = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 256);
if (descriptionTextSize.Y > childFrame.Y)
{
_adjustedForScollBarsOnlineProfile = true;
}
else
{
_adjustedForScollBarsOnlineProfile = false;
}
childFrame = childFrame with
{
X = childFrame.X + (_adjustedForScollBarsOnlineProfile ? ImGui.GetStyle().ScrollbarSize : 0),
};
if (ImGui.BeginChildFrame(101, childFrame))
{
UiSharedService.TextWrapped(_profileData.Description);
}
ImGui.EndChildFrame();
ImGui.TreePop();
}
var nsfw = _profileData.IsNsfw;
ImGui.BeginDisabled();
ImGui.Checkbox("Is NSFW", ref nsfw);
ImGui.EndDisabled();
}
ImGui.Separator();
if (_uiSharedService.MediumTreeNode("Profile Settings", UIColors.Get("LightlessPurple")))
{
ImGui.Dummy(new Vector2(5));
ImGui.TextUnformatted($"Profile Picture:");
if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture"))
{
_fileDialogManager.OpenFileDialog("Select new Profile picture", ".png", (success, file) =>
{
if (!success) return;
_ = Task.Run(async () =>
{
var fileContent = await File.ReadAllBytesAsync(file).ConfigureAwait(false);
MemoryStream ms = new(fileContent);
await using (ms.ConfigureAwait(false))
{
var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false);
if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase))
{
_showFileDialogError = true;
return;
}
using var image = Image.Load<Rgba32>(fileContent);
if (image.Width > 512 || image.Height > 512 || (fileContent.Length > 2000 * 1024))
{
_showFileDialogError = true;
return;
}
_showFileDialogError = false;
await _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, Convert.ToBase64String(fileContent), IsNsfw: null, IsDisabled: null))
.ConfigureAwait(false);
}
});
});
}
UiSharedService.AttachToolTip("Select and upload a new profile picture");
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture"))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, IsNsfw: null, IsDisabled: null));
}
UiSharedService.AttachToolTip("Clear your currently uploaded profile picture");
if (_showFileDialogError)
{
UiSharedService.ColorTextWrapped("The profile picture must be a PNG file with a maximum height and width of 256px and 250KiB size", ImGuiColors.DalamudRed);
}
ImGui.Separator();
ImGui.TextUnformatted($"Tags:");
var childFrameLocal = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 200);
var allCategoryIndexes = Enum.GetValues<ProfileTags>()
.Cast<int>()
.ToList();
foreach(int tag in allCategoryIndexes)
{
using (ImRaii.PushId($"tag-{tag}")) DrawTag(tag);
}
ImGui.Separator();
var widthTextBox = 400;
var posX = ImGui.GetCursorPosX();
ImGui.TextUnformatted($"Description {_descriptionText.Length}/1500");
ImGui.SetCursorPosX(posX);
ImGuiHelpers.ScaledRelativeSameLine(widthTextBox, ImGui.GetStyle().ItemSpacing.X);
ImGui.TextUnformatted("Preview (approximate)");
using (_uiSharedService.GameFont.Push())
ImGui.InputTextMultiline("##description", ref _descriptionText, 1500, ImGuiHelpers.ScaledVector2(widthTextBox, 200));
ImGui.SameLine();
using (_uiSharedService.GameFont.Push())
{
var descriptionTextSizeLocal = ImGui.CalcTextSize(_descriptionText, wrapWidth: 256f);
if (descriptionTextSizeLocal.Y > childFrameLocal.Y)
{
_adjustedForScollBarsLocalProfile = true;
}
else
{
_adjustedForScollBarsLocalProfile = false;
}
childFrameLocal = childFrameLocal with
{
X = childFrameLocal.X + (_adjustedForScollBarsLocalProfile ? ImGui.GetStyle().ScrollbarSize : 0),
};
if (ImGui.BeginChildFrame(102, childFrameLocal))
{
UiSharedService.TextWrapped(_descriptionText);
}
ImGui.EndChildFrame();
}
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description"))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: _descriptionText, Tags: null, PictureBase64: null, IsNsfw: null, IsDisabled: null));
}
UiSharedService.AttachToolTip("Sets your profile description text");
ImGui.SameLine();
if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description"))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, IsNsfw: null, IsDisabled: null));
}
UiSharedService.AttachToolTip("Clears your profile description text");
ImGui.Separator();
ImGui.TextUnformatted($"Profile Options:");
var isNsfw = _profileData.IsNsfw;
if (ImGui.Checkbox("Profile is NSFW", ref isNsfw))
{
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, IsNsfw: isNsfw, IsDisabled: null));
}
_uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON");
ImGui.TreePop();
}
}
profileTab.Dispose();
}
private void DrawManagement() private void DrawManagement()
{ {
@@ -192,7 +406,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
{ {
var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp; var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp;
if (pairs.Count > 10) tableFlags |= ImGuiTableFlags.ScrollY; if (pairs.Count > 10) tableFlags |= ImGuiTableFlags.ScrollY;
using var table = ImRaii.Table("userList#" + GroupFullInfo.Group.GID, 3, tableFlags); using var table = ImRaii.Table("userList#" + GroupFullInfo.Group.AliasOrGID, 3, tableFlags);
if (table) if (table)
{ {
ImGui.TableSetupColumn("Alias/UID/Note", ImGuiTableColumnFlags.None, 4); ImGui.TableSetupColumn("Alias/UID/Note", ImGuiTableColumnFlags.None, 4);
@@ -474,7 +688,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
ImGui.Separator(); ImGui.Separator();
} }
mgmtTab.Dispose(); mgmtTab.Dispose();
} }
private void DrawInvites(GroupPermissions perm) private void DrawInvites(GroupPermissions perm)
@@ -521,9 +734,37 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase
} }
inviteTab.Dispose(); inviteTab.Dispose();
} }
private void DrawTag(int tag)
{
var HasTag = _selectedTags.Contains(tag);
var tagName = (ProfileTags)tag;
if (ImGui.Checkbox(tagName.ToString(), ref HasTag))
{
if (HasTag)
{
_selectedTags.Add(tag);
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, IsNsfw: null, IsDisabled: null));
}
else
{
_selectedTags.Remove(tag);
_ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, IsNsfw: null, IsDisabled: null));
}
}
}
private void GetTagsFromProfile()
{
if (_profileData != null)
{
_selectedTags = [.. _profileData.Tags];
}
}
public override void OnClose() public override void OnClose()
{ {
Mediator.Publish(new RemoveWindowMessage(this)); Mediator.Publish(new RemoveWindowMessage(this));
_pfpTextureWrap?.Dispose();
} }
} }

View File

@@ -288,8 +288,6 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase
return; return;
} }
var currentGids = _nearbySyncshells.Select(s => s.Group.GID).ToHashSet(StringComparer.Ordinal);
if (updatedList != null) if (updatedList != null)
{ {
var previousGid = GetSelectedGid(); var previousGid = GetSelectedGid();

View File

@@ -1,4 +1,7 @@
using System.Security.Cryptography; using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text; using System.Text;
namespace LightlessSync.Utils; namespace LightlessSync.Utils;
@@ -13,8 +16,9 @@ public static class Crypto
public static string GetFileHash(this string filePath) public static string GetFileHash(this string filePath)
{ {
using SHA1CryptoServiceProvider cryptoProvider = new(); using SHA1 sha1 = SHA1.Create();
return BitConverter.ToString(cryptoProvider.ComputeHash(File.ReadAllBytes(filePath))).Replace("-", "", StringComparison.Ordinal); using FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return BitConverter.ToString(sha1.ComputeHash(stream)).Replace("-", "", StringComparison.Ordinal);
} }
public static string GetHash256(this (string, ushort) playerToHash) public static string GetHash256(this (string, ushort) playerToHash)

View File

@@ -5,12 +5,18 @@ using LightlessSync.API.Dto.Files;
using LightlessSync.API.Routes; using LightlessSync.API.Routes;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
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;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.IO;
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration;
namespace LightlessSync.WebAPI.Files; namespace LightlessSync.WebAPI.Files;
@@ -20,17 +26,27 @@ 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 PairProcessingLimiter _pairProcessingLimiter;
private readonly LightlessConfigService _configService;
private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams; private readonly ConcurrentDictionary<ThrottledStream, byte> _activeDownloadStreams;
private static readonly TimeSpan DownloadStallTimeout = TimeSpan.FromSeconds(30);
private volatile bool _disableDirectDownloads;
private int _consecutiveDirectDownloadFailures;
private bool _lastConfigDirectDownloadsState;
public FileDownloadManager(ILogger<FileDownloadManager> logger, LightlessMediator mediator, public FileDownloadManager(ILogger<FileDownloadManager> logger, LightlessMediator mediator,
FileTransferOrchestrator orchestrator, FileTransferOrchestrator orchestrator,
FileCacheManager fileCacheManager, FileCompactor fileCompactor) : base(logger, mediator) FileCacheManager fileCacheManager, FileCompactor fileCompactor,
PairProcessingLimiter pairProcessingLimiter, LightlessConfigService configService) : base(logger, mediator)
{ {
_downloadStatus = new Dictionary<string, FileDownloadStatus>(StringComparer.Ordinal); _downloadStatus = new Dictionary<string, FileDownloadStatus>(StringComparer.Ordinal);
_orchestrator = orchestrator; _orchestrator = orchestrator;
_fileDbManager = fileCacheManager; _fileDbManager = fileCacheManager;
_fileCompactor = fileCompactor; _fileCompactor = fileCompactor;
_pairProcessingLimiter = pairProcessingLimiter;
_configService = configService;
_activeDownloadStreams = new(); _activeDownloadStreams = new();
_lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads;
Mediator.Subscribe<DownloadLimitChangedMessage>(this, (msg) => Mediator.Subscribe<DownloadLimitChangedMessage>(this, (msg) =>
{ {
@@ -50,6 +66,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
public bool IsDownloading => CurrentDownloads.Any(); public bool IsDownloading => CurrentDownloads.Any();
private bool ShouldUseDirectDownloads()
{
return _configService.Current.EnableDirectDownloads && !_disableDirectDownloads;
}
public static void MungeBuffer(Span<byte> buffer) public static void MungeBuffer(Span<byte> buffer)
{ {
for (int i = 0; i < buffer.Length; ++i) for (int i = 0; i < buffer.Length; ++i)
@@ -156,39 +177,47 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
Logger.LogWarning("Download status missing for {group} when starting download", downloadGroup); Logger.LogWarning("Download status missing for {group} when starting download", downloadGroup);
} }
var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId);
await DownloadFileThrottled(requestUrl, tempPath, progress, MungeBuffer, ct, withToken: true).ConfigureAwait(false);
}
private delegate void DownloadDataCallback(Span<byte> data);
private async Task DownloadFileThrottled(Uri requestUrl, string destinationFilename, IProgress<long> progress, DownloadDataCallback? callback, CancellationToken ct, bool withToken)
{
const int maxRetries = 3; const int maxRetries = 3;
int retryCount = 0; int retryCount = 0;
TimeSpan retryDelay = TimeSpan.FromSeconds(2); TimeSpan retryDelay = TimeSpan.FromSeconds(2);
HttpResponseMessage? response = null;
HttpResponseMessage response = null!;
var requestUrl = LightlessFiles.CacheGetFullPath(fileTransfer[0].DownloadUri, requestId);
while (true) while (true)
{ {
try try
{ {
Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl} for request {id}", retryCount + 1, requestUrl, requestId); Logger.LogDebug("Attempt {attempt} - Downloading {requestUrl}", retryCount + 1, requestUrl);
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead, withToken).ConfigureAwait(false);
response = await _orchestrator.SendRequestAsync(HttpMethod.Get, requestUrl, ct, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
break; break;
} }
catch (HttpRequestException ex) when (ex.InnerException is TimeoutException || ex.StatusCode == null) catch (HttpRequestException ex) when (ex.InnerException is TimeoutException || ex.StatusCode == null)
{ {
response?.Dispose();
retryCount++; retryCount++;
Logger.LogWarning(ex, "Timeout during download of {requestUrl}. Attempt {attempt} of {maxRetries}", requestUrl, retryCount, maxRetries); Logger.LogWarning(ex, "Timeout during download of {requestUrl}. Attempt {attempt} of {maxRetries}", requestUrl, retryCount, maxRetries);
if (retryCount >= maxRetries || ct.IsCancellationRequested) if (retryCount >= maxRetries || ct.IsCancellationRequested)
{ {
Logger.LogError($"Max retries reached or cancelled. Failing download for {requestUrl}"); Logger.LogError("Max retries reached or cancelled. Failing download for {requestUrl}", requestUrl);
throw; throw;
} }
await Task.Delay(retryDelay, ct).ConfigureAwait(false); // Wait before retrying await Task.Delay(retryDelay, ct).ConfigureAwait(false);
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
response?.Dispose();
Logger.LogWarning(ex, "Error during download of {requestUrl}, HttpStatusCode: {code}", requestUrl, ex.StatusCode); Logger.LogWarning(ex, "Error during download of {requestUrl}, HttpStatusCode: {code}", requestUrl, ex.StatusCode);
if (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized) if (ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Unauthorized)
@@ -196,42 +225,80 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
throw new InvalidDataException($"Http error {ex.StatusCode} (cancelled: {ct.IsCancellationRequested}): {requestUrl}", ex); throw new InvalidDataException($"Http error {ex.StatusCode} (cancelled: {ct.IsCancellationRequested}): {requestUrl}", ex);
} }
throw; throw;
} }
} }
ThrottledStream? stream = null; ThrottledStream? stream = null;
FileStream? fileStream = null; FileStream? fileStream = null;
try try
{ {
fileStream = File.Create(tempPath); fileStream = File.Create(destinationFilename);
await using (fileStream.ConfigureAwait(false)) await using (fileStream.ConfigureAwait(false))
{ {
var bufferSize = response.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196; var bufferSize = response!.Content.Headers.ContentLength > 1024 * 1024 ? 65536 : 8196;
var buffer = new byte[bufferSize]; var buffer = new byte[bufferSize];
var bytesRead = 0;
var limit = _orchestrator.DownloadLimitPerSlot(); var limit = _orchestrator.DownloadLimitPerSlot();
Logger.LogTrace("Starting Download of {id} with a speed limit of {limit} to {tempPath}", requestId, limit, tempPath); Logger.LogTrace("Starting Download with a speed limit of {limit} to {destination}", limit, destinationFilename);
stream = new(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit); stream = new(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), limit);
_activeDownloadStreams.TryAdd(stream, 0); _activeDownloadStreams.TryAdd(stream, 0);
while ((bytesRead = await stream.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0) while (true)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
int bytesRead;
try
{
var readTask = stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct).AsTask();
while (!readTask.IsCompleted)
{
var completedTask = await Task.WhenAny(readTask, Task.Delay(DownloadStallTimeout)).ConfigureAwait(false);
if (completedTask == readTask)
{
break;
}
MungeBuffer(buffer.AsSpan(0, bytesRead)); ct.ThrowIfCancellationRequested();
var snapshot = _pairProcessingLimiter.GetSnapshot();
if (snapshot.Waiting > 0)
{
throw new TimeoutException($"No data received for {DownloadStallTimeout.TotalSeconds} seconds while downloading {requestUrl} (waiting: {snapshot.Waiting})");
}
Logger.LogTrace("Download stalled for {requestUrl} but no queued pairs, continuing to wait", requestUrl);
}
bytesRead = await readTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
if (bytesRead == 0)
{
break;
}
callback?.Invoke(buffer.AsSpan(0, bytesRead));
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false); await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false);
progress.Report(bytesRead); progress.Report(bytesRead);
} }
Logger.LogDebug("{requestUrl} downloaded to {tempPath}", requestUrl, tempPath); Logger.LogDebug("{requestUrl} downloaded to {destination}", requestUrl, destinationFilename);
} }
} }
catch (TimeoutException ex)
{
Logger.LogWarning(ex, "Detected stalled download for {requestUrl}, aborting transfer", requestUrl);
throw;
}
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
throw; throw;
@@ -240,18 +307,18 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
{ {
try try
{ {
fileStream?.Close(); fileStream?.Close();
if (!string.IsNullOrEmpty(tempPath) && File.Exists(tempPath)) if (!string.IsNullOrEmpty(destinationFilename) && File.Exists(destinationFilename))
{ {
File.Delete(tempPath); File.Delete(destinationFilename);
} }
} }
catch catch
{ {
// Ignore errors during cleanup // ignore cleanup errors
} }
throw; throw;
} }
finally finally
{ {
@@ -260,6 +327,134 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
_activeDownloadStreams.TryRemove(stream, out _); _activeDownloadStreams.TryRemove(stream, out _);
await stream.DisposeAsync().ConfigureAwait(false); await stream.DisposeAsync().ConfigureAwait(false);
} }
response?.Dispose();
}
}
private async Task DecompressBlockFileAsync(string downloadStatusKey, string blockFilePath, List<FileReplacementData> fileReplacement, string downloadLabel)
{
if (_downloadStatus.TryGetValue(downloadStatusKey, out var status))
{
status.TransferredFiles = 1;
status.DownloadStatus = DownloadStatus.Decompressing;
}
FileStream? fileBlockStream = null;
try
{
fileBlockStream = File.OpenRead(blockFilePath);
while (fileBlockStream.Position < fileBlockStream.Length)
{
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
try
{
var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1];
var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension);
Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", downloadLabel, fileHash, fileLengthBytes, filePath);
byte[] compressedFileContent = new byte[fileLengthBytes];
var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false);
if (readBytes != fileLengthBytes)
{
throw new EndOfStreamException();
}
MungeBuffer(compressedFileContent);
var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent);
await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath);
}
catch (EndOfStreamException)
{
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash);
}
catch (Exception e)
{
Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel);
}
}
}
catch (EndOfStreamException)
{
Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", downloadLabel);
}
catch (Exception ex)
{
Logger.LogError(ex, "{dlName}: Error during block file read", downloadLabel);
}
finally
{
if (fileBlockStream != null)
await fileBlockStream.DisposeAsync().ConfigureAwait(false);
}
}
private async Task PerformDirectDownloadFallbackAsync(DownloadFileTransfer directDownload, List<FileReplacementData> fileReplacement,
IProgress<long> progress, CancellationToken token, bool slotAlreadyAcquired)
{
if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl))
{
throw new InvalidOperationException("Direct download fallback requested without a direct download URL.");
}
var downloadKey = directDownload.DirectDownloadUrl!;
bool slotAcquiredHere = false;
string? blockFile = null;
try
{
if (!slotAlreadyAcquired)
{
if (_downloadStatus.TryGetValue(downloadKey, out var tracker))
{
tracker.DownloadStatus = DownloadStatus.WaitingForSlot;
}
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
slotAcquiredHere = true;
}
if (_downloadStatus.TryGetValue(downloadKey, out var queueTracker))
{
queueTracker.DownloadStatus = DownloadStatus.WaitingForQueue;
}
var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(directDownload.DownloadUri),
new[] { directDownload.Hash }, token).ConfigureAwait(false);
var requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync().ConfigureAwait(false)).Trim('"'));
blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk");
await DownloadAndMungeFileHttpClient(downloadKey, requestId, [directDownload], blockFile, progress, token).ConfigureAwait(false);
if (!File.Exists(blockFile))
{
throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile);
}
await DecompressBlockFileAsync(downloadKey, blockFile, fileReplacement, $"fallback-{directDownload.Hash}").ConfigureAwait(false);
}
finally
{
if (slotAcquiredHere)
{
_orchestrator.ReleaseDownloadSlot();
}
if (!string.IsNullOrEmpty(blockFile))
{
try
{
File.Delete(blockFile);
}
catch
{
// ignore cleanup errors
}
}
} }
} }
@@ -307,30 +502,76 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
private async Task DownloadFilesInternal(GameObjectHandler gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct) private async Task DownloadFilesInternal(GameObjectHandler gameObjectHandler, List<FileReplacementData> fileReplacement, CancellationToken ct)
{ {
var downloadGroups = CurrentDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal); var objectName = gameObjectHandler?.Name ?? "Unknown";
foreach (var downloadGroup in downloadGroups) var configAllowsDirect = _configService.Current.EnableDirectDownloads;
if (configAllowsDirect != _lastConfigDirectDownloadsState)
{ {
_downloadStatus[downloadGroup.Key] = new FileDownloadStatus() _lastConfigDirectDownloadsState = configAllowsDirect;
if (configAllowsDirect)
{
_disableDirectDownloads = false;
_consecutiveDirectDownloadFailures = 0;
}
}
var allowDirectDownloads = ShouldUseDirectDownloads();
var directDownloads = new List<DownloadFileTransfer>();
var batchDownloads = new List<DownloadFileTransfer>();
foreach (var download in CurrentDownloads)
{
if (!string.IsNullOrEmpty(download.DirectDownloadUrl) && allowDirectDownloads)
{
directDownloads.Add(download);
}
else
{
batchDownloads.Add(download);
}
}
var downloadBatches = batchDownloads.GroupBy(f => f.DownloadUri.Host + ":" + f.DownloadUri.Port, StringComparer.Ordinal).ToArray();
foreach (var directDownload in directDownloads)
{
_downloadStatus[directDownload.DirectDownloadUrl!] = new FileDownloadStatus()
{ {
DownloadStatus = DownloadStatus.Initializing, DownloadStatus = DownloadStatus.Initializing,
TotalBytes = downloadGroup.Sum(c => c.Total), TotalBytes = directDownload.Total,
TotalFiles = 1, TotalFiles = 1,
TransferredBytes = 0, TransferredBytes = 0,
TransferredFiles = 0 TransferredFiles = 0
}; };
} }
foreach (var downloadBatch in downloadBatches)
{
_downloadStatus[downloadBatch.Key] = new FileDownloadStatus()
{
DownloadStatus = DownloadStatus.Initializing,
TotalBytes = downloadBatch.Sum(c => c.Total),
TotalFiles = 1,
TransferredBytes = 0,
TransferredFiles = 0
};
}
if (directDownloads.Count > 0 || downloadBatches.Length > 0)
{
Logger.LogWarning("Downloading {direct} files directly, and {batchtotal} in {batches} batches.", directDownloads.Count, batchDownloads.Count, downloadBatches.Length);
}
Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus)); Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus));
await Parallel.ForEachAsync(downloadGroups, new ParallelOptions() Task batchDownloadsTask = downloadBatches.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(downloadBatches, new ParallelOptions()
{ {
MaxDegreeOfParallelism = downloadGroups.Count(), MaxDegreeOfParallelism = downloadBatches.Length,
CancellationToken = ct, CancellationToken = ct,
}, },
async (fileGroup, token) => async (fileGroup, token) =>
{ {
// let server predownload files
var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(fileGroup.First().DownloadUri), var requestIdResponse = await _orchestrator.SendRequestAsync(HttpMethod.Post, LightlessFiles.RequestEnqueueFullPath(fileGroup.First().DownloadUri),
fileGroup.Select(c => c.Hash), token).ConfigureAwait(false); fileGroup.Select(c => c.Hash), token).ConfigureAwait(false);
Logger.LogDebug("Sent request for {n} files on server {uri} with result {result}", fileGroup.Count(), fileGroup.First().DownloadUri, Logger.LogDebug("Sent request for {n} files on server {uri} with result {result}", fileGroup.Count(), fileGroup.First().DownloadUri,
@@ -353,7 +594,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
downloadStatus.DownloadStatus = DownloadStatus.WaitingForSlot; downloadStatus.DownloadStatus = DownloadStatus.WaitingForSlot;
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false); await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
downloadStatus.DownloadStatus = DownloadStatus.WaitingForQueue; downloadStatus.DownloadStatus = DownloadStatus.WaitingForQueue;
Progress<long> progress = new((bytesDownloaded) => var progress = CreateInlineProgress((bytesDownloaded) =>
{ {
try try
{ {
@@ -371,7 +612,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, gameObjectHandler); Logger.LogDebug("{dlName}: Detected cancellation of download, partially extracting files for {id}", fi.Name, objectName);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -382,72 +623,167 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
return; return;
} }
FileStream? fileBlockStream = null;
try try
{ {
if (_downloadStatus.TryGetValue(fileGroup.Key, out var status))
{
status.TransferredFiles = 1;
status.DownloadStatus = DownloadStatus.Decompressing;
}
if (!File.Exists(blockFile)) if (!File.Exists(blockFile))
{ {
Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name);
return; return;
} }
fileBlockStream = File.OpenRead(blockFile); await DecompressBlockFileAsync(fileGroup.Key, blockFile, fileReplacement, fi.Name).ConfigureAwait(false);
while (fileBlockStream.Position < fileBlockStream.Length)
{
(string fileHash, long fileLengthBytes) = ReadBlockFileHeader(fileBlockStream);
try
{
var fileExtension = fileReplacement.First(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase)).GamePaths[0].Split(".")[^1];
var filePath = _fileDbManager.GetCacheFilePath(fileHash, fileExtension);
Logger.LogDebug("{dlName}: Decompressing {file}:{le} => {dest}", fi.Name, fileHash, fileLengthBytes, filePath);
byte[] compressedFileContent = new byte[fileLengthBytes];
var readBytes = await fileBlockStream.ReadAsync(compressedFileContent, CancellationToken.None).ConfigureAwait(false);
if (readBytes != fileLengthBytes)
{
throw new EndOfStreamException();
}
MungeBuffer(compressedFileContent);
var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent);
await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false);
PersistFileToStorage(fileHash, filePath);
}
catch (EndOfStreamException)
{
Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", fi.Name, fileHash);
}
catch (Exception e)
{
Logger.LogWarning(e, "{dlName}: Error during decompression", fi.Name);
}
}
}
catch (EndOfStreamException)
{
Logger.LogDebug("{dlName}: Failure to extract file header data, stream ended", fi.Name);
}
catch (Exception ex)
{
Logger.LogError(ex, "{dlName}: Error during block file read", fi.Name);
} }
finally finally
{ {
_orchestrator.ReleaseDownloadSlot(); _orchestrator.ReleaseDownloadSlot();
if (fileBlockStream != null)
await fileBlockStream.DisposeAsync().ConfigureAwait(false);
File.Delete(blockFile); File.Delete(blockFile);
} }
}).ConfigureAwait(false); });
Logger.LogDebug("Download end: {id}", gameObjectHandler); Task directDownloadsTask = directDownloads.Count == 0 ? Task.CompletedTask : Parallel.ForEachAsync(directDownloads, new ParallelOptions()
{
MaxDegreeOfParallelism = directDownloads.Count,
CancellationToken = ct,
},
async (directDownload, token) =>
{
if (!_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out var downloadTracker))
{
Logger.LogWarning("Download status missing for direct URL {url}", directDownload.DirectDownloadUrl);
return;
}
var progress = CreateInlineProgress((bytesDownloaded) =>
{
try
{
if (_downloadStatus.TryGetValue(directDownload.DirectDownloadUrl!, out FileDownloadStatus? value))
{
value.TransferredBytes += bytesDownloaded;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not set download progress");
}
});
if (!ShouldUseDirectDownloads())
{
await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAlreadyAcquired: false).ConfigureAwait(false);
return;
}
var tempFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, "bin");
var slotAcquired = false;
try
{
downloadTracker.DownloadStatus = DownloadStatus.WaitingForSlot;
await _orchestrator.WaitForDownloadSlotAsync(token).ConfigureAwait(false);
slotAcquired = true;
downloadTracker.DownloadStatus = DownloadStatus.Downloading;
Logger.LogDebug("Beginning direct download of {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl);
await DownloadFileThrottled(new Uri(directDownload.DirectDownloadUrl!), tempFilename, progress, null, token, withToken: false).ConfigureAwait(false);
Interlocked.Exchange(ref _consecutiveDirectDownloadFailures, 0);
downloadTracker.DownloadStatus = DownloadStatus.Decompressing;
try
{
var replacement = fileReplacement.FirstOrDefault(f => string.Equals(f.Hash, directDownload.Hash, StringComparison.OrdinalIgnoreCase));
if (replacement == null || replacement.GamePaths.Length == 0)
{
Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash);
return;
}
var fileExtension = replacement.GamePaths[0].Split(".")[^1];
var finalFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, fileExtension);
Logger.LogDebug("Decompressing direct download {hash} from {compressedFile} to {finalFile}", directDownload.Hash, tempFilename, finalFilename);
byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename).ConfigureAwait(false);
var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes);
await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, CancellationToken.None).ConfigureAwait(false);
PersistFileToStorage(directDownload.Hash, finalFilename);
downloadTracker.TransferredFiles = 1;
Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash);
}
catch (Exception ex)
{
Logger.LogError(ex, "Exception downloading {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl);
}
}
catch (OperationCanceledException ex)
{
Logger.LogDebug("{hash}: Detected cancellation of direct download, discarding file.", directDownload.Hash);
Logger.LogError(ex, "{hash}: Error during direct download.", directDownload.Hash);
ClearDownload();
return;
}
catch (Exception ex)
{
var expectedDirectDownloadFailure = ex is InvalidDataException;
var failureCount = 0;
if (expectedDirectDownloadFailure)
{
Logger.LogInformation(ex, "{hash}: Direct download unavailable, attempting queued fallback.", directDownload.Hash);
}
else
{
failureCount = Interlocked.Increment(ref _consecutiveDirectDownloadFailures);
Logger.LogWarning(ex, "{hash}: Direct download failed, attempting queued fallback.", directDownload.Hash);
}
try
{
downloadTracker.DownloadStatus = DownloadStatus.WaitingForQueue;
await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAcquired).ConfigureAwait(false);
if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads)
{
_disableDirectDownloads = true;
Logger.LogWarning("Disabling direct downloads for this session after {count} consecutive failures.", failureCount);
}
}
catch (Exception fallbackEx)
{
if (slotAcquired)
{
_orchestrator.ReleaseDownloadSlot();
slotAcquired = false;
}
Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash);
ClearDownload();
return;
}
}
finally
{
if (slotAcquired)
{
_orchestrator.ReleaseDownloadSlot();
}
try
{
File.Delete(tempFilename);
}
catch
{
// ignore
}
}
});
await Task.WhenAll(batchDownloadsTask, directDownloadsTask).ConfigureAwait(false);
Logger.LogDebug("Download end: {id}", objectName);
ClearDownload(); ClearDownload();
} }
@@ -554,4 +890,24 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase
_orchestrator.ClearDownloadRequest(requestId); _orchestrator.ClearDownloadRequest(requestId);
} }
} }
}
private static IProgress<long> CreateInlineProgress(Action<long> callback)
{
return new InlineProgress(callback);
}
private sealed class InlineProgress : IProgress<long>
{
private readonly Action<long> _callback;
public InlineProgress(Action<long> callback)
{
_callback = callback ?? throw new ArgumentNullException(nameof(callback));
}
public void Report(long value)
{
_callback(value);
}
}
}

View File

@@ -81,27 +81,30 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
} }
public async Task<HttpResponseMessage> SendRequestAsync(HttpMethod method, Uri uri, public async Task<HttpResponseMessage> SendRequestAsync(HttpMethod method, Uri uri,
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead) CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
bool withToken = true)
{ {
using var requestMessage = new HttpRequestMessage(method, uri); using var requestMessage = new HttpRequestMessage(method, uri);
return await SendRequestInternalAsync(requestMessage, ct, httpCompletionOption).ConfigureAwait(false); return await SendRequestInternalAsync(requestMessage, ct, httpCompletionOption, withToken).ConfigureAwait(false);
} }
public async Task<HttpResponseMessage> SendRequestAsync<T>(HttpMethod method, Uri uri, T content, CancellationToken ct) where T : class public async Task<HttpResponseMessage> SendRequestAsync<T>(HttpMethod method, Uri uri, T content, CancellationToken ct,
bool withToken = true) where T : class
{ {
using var requestMessage = new HttpRequestMessage(method, uri); using var requestMessage = new HttpRequestMessage(method, uri);
if (content is not ByteArrayContent) if (content is not ByteArrayContent)
requestMessage.Content = JsonContent.Create(content); requestMessage.Content = JsonContent.Create(content);
else else
requestMessage.Content = content as ByteArrayContent; requestMessage.Content = content as ByteArrayContent;
return await SendRequestInternalAsync(requestMessage, ct).ConfigureAwait(false); return await SendRequestInternalAsync(requestMessage, ct, withToken: withToken).ConfigureAwait(false);
} }
public async Task<HttpResponseMessage> SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content, CancellationToken ct) public async Task<HttpResponseMessage> SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content,
CancellationToken ct, bool withToken = true)
{ {
using var requestMessage = new HttpRequestMessage(method, uri); using var requestMessage = new HttpRequestMessage(method, uri);
requestMessage.Content = content; requestMessage.Content = content;
return await SendRequestInternalAsync(requestMessage, ct).ConfigureAwait(false); return await SendRequestInternalAsync(requestMessage, ct, withToken: withToken).ConfigureAwait(false);
} }
public async Task WaitForDownloadSlotAsync(CancellationToken token) public async Task WaitForDownloadSlotAsync(CancellationToken token)
@@ -144,10 +147,13 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase
} }
private async Task<HttpResponseMessage> SendRequestInternalAsync(HttpRequestMessage requestMessage, private async Task<HttpResponseMessage> SendRequestInternalAsync(HttpRequestMessage requestMessage,
CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead) CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, bool withToken = true)
{ {
var token = await _tokenProvider.GetToken().ConfigureAwait(false); if (withToken)
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); {
var token = await _tokenProvider.GetToken().ConfigureAwait(false);
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent) if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent)
{ {

View File

@@ -18,6 +18,7 @@ public class DownloadFileTransfer : FileTransfer
} }
get => Dto.Size; get => Dto.Size;
} }
public string? DirectDownloadUrl => ((DownloadFileDto)TransferDto).CDNDownloadUrl;
public long TotalRaw => Dto.RawSize; public long TotalRaw => Dto.RawSize;
private DownloadFileDto Dto => (DownloadFileDto)TransferDto; private DownloadFileDto Dto => (DownloadFileDto)TransferDto;

View File

@@ -84,7 +84,7 @@ public partial class ApiController
public async Task<UserProfileDto> UserGetProfile(UserDto dto) public async Task<UserProfileDto> UserGetProfile(UserDto dto)
{ {
if (!IsConnected) return new UserProfileDto(dto.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, Description: null); if (!IsConnected) return new UserProfileDto(dto.User, Disabled: false, IsNSFW: null, ProfilePictureBase64: null, Description: null, Tags: null);
return await _lightlessHub!.InvokeAsync<UserProfileDto>(nameof(UserGetProfile), dto).ConfigureAwait(false); return await _lightlessHub!.InvokeAsync<UserProfileDto>(nameof(UserGetProfile), dto).ConfigureAwait(false);
} }

View File

@@ -195,7 +195,14 @@ public partial class ApiController
public Task Client_UserUpdateProfile(UserDto dto) public Task Client_UserUpdateProfile(UserDto dto)
{ {
Logger.LogDebug("Client_UserUpdateProfile: {dto}", dto); Logger.LogDebug("Client_UserUpdateProfile: {dto}", dto);
ExecuteSafely(() => Mediator.Publish(new ClearProfileDataMessage(dto.User))); ExecuteSafely(() => Mediator.Publish(new ClearProfileUserDataMessage(dto.User)));
return Task.CompletedTask;
}
public Task Client_GroupSendProfile(GroupProfileDto groupInfo)
{
Logger.LogDebug("Client_GroupSendProfile: {dto}", groupInfo);
ExecuteSafely(() => Mediator.Publish(new ClearProfileGroupDataMessage(groupInfo.Group)));
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -380,6 +387,12 @@ public partial class ApiController
_lightlessHub!.On(nameof(Client_UserUpdateProfile), act); _lightlessHub!.On(nameof(Client_UserUpdateProfile), act);
} }
public void ClientGroupSendProfile(Action<GroupProfileDto> act)
{
if (_initialized) return;
_lightlessHub!.On(nameof(Client_GroupSendProfile), act);
}
public void OnUserUpdateSelfPairPermissions(Action<UserPermissionsDto> act) public void OnUserUpdateSelfPairPermissions(Action<UserPermissionsDto> act)
{ {
if (_initialized) return; if (_initialized) return;

View File

@@ -115,6 +115,18 @@ public partial class ApiController
CheckConnection(); CheckConnection();
return await _lightlessHub!.InvokeAsync<int>(nameof(GroupPrune), group, days, execute).ConfigureAwait(false); return await _lightlessHub!.InvokeAsync<int>(nameof(GroupPrune), group, days, execute).ConfigureAwait(false);
} }
public async Task<GroupProfileDto> GroupGetProfile(GroupDto dto)
{
CheckConnection();
if (!IsConnected) return new GroupProfileDto(Group: dto.Group, Description: null, Tags: null, PictureBase64: null, IsNsfw: false, IsDisabled: false);
return await _lightlessHub!.InvokeAsync<GroupProfileDto>(nameof(GroupGetProfile), dto).ConfigureAwait(false);
}
public async Task GroupSetProfile(GroupProfileDto dto)
{
CheckConnection();
await _lightlessHub!.InvokeAsync(nameof(GroupSetProfile), dto).ConfigureAwait(false);
}
public async Task<List<GroupFullInfoDto>> GroupsGetAll() public async Task<List<GroupFullInfoDto>> GroupsGetAll()
{ {
@@ -139,7 +151,6 @@ public partial class ApiController
.ConfigureAwait(false); .ConfigureAwait(false);
} }
private void CheckConnection() private void CheckConnection()
{ {
if (ServerState is not (ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting)) throw new InvalidDataException("Not connected"); if (ServerState is not (ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting)) throw new InvalidDataException("Not connected");

View File

@@ -608,17 +608,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL
ServerState = state; ServerState = state;
} }
public Task Client_GroupSendProfile(GroupProfileDto groupInfo) public Task<UserProfileDto?> UserGetLightfinderProfile(string hashedCid)
{
throw new NotImplementedException();
}
public Task<GroupProfileDto> GroupGetProfile(GroupDto dto)
{
throw new NotImplementedException();
}
public Task GroupSetProfile(GroupProfileDto dto)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }