Files
LightlessClient/LightlessSync/FileCache/FileCacheManager.cs
defnotken 7672f147f5
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 32s
1.12.0 - LightFinder + Bugfixes + Other fixes from 1.11.X (#39)
![image.png](/attachments/c984e826-254e-4ce3-af92-4e795ea717ab)

Lightless 1.12.0 is HERE! In this major update, we are introducing something we've been working on and testing for the last couple of weeks. In this update we are introducing a new (**OPTIONAL**) feature called **LightFinder**! We took inspiration from FFXIV's very own Party Finder and decided to implement something that allows users to not only look for fellow Lightless users, but also put up their Syncshell for others looking to join a sync community!

When you enable LightFinder, you will be visible to other LightFinder users for **3 hours** or when you want to disabled it. When the 3 hours are up, you can either leave it disabled or enable it again for another 3 hours. The tag shown above will show above your nameplate, and you will be able to receive pair requests in your UI from other users with LightFinder enabled without having to input their uid!

![image.png](/attachments/91a7ce60-7771-49d7-bae8-6d7a67e46fa3)

Are you at a Venue? In Limsa? Partying in the streets of Uldah? If you're looking for fellow Lightless users you can now enable LightFinder and you will be shown to others who also have LightFinder enabled!

Looking for a Syncshell to join? Enable LightFinder to see what SyncShells are available to join!

Want to advertise your Syncshell? Choose the syncshell you want to put up in LightFinder and enable LightFinder.

**IMPORTANT: We want to stress the fact that, if you just want to sync with just your friends and no one else, this does not take that away. No one will know you use Lightless unless you choose to tell them, or use this **OPTIONAL** feature. Your privacy is still maintained if you don't want to use the feature.**

# Major Changes

## LightFinder - **OPTIONAL FEATURE** - **DOES NOT AFFECT YOU IF YOU DON'T WANT TO USE IT**

![image.png](/attachments/9a1e7455-f9b3-4e3a-9ec5-5bc74ed43c26)

* New **OPTIONAL** syncing feature where one can enable something like a Party Finder so that other Lightless users in the area can see you are looking for people to sync with. Enable it by clicking the compass button and then the `Enable LightFinder` button.

![image.png](/attachments/7be5c1fc-439f-43fc-bb1e-8ae32ab5e52e)

* [L] Send Pair Request has been added to player context menus. You should still be able to send a request without Lightfinder on BUT you will need to know the other player is using Lightless and have them send a pair request back.

![image.png](/attachments/75e0d600-baf9-43d5-af37-e86b6a1e209b)

* When in LightFinder mode, for X mins you will be visible to all Lightless Users WHO ALSO HAVE LIGHTFINDER ON and will receive notifications of people wanting to pair with you. If you are the person using LightFinder, you understand the risks of pairing with someone you don't know. If you are the person sending a request to someone with LightFinder on, you also understand the risks of pairing with someone you don't know. **AGAIN, THIS IS OPTIONAL.**

* When in LightFinder mode, you can also put up a Syncshell you own on the Syncshell Finder so that others can easily find it and join. This has to be done prior to enabling LightFinder.

![image.png](/attachments/1929d8e0-778d-4ced-9bf0-f350887980db)

* Syncshell Finder allows you to join Syncshells that are indexed by LightFinder

![image.png](/attachments/fef7b786-b1a0-4967-a4a9-886cdddc1a61)

# Minor Changes

* Vanity addition to our supporters: On top of vanity ids you can find a fun addition under Your User Settings -> Edit Lightless Profile -> Vanity Settings to change the colour of your name in the lightless ui. This will be shown to all users.
![image.png](/attachments/9b950c07-6b27-4bd1-a0d4-5346396b23e2)

* Pairing nameplate colour override can also now override FC tag (new option_

* Bunch of UI fixes, updates, and changes

* kdb is now a whitelisted filetype that can upload

Co-authored-by: CakeAndBanana <admin@cakeandbanana.nl>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: choco <thijmenhogenkamp@gmail.com>
Co-authored-by: choco <choco@noreply.git.lightless-sync.org>
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #39
2025-10-05 07:48:32 +02:00

554 lines
22 KiB
C#

using K4os.Compression.LZ4.Legacy;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
namespace LightlessSync.FileCache;
public sealed class FileCacheManager : IHostedService
{
public const string CachePrefix = "{cache}";
public const string CsvSplit = "|";
public const string PenumbraPrefix = "{penumbra}";
private readonly LightlessConfigService _configService;
private readonly LightlessMediator _lightlessMediator;
private readonly string _csvPath;
private readonly ConcurrentDictionary<string, List<FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager;
private readonly ILogger<FileCacheManager> _logger;
public string CacheFolder => _configService.Current.CacheFolder;
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
{
_logger = logger;
_ipcManager = ipcManager;
_configService = configService;
_lightlessMediator = lightlessMediator;
_csvPath = Path.Combine(configService.ConfigurationDirectory, "FileCache.csv");
}
private string CsvBakPath => _csvPath + ".bak";
public FileCacheEntity? CreateCacheEntry(string path)
{
FileInfo fi = new(path);
if (!fi.Exists) return null;
_logger.LogTrace("Creating cache entry for {path}", path);
return CreateFileEntity(_configService.Current.CacheFolder.ToLowerInvariant(), CachePrefix, fi);
}
public FileCacheEntity? CreateFileEntry(string path)
{
FileInfo fi = new(path);
if (!fi.Exists) return null;
_logger.LogTrace("Creating file entry for {path}", path);
return CreateFileEntity(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), PenumbraPrefix, fi);
}
private FileCacheEntity? CreateFileEntity(string directory, string prefix, FileInfo fi)
{
var fullName = fi.FullName.ToLowerInvariant();
if (!fullName.Contains(directory, StringComparison.Ordinal)) return null;
string prefixedPath = fullName.Replace(directory, prefix + "\\", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal);
return CreateFileCacheEntity(fi, prefixedPath);
}
public List<FileCacheEntity> GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v).ToList();
public List<FileCacheEntity> GetAllFileCachesByHash(string hash, bool ignoreCacheEntries = false, bool validate = true)
{
List<FileCacheEntity> output = [];
if (_fileCaches.TryGetValue(hash, out var fileCacheEntities))
{
foreach (var fileCache in fileCacheEntities.Where(c => !ignoreCacheEntries || !c.IsCacheEntry).ToList())
{
if (!validate) output.Add(fileCache);
else
{
var validated = GetValidatedFileCache(fileCache);
if (validated != null) output.Add(validated);
}
}
}
return output;
}
public Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int, int, FileCacheEntity)> progress, CancellationToken cancellationToken)
{
_lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity)));
_logger.LogInformation("Validating local storage");
var cacheEntries = _fileCaches.SelectMany(v => v.Value).Where(v => v.IsCacheEntry).ToList();
List<FileCacheEntity> brokenEntities = [];
int i = 0;
foreach (var fileCache in cacheEntries)
{
if (cancellationToken.IsCancellationRequested) break;
_logger.LogInformation("Validating {file}", fileCache.ResolvedFilepath);
progress.Report((i, cacheEntries.Count, fileCache));
i++;
if (!File.Exists(fileCache.ResolvedFilepath))
{
brokenEntities.Add(fileCache);
continue;
}
try
{
var computedHash = Crypto.GetFileHash(fileCache.ResolvedFilepath);
if (!string.Equals(computedHash, fileCache.Hash, StringComparison.Ordinal))
{
_logger.LogInformation("Failed to validate {file}, got hash {computedHash}, expected hash {hash}", fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
brokenEntities.Add(fileCache);
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Error during validation of {file}", fileCache.ResolvedFilepath);
brokenEntities.Add(fileCache);
}
}
foreach (var brokenEntity in brokenEntities)
{
RemoveHashedFile(brokenEntity.Hash, brokenEntity.PrefixedFilePath);
try
{
File.Delete(brokenEntity.ResolvedFilepath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete {file}", brokenEntity.ResolvedFilepath);
}
}
_lightlessMediator.Publish(new ResumeScanMessage(nameof(ValidateLocalIntegrity)));
return Task.FromResult(brokenEntities);
}
public string GetCacheFilePath(string hash, string extension)
{
return Path.Combine(_configService.Current.CacheFolder, hash + "." + extension);
}
public async Task<(string, byte[])> GetCompressedFileData(string fileHash, CancellationToken uploadToken)
{
var fileCache = GetFileCacheByHash(fileHash)!.ResolvedFilepath;
return (fileHash, LZ4Wrapper.WrapHC(await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false), 0,
(int)new FileInfo(fileCache).Length));
}
public FileCacheEntity? GetFileCacheByHash(string hash)
{
if (_fileCaches.TryGetValue(hash, out var hashes))
{
var item = hashes.OrderBy(p => p.PrefixedFilePath.Contains(PenumbraPrefix, StringComparison.Ordinal) ? 0 : 1).FirstOrDefault();
if (item != null) return GetValidatedFileCache(item);
}
return null;
}
private FileCacheEntity? GetFileCacheByPath(string path)
{
var cleanedPath = path.Replace("/", "\\", StringComparison.OrdinalIgnoreCase).ToLowerInvariant()
.Replace(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), "", StringComparison.OrdinalIgnoreCase);
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 CreateFileEntry(path);
}
var validatedCacheEntry = GetValidatedFileCache(entry);
return validatedCacheEntry;
}
public Dictionary<string, FileCacheEntity?> GetFileCachesByPaths(string[] paths)
{
_getCachesByPathsSemaphore.Wait();
try
{
var allEntities = _fileCaches.SelectMany(f => f.Value).ToArray();
var cacheDict = new ConcurrentDictionary<string, FileCacheEntity>(
StringComparer.OrdinalIgnoreCase);
Parallel.ForEach(allEntities, entity =>
{
if (entity != null && entity.PrefixedFilePath != null)
{
cacheDict[entity.PrefixedFilePath] = entity;
}
else
{
_logger.LogWarning("Null FileCacheEntity or PrefixedFilePath encountered in cache population: {entity}", entity);
}
});
var cleanedPaths = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
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);
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
{
_getCachesByPathsSemaphore.Release();
}
}
public void RemoveHashedFile(string hash, string prefixedFilePath)
{
if (_fileCaches.TryGetValue(hash, out var caches))
{
_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)
{
_fileCaches.Remove(hash, out var entity);
}
}
}
public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true)
{
_logger.LogTrace("Updating hash for {path}", fileCache.ResolvedFilepath);
var oldHash = fileCache.Hash;
var prefixedPath = fileCache.PrefixedFilePath;
if (computeProperties)
{
var fi = new FileInfo(fileCache.ResolvedFilepath);
fileCache.Size = fi.Length;
fileCache.CompressedSize = null;
fileCache.Hash = Crypto.GetFileHash(fileCache.ResolvedFilepath);
fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
}
RemoveHashedFile(oldHash, prefixedPath);
AddHashedFile(fileCache);
}
public (FileState State, FileCacheEntity FileCache) ValidateFileCacheEntity(FileCacheEntity fileCache)
{
fileCache = ReplacePathPrefixes(fileCache);
FileInfo fi = new(fileCache.ResolvedFilepath);
if (!fi.Exists)
{
return (FileState.RequireDeletion, fileCache);
}
if (!string.Equals(fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileCache.LastModifiedDateTicks, StringComparison.Ordinal))
{
return (FileState.RequireUpdate, fileCache);
}
return (FileState.Valid, fileCache);
}
public void WriteOutFullCsv()
{
lock (_fileWriteLock)
{
StringBuilder sb = new();
foreach (var entry in _fileCaches.SelectMany(k => k.Value).OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase))
{
sb.AppendLine(entry.CsvEntry);
}
if (File.Exists(_csvPath))
{
File.Copy(_csvPath, CsvBakPath, overwrite: true);
}
try
{
File.WriteAllText(_csvPath, sb.ToString());
File.Delete(CsvBakPath);
}
catch
{
File.WriteAllText(CsvBakPath, sb.ToString());
}
}
}
internal FileCacheEntity MigrateFileHashToExtension(FileCacheEntity fileCache, string ext)
{
try
{
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
var extensionPath = fileCache.ResolvedFilepath.ToUpper(CultureInfo.InvariantCulture) + "." + ext;
File.Move(fileCache.ResolvedFilepath, extensionPath, overwrite: true);
var newHashedEntity = new FileCacheEntity(fileCache.Hash, fileCache.PrefixedFilePath + "." + ext, DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture));
newHashedEntity.SetResolvedFilePath(extensionPath);
AddHashedFile(newHashedEntity);
_logger.LogTrace("Migrated from {oldPath} to {newPath}", fileCache.ResolvedFilepath, newHashedEntity.ResolvedFilepath);
return newHashedEntity;
}
catch (Exception ex)
{
AddHashedFile(fileCache);
_logger.LogWarning(ex, "Failed to migrate entity {entity}", fileCache.PrefixedFilePath);
return fileCache;
}
}
private void AddHashedFile(FileCacheEntity fileCache)
{
if (!_fileCaches.TryGetValue(fileCache.Hash, out var entries) || entries is null)
{
_fileCaches[fileCache.Hash] = entries = [];
}
if (!entries.Exists(u => string.Equals(u.PrefixedFilePath, fileCache.PrefixedFilePath, StringComparison.OrdinalIgnoreCase)))
{
//_logger.LogTrace("Adding to DB: {hash} => {path}", fileCache.Hash, fileCache.PrefixedFilePath);
entries.Add(fileCache);
}
}
private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null)
{
hash ??= Crypto.GetFileHash(fileInfo.FullName);
var entity = new FileCacheEntity(hash, prefixedPath, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileInfo.Length);
entity = ReplacePathPrefixes(entity);
AddHashedFile(entity);
lock (_fileWriteLock)
{
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
}
var result = GetFileCacheByPath(fileInfo.FullName);
_logger.LogTrace("Creating cache entity for {name} success: {success}", fileInfo.FullName, (result != null));
return result;
}
private FileCacheEntity? GetValidatedFileCache(FileCacheEntity fileCache)
{
var resultingFileCache = ReplacePathPrefixes(fileCache);
//_logger.LogTrace("Validating {path}", fileCache.PrefixedFilePath);
resultingFileCache = Validate(resultingFileCache);
return resultingFileCache;
}
private FileCacheEntity ReplacePathPrefixes(FileCacheEntity fileCache)
{
if (fileCache.PrefixedFilePath.StartsWith(PenumbraPrefix, StringComparison.OrdinalIgnoreCase))
{
fileCache.SetResolvedFilePath(fileCache.PrefixedFilePath.Replace(PenumbraPrefix, _ipcManager.Penumbra.ModDirectory, StringComparison.Ordinal));
}
else if (fileCache.PrefixedFilePath.StartsWith(CachePrefix, StringComparison.OrdinalIgnoreCase))
{
fileCache.SetResolvedFilePath(fileCache.PrefixedFilePath.Replace(CachePrefix, _configService.Current.CacheFolder, StringComparison.Ordinal));
}
return fileCache;
}
private FileCacheEntity? Validate(FileCacheEntity fileCache)
{
if (string.IsNullOrWhiteSpace(fileCache.ResolvedFilepath))
{
_logger.LogWarning("FileCacheEntity has empty ResolvedFilepath for hash {hash}, prefixed path {prefixed}", fileCache.Hash, fileCache.PrefixedFilePath);
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
return null;
}
var file = new FileInfo(fileCache.ResolvedFilepath);
if (!file.Exists)
{
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
return null;
}
if (!string.Equals(file.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileCache.LastModifiedDateTicks, StringComparison.Ordinal))
{
UpdateHashedFile(fileCache);
}
return fileCache;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting FileCacheManager");
lock (_fileWriteLock)
{
try
{
_logger.LogInformation("Checking for {bakPath}", CsvBakPath);
if (File.Exists(CsvBakPath))
{
_logger.LogInformation("{bakPath} found, moving to {csvPath}", CsvBakPath, _csvPath);
File.Move(CsvBakPath, _csvPath, overwrite: true);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to move BAK to ORG, deleting BAK");
try
{
if (File.Exists(CsvBakPath))
File.Delete(CsvBakPath);
}
catch (Exception ex1)
{
_logger.LogWarning(ex1, "Could not delete bak file");
}
}
}
if (File.Exists(_csvPath))
{
if (!_ipcManager.Penumbra.APIAvailable || string.IsNullOrEmpty(_ipcManager.Penumbra.ModDirectory))
{
_lightlessMediator.Publish(new NotificationMessage("Penumbra not connected",
"Could not load local file cache data. Penumbra is not connected or not properly set up. Please enable and/or configure Penumbra properly to use Lightless. After, reload Lightless in the Plugin installer.",
LightlessConfiguration.Models.NotificationType.Error));
}
_logger.LogInformation("{csvPath} found, parsing", _csvPath);
bool success = false;
string[] entries = [];
int attempts = 0;
while (!success && attempts < 10)
{
try
{
_logger.LogInformation("Attempting to read {csvPath}", _csvPath);
entries = File.ReadAllLines(_csvPath);
success = true;
}
catch (Exception ex)
{
attempts++;
_logger.LogWarning(ex, "Could not open {file}, trying again", _csvPath);
Task.Delay(100, cancellationToken);
}
}
if (!entries.Any())
{
_logger.LogWarning("Could not load entries from {path}, continuing with empty file cache", _csvPath);
}
_logger.LogInformation("Found {amount} files in {path}", entries.Length, _csvPath);
Dictionary<string, bool> processedFiles = new(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
{
var splittedEntry = entry.Split(CsvSplit, StringSplitOptions.None);
try
{
var hash = splittedEntry[0];
if (hash.Length != 40) throw new InvalidOperationException("Expected Hash length of 40, received " + hash.Length);
var path = splittedEntry[1];
var time = splittedEntry[2];
if (processedFiles.ContainsKey(path))
{
_logger.LogWarning("Already processed {file}, ignoring", path);
continue;
}
processedFiles.Add(path, value: true);
long size = -1;
long compressed = -1;
if (splittedEntry.Length > 3)
{
if (long.TryParse(splittedEntry[3], CultureInfo.InvariantCulture, out long result))
{
size = result;
}
if (long.TryParse(splittedEntry[4], CultureInfo.InvariantCulture, out long resultCompressed))
{
compressed = resultCompressed;
}
}
AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed)));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to initialize entry {entry}, ignoring", entry);
}
}
if (processedFiles.Count != entries.Length)
{
WriteOutFullCsv();
}
}
_logger.LogInformation("Started FileCacheManager");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
WriteOutFullCsv();
return Task.CompletedTask;
}
}