diff --git a/src/GIMI-ModManager.Core/Services/ArchiveService.cs b/src/GIMI-ModManager.Core/Services/ArchiveService.cs new file mode 100644 index 00000000..03a6cee9 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/ArchiveService.cs @@ -0,0 +1,159 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using Serilog; +using SharpCompress.Archives; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; + +namespace GIMI_ModManager.Core.Services; + +public class ArchiveService +{ + private readonly ILogger _logger; + private readonly ExtractTool _extractTool; + + public ArchiveService(ILogger logger) + { + _logger = logger.ForContext(); + _extractTool = GetExtractTool(); + } + + public DirectoryInfo ExtractArchive(string archivePath, string destinationPath, bool overwritePath = false) + { + var archive = new FileInfo(archivePath); + if (!archive.Exists) + throw new FileNotFoundException("Archive not found", archivePath); + + if (!IsArchive(archivePath)) + throw new InvalidOperationException("File is not an archive"); + + var destinationDirectory = Directory.CreateDirectory(destinationPath); + + var extractedFolder = Path.Combine(destinationDirectory.FullName, archive.Name); + + if (Directory.Exists(extractedFolder)) + throw new InvalidOperationException("Destination folder already exists, could not extract folder"); + + Directory.CreateDirectory(extractedFolder); + + var extractor = Extractor(extractedFolder); + + extractor?.Invoke(archive.FullName, extractedFolder); + + return new DirectoryInfo(extractedFolder); + } + + // https://stackoverflow.com/a/31349703 + public async Task CalculateFileMd5HashAsync(string filePath, CancellationToken cancellationToken = default) + { + var file = new FileInfo(filePath); + if (!file.Exists) + throw new FileNotFoundException("File not found", filePath); + + + using var md5 = MD5.Create(); + await using var stream = file.OpenRead(); + var hash = await md5.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); + var convertedHash = BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + return convertedHash; + } + + public bool IsHashEqual(byte[] hash1, byte[] hash2) + { + return hash1.SequenceEqual(hash2); + } + + private bool IsArchive(string path) + { + return Path.GetExtension(path) switch + { + ".zip" => true, + ".rar" => true, + ".7z" => true, + _ => false + }; + } + + private Action? Extractor(string archivePath) + { + Action? action = null; + + if (_extractTool == ExtractTool.Bundled7Zip) + action = Extract7Z; + else if (_extractTool == ExtractTool.SharpCompress) + action = Path.GetExtension(archivePath) switch + { + ".zip" => SharpExtract, + ".rar" => SharpExtract, + ".7z" => SharpExtract, + _ => null + }; + else if (_extractTool == ExtractTool.System7Zip) throw new NotImplementedException(); + + return action; + } + + private void ExtractEntries(IArchive archive, string extractPath) + { + _logger.Information("Extracting {ArchiveType} archive", archive.Type); + foreach (var entry in archive.Entries) + { + _logger.Debug("Extracting {EntryName}", entry.Key); + entry.WriteToDirectory(extractPath, new ExtractionOptions() + { + ExtractFullPath = true, + Overwrite = true, + PreserveFileTime = false + }); + } + } + + private void SharpExtract(string archivePath, string extractPath) + { + using var archive = ZipArchive.Open(archivePath); + ExtractEntries(archive, extractPath); + } + + + private enum ExtractTool + { + Bundled7Zip, // 7zip bundled with JASM + SharpCompress, // SharpCompress library + System7Zip // 7zip installed on the system + } + + private ExtractTool GetExtractTool() + { + var bundled7ZFolder = Path.Combine(AppContext.BaseDirectory, @"Assets\7z\"); + if (File.Exists(Path.Combine(bundled7ZFolder, "7z.exe")) && + File.Exists(Path.Combine(bundled7ZFolder, "7-zip.dll")) && + File.Exists(Path.Combine(bundled7ZFolder, "7z.dll"))) + { + _logger.Debug("Using bundled 7zip"); + return ExtractTool.Bundled7Zip; + } + + _logger.Information("Bundled 7zip not found, using SharpCompress library"); + return ExtractTool.SharpCompress; + } + + + private void Extract7Z(string archivePath, string extractPath) + { + var sevenZipPath = Path.Combine(AppContext.BaseDirectory, @"Assets\7z\7z.exe"); + var process = new Process + { + StartInfo = + { + FileName = sevenZipPath, + Arguments = $"x \"{archivePath}\" -o\"{extractPath}\" -y", + UseShellExecute = false, + CreateNoWindow = true + } + }; + _logger.Information("Extracting 7z archive with command: {Command}", process.StartInfo.Arguments); + process.Start(); + process.WaitForExit(); + _logger.Information("7z extraction finished with exit code {ExitCode}", process.ExitCode); + } +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/ApiGameBananaCache.cs b/src/GIMI-ModManager.Core/Services/GameBanana/ApiGameBananaCache.cs new file mode 100644 index 00000000..171a391d --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/ApiGameBananaCache.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; + +namespace GIMI_ModManager.Core.Services.GameBanana; + +internal sealed class ApiGameBananaCache +{ + private readonly ConcurrentDictionary> _cache = new(); + + private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5); + + internal ApiGameBananaCache() + { + } + + internal ApiGameBananaCache(TimeSpan cacheDuration) + { + _cacheDuration = cacheDuration; + } + + public T? Get(string key) where T : class + { + ClearExpiredEntries(); + + key = CreateKey(key, typeof(T)); + + if (_cache.TryGetValue(key, out var entry)) + { + if (!entry.IsExpired) + { + return (T)entry.Value; + } + + _cache.TryRemove(key, out _); + } + + return null; + } + + + public void Set(string key, T value, TimeSpan? cacheDuration = null) where T : class + { + ClearExpiredEntries(); + key = CreateKey(key, typeof(T)); + + _cache[key] = new CacheEntry(value, cacheDuration ?? _cacheDuration); + } + + + public void ClearExpiredEntries() + { + foreach (var (key, entry) in _cache.ToArray()) + { + if (entry.IsExpired) + { + _cache.TryRemove(key, out _); + } + } + } + + public void ClearAllEntries() + { + _cache.Clear(); + } + + private static string CreateKey(string key, Type type) => $"{type.Name}_{key}"; +} + +internal sealed class CacheEntry +{ + public T Value { get; } + public DateTime Creation { get; } + public DateTime Expiration => Creation.Add(CacheDuration); + public bool IsExpired => DateTime.Now > Expiration; + + public TimeSpan CacheDuration { get; } + + public CacheEntry(T value, TimeSpan cacheDuration) + { + Value = value; + Creation = DateTime.Now; + CacheDuration = cacheDuration; + } +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/ApiGameBananaClient.cs b/src/GIMI-ModManager.Core/Services/GameBanana/ApiGameBananaClient.cs new file mode 100644 index 00000000..40b02dfe --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/ApiGameBananaClient.cs @@ -0,0 +1,231 @@ +using System.Diagnostics; +using System.Net; +using System.Text.Json; +using GIMI_ModManager.Core.Services.GameBanana.ApiModels; +using GIMI_ModManager.Core.Services.GameBanana.Models; +using Polly; +using Polly.RateLimiting; +using Polly.Registry; +using Serilog; + +namespace GIMI_ModManager.Core.Services.GameBanana; + +public sealed class ApiGameBananaClient( + ILogger logger, + HttpClient httpClient, + ResiliencePipelineProvider resiliencePipelineProvider) + : IApiGameBananaClient +{ + private readonly ILogger _logger = logger.ForContext(); + private readonly HttpClient _httpClient = httpClient; + private readonly ResiliencePipeline _resiliencePipeline = resiliencePipelineProvider.GetPipeline(HttpClientName); + public const string HttpClientName = "GameBanana"; + + private const string DownloadUrl = "https://gamebanana.com/dl/"; + private const string ApiUrl = "https://gamebanana.com/apiv11/Mod/"; + private const string HealthCheckUrl = "https://gamebanana.com/apiv11"; + + public async Task HealthCheckAsync(CancellationToken cancellationToken = default) + { + using var response = await _httpClient.GetAsync(HealthCheckUrl, cancellationToken).ConfigureAwait(false); + + foreach (var (key, value) in response.Headers) + { + if (key.Contains("Deprecation", StringComparison.OrdinalIgnoreCase) || + key.Contains("Deprecated", StringComparison.OrdinalIgnoreCase)) + { + _logger.Warning("GameBanana API is deprecated: {Key}={Value}", key, value); + Debugger.Break(); + break; + } + } + + return response.StatusCode == HttpStatusCode.OK; + } + + public async Task GetModProfileAsync(GbModId modId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(modId); + + var modPageApiUrl = GetModInfoUrl(modId); + + var response = await SendRequest(modPageApiUrl, cancellationToken).ConfigureAwait(false); + + _logger.Debug("Got response from GameBanana: {response}", response.StatusCode); + await using var contentStream = + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + var apiResponse = + await JsonSerializer.DeserializeAsync(contentStream, + cancellationToken: cancellationToken).ConfigureAwait(false); + + + if (apiResponse == null) + { + _logger.Error("Failed to deserialize GameBanana response: {content}", contentStream); + throw new HttpRequestException( + $"Failed to deserialize GameBanana response. Reason: {response?.ReasonPhrase}"); + } + + return apiResponse; + } + + public async Task GetModFilesInfoAsync(GbModId modId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(modId); + + var requestUrl = GetModFilesInfoUrl(modId); + + var response = await SendRequest(requestUrl, cancellationToken).ConfigureAwait(false); + + _logger.Debug("Got response from GameBanana: {response}", response.StatusCode); + await using var contentStream = + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + var apiResponse = + await JsonSerializer.DeserializeAsync(contentStream, + cancellationToken: cancellationToken).ConfigureAwait(false); + + + if (apiResponse == null) + { + _logger.Error("Failed to deserialize GameBanana response: {content}", contentStream); + throw new HttpRequestException( + $"Failed to deserialize GameBanana response. Reason: {response?.ReasonPhrase}"); + } + + return apiResponse; + } + + public async Task GetModFileInfoAsync(GbModId modId, GbModFileId modFileId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(modFileId); + + var modFilesInfo = await GetModFilesInfoAsync(modId, cancellationToken).ConfigureAwait(false); + + return modFilesInfo?.Files.FirstOrDefault(x => x.FileId.ToString() == modFileId); + } + + public async Task ModFileExists(GbModFileId modFileId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(modFileId); + + var requestUrl = GetAltUrlForModInfo(modFileId); + + var response = await SendRequest(requestUrl, cancellationToken).ConfigureAwait(false); + + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + return !content.Contains("error:", StringComparison.OrdinalIgnoreCase); + } + + private static Uri GetAltUrlForModInfo(GbModFileId modFileId) + { + return new Uri( + $"https://api.gamebanana.com/Core/Item/Data?itemid={modFileId}&itemtype=File&fields=file"); + } + + public async Task DownloadModAsync(GbModFileId modFileId, FileStream destinationFile, IProgress? progress, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modFileId, nameof(modFileId)); + ArgumentNullException.ThrowIfNull(destinationFile); + var downloadUrl = DownloadUrl + modFileId; + + + using var response = await _httpClient + .GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.NotFound) + throw new InvalidOperationException("Mod not found."); + + if (response.StatusCode != HttpStatusCode.OK) + throw new HttpRequestException( + $"Failed to download mod from GameBanana. Reason: {response?.ReasonPhrase}"); + + var contentLength = response.Content.Headers.ContentLength; + + await using var downloadStream = + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + if (contentLength is not null && progress is not null) + _ = Task.Run(() => DownloadMonitor(contentLength.Value, destinationFile.Name, progress, cancellationToken), + cancellationToken); + + await downloadStream.CopyToAsync(destinationFile, cancellationToken).ConfigureAwait(false); + } + + private static async Task DownloadMonitor(long totalSizeBytes, string downloadFilePath, IProgress progress, + CancellationToken cancellationToken = default) + { + try + { + var file = new FileInfo(downloadFilePath); + while (!cancellationToken.IsCancellationRequested && file.Length < totalSizeBytes) + { + file.Refresh(); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + var fileSize = file.Length; + progress.Report((int)Math.Round((decimal)fileSize / (decimal)totalSizeBytes * 100)); + } + } + catch (Exception e) + { +#if DEBUG + throw; +#endif + } + } + + private Uri GetModFilesInfoUrl(GbModId gbModId) + { + return new Uri(ApiUrl + gbModId + "/DownloadPage"); + } + + private Uri GetModInfoUrl(GbModId gbModId) + { + return new Uri(ApiUrl + gbModId + "/ProfilePage"); + } + + private static void ValidateGameBananaUrl(Uri url) + { + if (!url.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) || + !url.Host.Equals("gamebanana.com", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException($"Invalid GameBanana url: {url}", nameof(url)); + } + + private async Task SendRequest(Uri downloadsApiUrl, CancellationToken cancellationToken) + { + HttpResponseMessage response; + retry: + try + { + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + response = await _resiliencePipeline.ExecuteAsync( + (ct) => new ValueTask(_httpClient.GetAsync(downloadsApiUrl, ct)), + cancellationToken) + .ConfigureAwait(false); + } + catch (RateLimiterRejectedException e) + { + _logger.Debug("Rate limit exceeded, retrying after {retryAfter}", e.RetryAfter); + var delay = e.RetryAfter ?? TimeSpan.FromSeconds(2); + + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + goto retry; + } + + + if (!response.IsSuccessStatusCode) + { + _logger.Error("Failed to get mod info from GameBanana: {response} | Url: {Url}", response, + downloadsApiUrl); + throw new HttpRequestException( + $"Failed to get mod info from GameBanana. Reason: {response?.ReasonPhrase ?? "Unknown"} | Url: {downloadsApiUrl}"); + } + + return response; + } +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiMinimalModFileInfo.cs b/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiMinimalModFileInfo.cs new file mode 100644 index 00000000..e453b19f --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiMinimalModFileInfo.cs @@ -0,0 +1,6 @@ +namespace GIMI_ModManager.Core.Services.GameBanana.ApiModels; + +internal class ApiMinimalModFileInfo +{ + // Note: https://api.gamebanana.com/Core/Item/Data?itemid=1178011&itemtype=File&return_keys=true&fields=file,date,sModManagerDownloadUrl() +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiModFileInfo.cs b/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiModFileInfo.cs new file mode 100644 index 00000000..76627573 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiModFileInfo.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace GIMI_ModManager.Core.Services.GameBanana.ApiModels; + +public class ApiModFileInfo +{ + [JsonPropertyName("_idRow")] public int FileId { get; init; } = -1; + [JsonPropertyName("_sFile")] public string FileName { get; init; } = null!; + [JsonPropertyName("_sDownloadUrl")] public string DownloadUrl { get; init; } = null!; + + [JsonPropertyName("_tsDateAdded")] public int DateAdded { get; init; } = -1; + + [JsonPropertyName("_sDescription")] public string Description { get; init; } = null!; + + [JsonPropertyName("_nFilesize")] public int FileSize { get; init; } = -1; + + [JsonPropertyName("_sAnalysisResultCode")] + public string AnalysisResultCode { get; init; } = null!; + + [JsonPropertyName("_sMd5Checksum")] public string Md5Checksum { get; init; } = null!; + + [JsonPropertyName("_nDownloadCount")] public int DownloadCount { get; init; } = -1; +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiModFilesInfo.cs b/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiModFilesInfo.cs new file mode 100644 index 00000000..2d530dbb --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiModFilesInfo.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace GIMI_ModManager.Core.Services.GameBanana.ApiModels; + +public class ApiModFilesInfo +{ + [JsonPropertyName("_bIsTrashed")] public bool IsTrashed { get; init; } + [JsonPropertyName("_bIsWithheld")] public bool IsWithheld { get; init; } + + [JsonPropertyName("_aFiles")] public ICollection Files { get; init; } = []; +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiModProfile.cs b/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiModProfile.cs new file mode 100644 index 00000000..f9601ea2 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/ApiModels/ApiModProfile.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace GIMI_ModManager.Core.Services.GameBanana.ApiModels; + +public class ApiModProfile +{ + [JsonPropertyName("_idRow")] public int ModId { get; init; } = -1; + [JsonPropertyName("_sName")] public string? ModName { get; init; } + + [JsonPropertyName("_aSubmitter")] public ApiAuthor? Author { get; init; } + [JsonPropertyName("_aPreviewMedia")] public ApiImagesRoot? PreviewMedia { get; init; } + + [JsonPropertyName("_sProfileUrl")] public string? ModPageUrl { get; init; } + + [JsonPropertyName("_aFiles")] public ICollection? Files { get; init; } +} + +public sealed class ApiAuthor +{ + [JsonPropertyName("_sName")] public string? AuthorName { get; init; } + [JsonPropertyName("_sAvatarUrl")] public string? AvatarImageUrl { get; init; } + [JsonPropertyName("_sProfileUrl")] public string? ProfileUrl { get; init; } +} + +public sealed class ApiImagesRoot +{ + [JsonPropertyName("_aImages")] public ApiImageUrl[] Images { get; init; } = []; +} + +public sealed class ApiImageUrl +{ + [JsonPropertyName("_sFile")] public string? ImageId { get; init; } + [JsonPropertyName("_sBaseUrl")] public string? BaseUrl { get; init; } +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/GameBananaCoreService.cs b/src/GIMI-ModManager.Core/Services/GameBanana/GameBananaCoreService.cs new file mode 100644 index 00000000..5b5f4b33 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/GameBananaCoreService.cs @@ -0,0 +1,206 @@ +using System.Collections.Concurrent; +using GIMI_ModManager.Core.Services.GameBanana.ApiModels; +using GIMI_ModManager.Core.Services.GameBanana.Models; +using Microsoft.Extensions.DependencyInjection; +using Serilog; + +namespace GIMI_ModManager.Core.Services.GameBanana; + +/// +/// A Higher level service that provides functionality to interact with GameBanana. That uses caching to reduce the number of API calls. +/// +public sealed class GameBananaCoreService( + IServiceProvider serviceProvider, + ILogger logger, + ModArchiveRepository modArchiveRepository) +{ + private readonly IServiceProvider _serviceProvider = serviceProvider; + private readonly ILogger _logger = logger.ForContext(); + private readonly ModArchiveRepository _modArchiveRepository = modArchiveRepository; + + private readonly ApiGameBananaCache _cache = new(cacheDuration: TimeSpan.FromMinutes(10)); + + + private IApiGameBananaClient CreateApiGameBananaClient() => + _serviceProvider.GetRequiredService(); + + /// + /// Checks if the GameBanana API is reachable + /// + public async Task HealthCheckAsync(CancellationToken ct = default) + { + var apiGameBananaClient = CreateApiGameBananaClient(); + + return await apiGameBananaClient.HealthCheckAsync(ct).ConfigureAwait(false); + } + + /// + /// Gets the profile of a mod from GameBanana. Uses caching to reduce the number of API calls. + /// The return type also contains mod files info + /// + public async Task GetModProfileAsync(GbModId modId, CancellationToken ct = default) + { + var cachedModProfile = _cache.Get(modId); + + if (cachedModProfile != null) + return cachedModProfile; + + var apiGameBananaClient = CreateApiGameBananaClient(); + + var apiModProfile = await apiGameBananaClient.GetModProfileAsync(modId, ct).ConfigureAwait(false); + + if (apiModProfile == null) + return null; + + + var modInfo = new ModPageInfo(apiModProfile); + + _cache.Set(modId, modInfo); + + return modInfo; + } + + /// + /// Tries to get a locally cached mod archive by its MD5 hash + /// + public async Task GetLocalModArchiveByMd5HashAsync(string md5Hash, + CancellationToken ct = default) => + await _modArchiveRepository.FirstOrDefaultAsync(modFile => modFile.MD5Hash == md5Hash, ct) + .ConfigureAwait(false); + + /// + /// Gets the files info of a mod from GameBanana. Uses caching to reduce the number of API calls. Is more lightweight than > + /// + /// + /// + /// + public async Task?> GetModFilesInfoAsync(GbModId modId, CancellationToken ct = default) + { + var apiGameBananaClient = CreateApiGameBananaClient(); + + var modFilesInfo = await GetModFilesInfoAsync(apiGameBananaClient, modId, ct: ct).ConfigureAwait(false); + + if (modFilesInfo == null) + return null; + + return new List(modFilesInfo.Files.Select(x => new ModFileInfo(x, modId))); + } + + /// + /// Makes requests in parallel + /// + public async Task> ModFilesExists( + IEnumerable modFileIdentifier, CancellationToken ct = default) + { + var apiGameBananaClient = CreateApiGameBananaClient(); + + + var results = new ConcurrentDictionary(); + + await Parallel.ForEachAsync(modFileIdentifier, ct, async (i, cancellationToken) => + { + var modFileExists = _cache.Get(i.ModFileId); + + if (modFileExists is null) + { + var result = await apiGameBananaClient.ModFileExists(i, cancellationToken) + .ConfigureAwait(false); + + modFileExists = new ExistsResult { Exists = result }; + + _cache.Set(i.ModFileId, modFileExists); + } + + results[i] = modFileExists.Exists; + }).ConfigureAwait(false); + + return new Dictionary(results); + } + + private class ExistsResult + { + public required bool Exists { get; init; } + } + + /// + /// Downloads a mod from GameBanana. Uses caching to reduce the number of API calls and checks archive cache before downloading. + /// + /// + /// An IProgress that can be used to monitor progress. Goes from 0 to 100 + /// + /// The Absolute path to the downloaded archive + /// + public async Task DownloadModAsync(GbModFileIdentifier modFileIdentifier, IProgress? progress = null, + CancellationToken ct = default) + { + var cachedDataUsed = true; + var modFilesInfo = _cache.Get(modFileIdentifier.ModId); + var apiGameBananaClient = CreateApiGameBananaClient(); + + if (modFilesInfo is null) + { + modFilesInfo = await GetModFilesInfoAsync(apiGameBananaClient, modFileIdentifier.ModId, ct: ct) + .ConfigureAwait(false); + + if (modFilesInfo == null) + throw new InvalidOperationException($"Mod with id {modFileIdentifier.ModId} not found"); + + cachedDataUsed = false; + } + + var modFileInfo = modFilesInfo.Files.FirstOrDefault(x => x.FileId.ToString() == modFileIdentifier.ModFileId); + if (modFileInfo is null) + { + if (cachedDataUsed) + // Mod file not found in cache, try to get it directly from the API + modFileInfo = (await GetModFilesInfoAsync(apiGameBananaClient, modFileIdentifier.ModId, true, ct) + .ConfigureAwait(false)) + ?.Files.FirstOrDefault(x => x.FileId.ToString() == modFileIdentifier.ModFileId); + + if (modFileInfo is null) + throw new InvalidOperationException($"Mod file with id {modFileIdentifier.ModFileId} not found"); + } + + + var modArchiveHandle = + await GetLocalModArchiveByMd5HashAsync(modFileInfo.Md5Checksum, ct).ConfigureAwait(false); + + if (modArchiveHandle != null) + // Mod archive already exists locally + return modArchiveHandle.FullName; + + + modArchiveHandle = await _modArchiveRepository.CreateAndTrackModArchiveAsync( + async (fileStream) => + { + await apiGameBananaClient.DownloadModAsync(modFileIdentifier.ModFileId, fileStream, progress, ct) + .ConfigureAwait(false); + return modFileInfo.FileName; + }, modFileIdentifier, cancellationToken: ct).ConfigureAwait(false); + + return modArchiveHandle.FullName; + } + + + private async Task GetModFilesInfoAsync( + IApiGameBananaClient apiClient, GbModId modId, + bool ignoreCache = false, CancellationToken ct = default) + { + if (!ignoreCache) + { + var cachedModFilesInfo = _cache.Get(modId); + if (cachedModFilesInfo != null) + return cachedModFilesInfo; + } + + + var modFilesInfo = await apiClient.GetModFilesInfoAsync(modId, ct) + .ConfigureAwait(false); + + if (modFilesInfo == null) + return null; + + _cache.Set(modId, modFilesInfo); + return modFilesInfo; + } +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/IApiGameBananaClient.cs b/src/GIMI-ModManager.Core/Services/GameBanana/IApiGameBananaClient.cs new file mode 100644 index 00000000..223d8600 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/IApiGameBananaClient.cs @@ -0,0 +1,57 @@ +using GIMI_ModManager.Core.Services.GameBanana.ApiModels; +using GIMI_ModManager.Core.Services.GameBanana.Models; + +namespace GIMI_ModManager.Core.Services.GameBanana; + +public interface IApiGameBananaClient +{ + /// + /// Checks if the GameBanana API is reachable. + /// + public Task HealthCheckAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the mod profile from the GameBanana API. + /// + /// The Game banana's mod Id + /// + /// ApiModProfile if mod exists or null + public Task GetModProfileAsync(GbModId modId, CancellationToken cancellationToken = default); + + /// + /// Gets the mod files info from the GameBanana API. + /// + /// The Game banana's mod Id + /// + /// ApiModFilesInfo if mod exists or null + public Task GetModFilesInfoAsync(GbModId modId, CancellationToken cancellationToken = default); + + + /// + /// Gets the mod file info from the GameBanana API. + /// + /// The Game banana's mod Id + /// The Game banana's mod files Id + /// + /// ApiModFileInfo if file exists or null + [Obsolete("Use GetModFilesInfoAsync instead")] + public Task GetModFileInfoAsync(GbModId modId, GbModFileId modFileId, + CancellationToken cancellationToken = default); + + /// + /// Checks if the mod file exists on GameBanana. + /// + public Task ModFileExists(GbModFileId modFileId, CancellationToken cancellationToken = default); + + /// + /// Download mod file from GameBanana. + /// + /// The Game banana's mod files Id + /// File stream to write the contents to + /// Reports to as a percentage from 0 to 100 + /// Cancels the download but does not delete the destinationFile + /// When mod is not found + /// + public Task DownloadModAsync(GbModFileId modFileId, FileStream destinationFile, IProgress? progress, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/ModArchiveRepository.cs b/src/GIMI-ModManager.Core/Services/GameBanana/ModArchiveRepository.cs new file mode 100644 index 00000000..36c28a66 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/ModArchiveRepository.cs @@ -0,0 +1,334 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using GIMI_ModManager.Core.Services.GameBanana.Models; +using Serilog; + +namespace GIMI_ModManager.Core.Services.GameBanana; + +public sealed class ModArchiveRepository +{ + private readonly ILogger _logger; + private readonly ArchiveService _archiveService; + + + private readonly ConcurrentDictionary _modArchives = new(); + + private DirectoryInfo _settingsDirectory = null!; + private DirectoryInfo _modArchiveDirectory = null!; + private const string ModArchiveDirectoryName = "ModDownloads"; + + private const string DownloadTempPrefix = ".TMP_DOWNLOAD"; + public const string Separator = "_!!_"; + + private int _maxDirectorySizeGb; + + public ModArchiveRepository(ILogger logger, ArchiveService archiveService) + { + _archiveService = archiveService; + _logger = logger.ForContext(); + } + + + public async Task InitializeAsync(string appDataFolder, Action? setup = null) + { + ReadOptions(setup); + + _settingsDirectory = new DirectoryInfo(appDataFolder); + _settingsDirectory.Create(); + + _modArchiveDirectory = new DirectoryInfo(Path.Combine(appDataFolder, ModArchiveDirectoryName)); + _modArchiveDirectory.Create(); + + var deleteTasks = new List(); + + foreach (var archive in _modArchiveDirectory.EnumerateFiles().ToArray()) + { + try + { + var archiveHandle = ModArchiveHandle.FromManagedFile(EnsureValidArchive(archive.FullName)); + + if (_modArchives.ContainsKey(archiveHandle.FullName)) + { + _logger.Debug("Duplicate archive found, deleting: {ArchiveName}", archiveHandle.FullName); + + + deleteTasks.Add(Task.Run(archive.Delete)); + continue; + } + + TrackArchive(archiveHandle); + } + catch (Exception e) + { + _logger.Information("Invalid archive, deleting: {ArchiveName}", archive.FullName); + deleteTasks.Add(Task.Run(archive.Delete)); + } + } + + await Task.WhenAll(deleteTasks).ConfigureAwait(false); + + var _ = Task.Run(RemoveUntilUnderMaxSize); + } + + private void ReadOptions(Action? setup = null) + { + var options = new SetupOptions(); + setup?.Invoke(options); + _maxDirectorySizeGb = options.MaxDirectorySizeGb; + } + + public async Task CopyAndTrackModArchiveAsync(string archivePath, + GbModFileIdentifier modFileIdentifier, + CancellationToken cancellationToken = default) + { + var archiveFile = EnsureValidArchive(archivePath); + + await foreach (var modArchiveHandle in GetModArchivesAsync(cancellationToken).ConfigureAwait(false)) + { + if (modArchiveHandle.FullName.Equals(archiveFile.FullName, StringComparison.OrdinalIgnoreCase)) + return modArchiveHandle; + } + + var archiveHash = await _archiveService.CalculateFileMd5HashAsync(archiveFile.FullName, cancellationToken) + .ConfigureAwait(false); + var newName = await GetManagedArchiveNameAsync(archiveFile.Name, modFileIdentifier, archiveHash) + .ConfigureAwait(false); + + archiveFile = archiveFile.CopyTo(Path.Combine(_modArchiveDirectory.FullName, newName), true); + + var archiveHandle = ModArchiveHandle.FromManagedFile(archiveFile); + TrackArchive(archiveHandle); + return archiveHandle; + } + + + public async Task CreateAndTrackModArchiveAsync( + Func> streamToWrite, GbModFileIdentifier modIdentifier, + CancellationToken cancellationToken = default) + { + var newFile = + new FileInfo(Path.Combine(_modArchiveDirectory.FullName, $"{DownloadTempPrefix}_{Guid.NewGuid()}")); + + if (newFile.Exists) + newFile.Delete(); + + + try + { + var fileStream = newFile.Create(); + + var archiveName = ""; + await using (fileStream.ConfigureAwait(false)) + { + archiveName = await streamToWrite(fileStream).ConfigureAwait(false); + } + + var archiveHash = await _archiveService.CalculateFileMd5HashAsync(newFile.FullName, cancellationToken) + .ConfigureAwait(false); + + var newName = await GetManagedArchiveNameAsync(archiveName, modIdentifier, archiveHash) + .ConfigureAwait(false); + + newFile.MoveTo(Path.Combine(_modArchiveDirectory.FullName, newName), true); + } + catch (Exception e) + { + newFile.Delete(); + + _logger.Error(e, "Failed to download mod archive"); + throw; + } + + + var archiveHandle = ModArchiveHandle.FromManagedFile(newFile); + TrackArchive(archiveHandle); + return archiveHandle; + } + + public async Task FirstOrDefaultAsync(Predicate predicate, + CancellationToken cancellationToken = default) + { + await foreach (var archiveHandle in GetModArchivesAsync(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (predicate(archiveHandle)) + { + return archiveHandle; + } + } + + return null; + } + + public async IAsyncEnumerable GetModArchivesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var archives = _modArchives.ToArray(); + + foreach (var archiveHandle in archives) + { + cancellationToken.ThrowIfCancellationRequested(); + archiveHandle.Value.Refresh(); + + if (!archiveHandle.Value.Exists) + { + _modArchives.TryRemove(archiveHandle.Key, out _); + continue; + } + + yield return archiveHandle.Value; + } + } + + + private void TrackArchive(ModArchiveHandle archiveHandle) + { + ArgumentNullException.ThrowIfNull(archiveHandle); + _modArchives.AddOrUpdate(archiveHandle.FullName, (_) => archiveHandle, (_, _) => archiveHandle); + } + + private Task GetManagedArchiveNameAsync(string archiveFileName, GbModFileIdentifier modFileIdentifier, + string? archiveHash) + { + ArgumentNullException.ThrowIfNull(modFileIdentifier); + var archiveName = Path.GetFileNameWithoutExtension(archiveFileName); + var archiveExtension = Path.GetExtension(archiveFileName); + + var hash = archiveHash ?? "|REPLACE|"; + + return Task.FromResult( + $"{archiveName}{Separator}{modFileIdentifier.ModId}{Separator}{modFileIdentifier.ModFileId}{Separator}{hash}{archiveExtension}"); + } + + private Task GetManagedArchiveNameAsync(ModArchiveHandle archiveHandle) + { + var modId = new GbModId(archiveHandle.ModId); + var modFileId = new GbModFileId(archiveHandle.ModFileId); + + var archiveHash = archiveHandle.MD5Hash; + + return GetManagedArchiveNameAsync(archiveHandle.Name, new GbModFileIdentifier(modId, modFileId), archiveHash); + } + + private static FileInfo EnsureValidArchive(string archivePath) + { + var archiveFileName = Path.GetFileNameWithoutExtension(archivePath); + var archiveExtension = Path.GetExtension(archivePath); + + if (string.IsNullOrEmpty(archiveFileName) || string.IsNullOrEmpty(archiveExtension)) + throw new ArgumentException($"Invalid archive file path: {archivePath}", nameof(archivePath)); + + if (!File.Exists(archivePath)) + throw new FileNotFoundException("Archive not found", archivePath); + + var archiveFile = new FileInfo(archivePath); + + return archiveFile; + } + + + private void RemoveUntilUnderMaxSize() + { + // Remove archives until the directory is under the max size + var currentSize = _modArchives.Sum(x => x.Value.SizeInGb); + + if (currentSize > _maxDirectorySizeGb) + { + _logger.Information( + "Mod archive directory is over the size limit, removing archives. Limit {MaxLimit} | CurrentSize: {CurrentSize}", + _maxDirectorySizeGb, currentSize); + } + + + while (_modArchives.Sum(x => x.Value.SizeInGb) > _maxDirectorySizeGb) + { + var oldest = _modArchives.MinBy(x => x.Value.LastWriteTime); + _modArchives.TryRemove(oldest.Key, out _); + + if (oldest.Value.Exists) + File.Delete(oldest.Value.FullName); + oldest.Value.Refresh(); + } + } +} + +public class ModArchiveHandle +{ + private readonly FileInfo _archiveFile; + + public string ModId { get; private set; } + public string ModFileId { get; private set; } + public string ModFileName { get; private set; } + public string MD5Hash { get; private set; } + + public string FullName => _archiveFile.FullName.ToLowerInvariant(); + + public string Name => _archiveFile.Name.ToLowerInvariant(); + + public bool Exists => _archiveFile.Exists; + + public double SizeInGb => _archiveFile.Length / 1024D / 1024D / 1024D; + + public DateTime LastWriteTime => _archiveFile.LastWriteTime; + + public void Refresh() => _archiveFile.Refresh(); + + private ModArchiveHandle(string modId, string modFileId, string modFileName, string md5Hash, FileInfo archiveFile) + { + ModId = modId; + ModFileId = modFileId; + ModFileName = modFileName; + MD5Hash = md5Hash; + _archiveFile = archiveFile; + } + + internal static ModArchiveHandle FromManagedFile(FileInfo archiveFile) + { + var archiveFileName = Path.GetFileNameWithoutExtension(archiveFile.Name); + + var sections = archiveFileName.Split(ModArchiveRepository.Separator); + + if (sections.Length != 4) + throw new InvalidArchiveNameFormatException(); + + var modFileName = sections[0]; + var modId = sections[1]; + var modFileId = sections[2]; + var md5Hash = sections[3]; + + if (string.IsNullOrEmpty(modFileName) || string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(modFileId) || + string.IsNullOrEmpty(md5Hash)) + throw new InvalidArchiveNameFormatException(); + + + return new ModArchiveHandle(modId, modFileId, modFileName, md5Hash, archiveFile); + } +} + +public class InvalidArchiveNameFormatException : Exception +{ + public InvalidArchiveNameFormatException(string message) : base(message) + { + } + + public InvalidArchiveNameFormatException() : base("Invalid archive name format") + { + } +} + +public class SetupOptions +{ + private int _maxDirectorySizeGb = 10; + + public int MaxDirectorySizeGb + { + get => _maxDirectorySizeGb; + set + { + if (value <= 0) + _maxDirectorySizeGb = 0; + + _maxDirectorySizeGb = value; + } + } +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/Models/GbModFileId.cs b/src/GIMI-ModManager.Core/Services/GameBanana/Models/GbModFileId.cs new file mode 100644 index 00000000..b08e6748 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/Models/GbModFileId.cs @@ -0,0 +1,20 @@ +namespace GIMI_ModManager.Core.Services.GameBanana.Models; + +public record GbModFileId +{ + public string ModFileId { get; } + + public GbModFileId(string modFileId) + { + ModFileId = modFileId; + } + + public GbModFileId(int modFileId) + { + ModFileId = modFileId.ToString(); + } + + public override string ToString() => ModFileId; + + public static implicit operator string(GbModFileId modFileId) => modFileId.ToString(); +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/Models/GbModFileIdentifier.cs b/src/GIMI-ModManager.Core/Services/GameBanana/Models/GbModFileIdentifier.cs new file mode 100644 index 00000000..8cc062a0 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/Models/GbModFileIdentifier.cs @@ -0,0 +1,6 @@ +namespace GIMI_ModManager.Core.Services.GameBanana.Models; + +/// +/// Represents a unique identifier for a mod file on GameBanana. +/// +public record GbModFileIdentifier(GbModId ModId, GbModFileId ModFileId); \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/Models/GbModId.cs b/src/GIMI-ModManager.Core/Services/GameBanana/Models/GbModId.cs new file mode 100644 index 00000000..0308dcf0 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/Models/GbModId.cs @@ -0,0 +1,20 @@ +namespace GIMI_ModManager.Core.Services.GameBanana.Models; + +public record GbModId +{ + public string ModId { get; } + + public GbModId(string modId) + { + ModId = modId; + } + + public GbModId(int modId) + { + ModId = modId.ToString(); + } + + public override string ToString() => ModId; + + public static implicit operator string(GbModId modId) => modId.ToString(); +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/Models/ModFileInfo.cs b/src/GIMI-ModManager.Core/Services/GameBanana/Models/ModFileInfo.cs new file mode 100644 index 00000000..fb800f96 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/Models/ModFileInfo.cs @@ -0,0 +1,25 @@ +using GIMI_ModManager.Core.Services.GameBanana.ApiModels; + +namespace GIMI_ModManager.Core.Services.GameBanana.Models; + +public class ModFileInfo +{ + public ModFileInfo(ApiModFileInfo apiModFileInfo, string modId) + { + ModId = modId; + FileId = apiModFileInfo.FileId.ToString(); + FileName = apiModFileInfo.FileName; + Description = apiModFileInfo.Description; + DateAdded = DateTimeOffset.FromUnixTimeSeconds(apiModFileInfo.DateAdded).DateTime; + Md5Checksum = apiModFileInfo.Md5Checksum; + ModId = modId; + } + + public string ModId { get; init; } + public string FileId { get; init; } + public string FileName { get; init; } + public string Description { get; init; } + public DateTime DateAdded { get; init; } + public TimeSpan Age => DateTime.Now - DateAdded; + public string Md5Checksum { get; init; } +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBanana/Models/ModPageInfo.cs b/src/GIMI-ModManager.Core/Services/GameBanana/Models/ModPageInfo.cs new file mode 100644 index 00000000..8f358d52 --- /dev/null +++ b/src/GIMI-ModManager.Core/Services/GameBanana/Models/ModPageInfo.cs @@ -0,0 +1,48 @@ +using GIMI_ModManager.Core.Services.GameBanana.ApiModels; + +namespace GIMI_ModManager.Core.Services.GameBanana.Models; + +public class ModPageInfo +{ + public ModPageInfo(ApiModProfile apiModProfile) + { + ModId = apiModProfile.ModId.ToString(); + ModPageUrl = Uri.TryCreate(apiModProfile.ModPageUrl, UriKind.Absolute, out var modPageUrl) ? modPageUrl : null; + ModName = apiModProfile.ModName; + AuthorName = apiModProfile.Author?.AuthorName; + List previewImageUrls = []; + if (apiModProfile.PreviewMedia is not null) + { + foreach (var previewMediaImage in apiModProfile.PreviewMedia.Images) + { + var imageUrl = Uri.TryCreate(previewMediaImage.BaseUrl + "/" + previewMediaImage.ImageId, + UriKind.Absolute, out var uri) + ? uri + : null; + + + if (imageUrl is null || + imageUrl.Scheme != Uri.UriSchemeHttps || + !imageUrl.Host.Equals("images.gamebanana.com", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + previewImageUrls.Add(imageUrl); + } + } + + PreviewImages = previewImageUrls.AsReadOnly(); + + Files = apiModProfile.Files?.Select(apiModFileInfo => new ModFileInfo(apiModFileInfo, ModId)).ToList() ?? + []; + } + + public string ModId { get; init; } + public Uri? ModPageUrl { get; init; } + public string? ModName { get; init; } + public string? AuthorName { get; init; } + public IReadOnlyList PreviewImages { get; init; } + + public IReadOnlyList Files { get; init; } +} \ No newline at end of file diff --git a/src/GIMI-ModManager.Core/Services/GameBananaModPageRetriever.cs b/src/GIMI-ModManager.Core/Services/GameBananaModPageRetriever.cs index 0935945b..5a091bc6 100644 --- a/src/GIMI-ModManager.Core/Services/GameBananaModPageRetriever.cs +++ b/src/GIMI-ModManager.Core/Services/GameBananaModPageRetriever.cs @@ -1,5 +1,7 @@ -using System.Text.Json; +using System.Net; +using System.Text.Json; using System.Text.Json.Serialization; +using GIMI_ModManager.Core.Services.GameBanana; using Polly; using Polly.RateLimiting; using Polly.Registry; @@ -7,6 +9,7 @@ namespace GIMI_ModManager.Core.Services; +[Obsolete($"Use {nameof(GameBananaCoreService)} instead")] public class GameBananaModPageRetriever : IModUpdateChecker { private readonly ILogger _logger; @@ -16,6 +19,7 @@ public class GameBananaModPageRetriever : IModUpdateChecker private const string DownloadUrl = "https://gamebanana.com/dl/"; private const string ApiUrl = "https://gamebanana.com/apiv11/Mod/"; + private const string HealthCheckUrl = "https://gamebanana.com/apiv11"; public GameBananaModPageRetriever(ILogger logger, HttpClient httpClient, ResiliencePipelineProvider resiliencePipelineProvider) @@ -25,7 +29,13 @@ public GameBananaModPageRetriever(ILogger logger, HttpClient httpClient, _resiliencePipeline = resiliencePipelineProvider.GetPipeline(HttpClientName); } + public async Task CheckSiteStatusAsync(CancellationToken cancellationToken = default) + { + using var response = await _httpClient.GetAsync(HealthCheckUrl, cancellationToken).ConfigureAwait(false); + return response.StatusCode == HttpStatusCode.OK; + } + [Obsolete($"Use {nameof(GameBananaCoreService)} instead")] public async Task CheckForUpdatesAsync(Uri url, DateTime lastCheck, CancellationToken cancellationToken = default) { @@ -68,6 +78,7 @@ await JsonSerializer.DeserializeAsync(contentStream, } + [Obsolete($"Use {nameof(GameBananaCoreService)} instead")] public async Task GetModPageDataAsync(Uri url, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(url); @@ -197,9 +208,11 @@ private static void ValidateGameBananaUrl(Uri url) public interface IModUpdateChecker { + [Obsolete($"Use {nameof(GameBananaCoreService)} instead")] public Task CheckForUpdatesAsync(Uri url, DateTime lastCheck, CancellationToken cancellationToken = default); + [Obsolete($"Use {nameof(GameBananaCoreService)} instead")] public Task GetModPageDataAsync(Uri url, CancellationToken cancellationToken = default); } @@ -244,6 +257,8 @@ public sealed class ApiImageUrl public sealed class ApiRootResponse { + [JsonPropertyName("_idRow")] public int ModId { get; init; } = -1; + [JsonPropertyName("_aFiles")] public List Files { get; set; } = new(0); } @@ -281,6 +296,20 @@ public UpdateCheckResult(bool isNewer, string fileName, string description, Date public record ModsRetrievedResult { + private string _modId = "-1"; + + public string ModId + { + get + { + if (_modId == "-1" && SitePageUrl != null) + return SitePageUrl.Segments.Last(); + + return _modId; + } + set => _modId = value; + } + public DateTime CheckTime { get; init; } public DateTime LastCheck { get; init; } public Uri SitePageUrl { get; init; } = null!; @@ -313,6 +342,7 @@ internal static ModsRetrievedResult Map(ApiRootResponse apiResponse, DateTime la return new ModsRetrievedResult { + ModId = apiResponse.ModId.ToString(), CheckTime = DateTime.Now, LastCheck = lastCheck, SitePageUrl = sitePageUrl, diff --git a/src/GIMI-ModManager.WinUI/Activation/FirstTimeStartupActivationHandler.cs b/src/GIMI-ModManager.WinUI/Activation/FirstTimeStartupActivationHandler.cs index 4717129c..1c129cd9 100644 --- a/src/GIMI-ModManager.WinUI/Activation/FirstTimeStartupActivationHandler.cs +++ b/src/GIMI-ModManager.WinUI/Activation/FirstTimeStartupActivationHandler.cs @@ -2,9 +2,11 @@ using GIMI_ModManager.Core.GamesService; using GIMI_ModManager.Core.GamesService.Models; using GIMI_ModManager.Core.Services; +using GIMI_ModManager.Core.Services.GameBanana; using GIMI_ModManager.Core.Services.ModPresetService; using GIMI_ModManager.WinUI.Contracts.Services; using GIMI_ModManager.WinUI.Models.Options; +using GIMI_ModManager.WinUI.Models.Settings; using GIMI_ModManager.WinUI.Services.AppManagement; using GIMI_ModManager.WinUI.ViewModels; using Microsoft.UI.Xaml; @@ -23,12 +25,14 @@ public class FirstTimeStartupActivationHandler : ActivationHandler { + var modArchiveSettings = + await _localSettingsService.ReadOrCreateSettingAsync(ModArchiveSettings.Key); + await _gameService.InitializeAsync(gameServiceOptions).ConfigureAwait(false); await _skinManagerService.InitializeAsync(modManagerOptions!.ModsFolderPath!, null, @@ -71,7 +79,9 @@ await _skinManagerService.InitializeAsync(modManagerOptions!.ModsFolderPath!, nu var tasks = new List { _userPreferencesService.InitializeAsync(), - _modPresetService.InitializeAsync(_localSettingsService.ApplicationDataFolder) + _modPresetService.InitializeAsync(_localSettingsService.ApplicationDataFolder), + _modArchiveRepository.InitializeAsync(_localSettingsService.ApplicationDataFolder, + o => o.MaxDirectorySizeGb = modArchiveSettings.MaxLocalArchiveCacheSizeGb) }; await Task.WhenAll(tasks).ConfigureAwait(false); diff --git a/src/GIMI-ModManager.WinUI/App.xaml.cs b/src/GIMI-ModManager.WinUI/App.xaml.cs index 2d5a7660..76e32f7f 100644 --- a/src/GIMI-ModManager.WinUI/App.xaml.cs +++ b/src/GIMI-ModManager.WinUI/App.xaml.cs @@ -3,6 +3,7 @@ using GIMI_ModManager.Core.Contracts.Services; using GIMI_ModManager.Core.GamesService; using GIMI_ModManager.Core.Services; +using GIMI_ModManager.Core.Services.GameBanana; using GIMI_ModManager.Core.Services.ModPresetService; using GIMI_ModManager.WinUI.Activation; using GIMI_ModManager.WinUI.Contracts.Services; @@ -25,6 +26,7 @@ using Serilog; using Serilog.Events; using Serilog.Templates; +using GameBananaService = GIMI_ModManager.WinUI.Services.ModHandling.GameBananaService; using NotificationManager = GIMI_ModManager.WinUI.Services.Notifications.NotificationManager; namespace GIMI_ModManager.WinUI; @@ -137,9 +139,13 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddTransient(); + services.AddTransient(); // Even though I've followed the docs, I keep getting "Exception thrown: 'System.IO.IOException' in System.Net.Sockets.dll" // I've read just about every microsoft docs page httpclients, and I can't figure out what I'm doing wrong @@ -155,6 +161,17 @@ public App() }) .SetHandlerLifetime(Timeout.InfiniteTimeSpan); + services.AddHttpClient(client => + { + client.DefaultRequestHeaders.Add("User-Agent", "JASM-Just_Another_Skin_Manager-Update-Checker"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler() + { + PooledConnectionLifetime = TimeSpan.FromMinutes(10) + }) + .SetHandlerLifetime(Timeout.InfiniteTimeSpan); + // I'm preeeetty sure this is not correctly set up, not used to polly 8.x.x // But it does rate limit, so I guess it's fine for now services.AddResiliencePipeline(GameBananaModPageRetriever.HttpClientName, (builder, context) => diff --git a/src/GIMI-ModManager.WinUI/Models/Settings/CharacterDetailsSettings.cs b/src/GIMI-ModManager.WinUI/Models/Settings/CharacterDetailsSettings.cs new file mode 100644 index 00000000..79de1791 --- /dev/null +++ b/src/GIMI-ModManager.WinUI/Models/Settings/CharacterDetailsSettings.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace GIMI_ModManager.WinUI.Models.Settings; + +public class CharacterDetailsSettings +{ + [JsonIgnore] public const string Key = "CharacterDetailsSettings"; + + public bool GalleryView { get; set; } = false; +} \ No newline at end of file diff --git a/src/GIMI-ModManager.WinUI/Models/Settings/ModArchiveSettings.cs b/src/GIMI-ModManager.WinUI/Models/Settings/ModArchiveSettings.cs new file mode 100644 index 00000000..25a672f4 --- /dev/null +++ b/src/GIMI-ModManager.WinUI/Models/Settings/ModArchiveSettings.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace GIMI_ModManager.WinUI.Models.Settings; + +internal class ModArchiveSettings +{ + [JsonIgnore] public const string Key = "ModArchiveSettings"; + public int MaxLocalArchiveCacheSizeGb { get; set; } = 10; +} \ No newline at end of file diff --git a/src/GIMI-ModManager.WinUI/Services/ModHandling/ModInstallerService.cs b/src/GIMI-ModManager.WinUI/Services/ModHandling/ModInstallerService.cs index eef5d95d..1844889f 100644 --- a/src/GIMI-ModManager.WinUI/Services/ModHandling/ModInstallerService.cs +++ b/src/GIMI-ModManager.WinUI/Services/ModHandling/ModInstallerService.cs @@ -10,6 +10,7 @@ using GIMI_ModManager.WinUI.Contracts.Services; using GIMI_ModManager.WinUI.Models.Settings; using GIMI_ModManager.WinUI.Services.AppManagement; +using GIMI_ModManager.WinUI.ViewModels; using GIMI_ModManager.WinUI.Views; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml.Media; @@ -24,8 +25,8 @@ public class ModInstallerService( private readonly ILocalSettingsService _localSettingsService = localSettingsService; private readonly IWindowManagerService _windowManagerService = windowManagerService; - public Task StartModInstallationAsync(DirectoryInfo modFolder, ICharacterModList modList, - ICharacterSkin? inGameSkin = null) + public async Task StartModInstallationAsync(DirectoryInfo modFolder, ICharacterModList modList, + ICharacterSkin? inGameSkin = null, Action? setup = null) { ArgumentNullException.ThrowIfNull(modFolder); ArgumentNullException.ThrowIfNull(modList); @@ -36,13 +37,18 @@ public Task StartModInstallationAsync(DirectoryInfo modFolder, ICharacterModList var dispatcherQueue = DispatcherQueue.GetForCurrentThread() ?? App.MainWindow.DispatcherQueue; - dispatcherQueue.EnqueueAsync(() => InternalStartAsync(modFolder, modList, inGameSkin)); + var modOptions = new InstallOptions(); + setup?.Invoke(modOptions); - return Task.CompletedTask; + + var monitor = + await dispatcherQueue.EnqueueAsync(() => InternalStartAsync(modFolder, modList, inGameSkin, modOptions)); + + return monitor; } - private async Task InternalStartAsync(DirectoryInfo modFolder, ICharacterModList modList, - ICharacterSkin? inGameSkin = null) + private async Task InternalStartAsync(DirectoryInfo modFolder, ICharacterModList modList, + ICharacterSkin? inGameSkin = null, InstallOptions? options = null) { var modTitle = Guid.TryParse(modFolder.Name, out _) ? modFolder.EnumerateDirectories().FirstOrDefault()?.Name @@ -53,7 +59,7 @@ private async Task InternalStartAsync(DirectoryInfo modFolder, ICharacterModList var modInstallerSettings = await _localSettingsService.ReadOrCreateSettingAsync(ModInstallerSettings.Key); - var modInstallPage = new ModInstallerPage(modList, modFolder, inGameSkin); + var modInstallPage = new ModInstallerPage(modList, modFolder, inGameSkin, options); var modInstallWindow = new WindowEx() { SystemBackdrop = new MicaBackdrop(), @@ -65,8 +71,61 @@ private async Task InternalStartAsync(DirectoryInfo modFolder, ICharacterModList MinWidth = 1024, IsAlwaysOnTop = modInstallerSettings.ModInstallerWindowOnTop }; - modInstallPage.CloseRequested += (_, _) => { modInstallWindow.Close(); }; _windowManagerService.CreateWindow(modInstallWindow, modList); + + return new InstallMonitor(modInstallPage, modInstallWindow); + } +} + +public class InstallOptions +{ + public Uri? ModUrl { get; set; } +} + +public sealed class InstallMonitor : IDisposable +{ + private readonly TaskCompletionSource _taskCompletionSource = new(); + private readonly ModInstallerPage _modInstallerPage; + private readonly WindowEx _modInstallerWindow; + private CancellationTokenRegistration? _cancellationTokenRegistration; + + + public InstallMonitor(ModInstallerPage modInstallerPage, WindowEx modInstallerWindow) + { + _modInstallerPage = modInstallerPage; + _modInstallerWindow = modInstallerWindow; + + _modInstallerPage.CloseRequested += (_, e) => + { + _taskCompletionSource.SetResult(e); + _modInstallerWindow.Close(); + }; + + _modInstallerWindow.Closed += (_, _) => + { + if (!_taskCompletionSource.Task.IsCompleted) + _taskCompletionSource.TrySetResult(new CloseRequestedArgs(CloseRequestedArgs.CloseReasons.Canceled)); + }; + } + + public Task WaitForCloseAsync(CancellationToken cancellationToken = default) + { + _cancellationTokenRegistration = cancellationToken.Register(() => + { + if (!_taskCompletionSource.Task.IsCompleted) + { + _taskCompletionSource.TrySetCanceled(); + _modInstallerWindow.Close(); + } + }); + + return _taskCompletionSource.Task; + } + + public void Dispose() + { + _cancellationTokenRegistration?.Dispose(); + _cancellationTokenRegistration = null; } } @@ -213,14 +272,15 @@ public async Task RenameAndAddAsync(AddModOptions options, ISkinMod du if (dupeModNewFolderName.IsNullOrEmpty() && options.NewModFolderName.IsNullOrEmpty()) throw new ArgumentException("The new mod folder name and old folder name cannot be null or empty"); - if (dupeModNewFolderName == options.NewModFolderName) + if (ModFolderHelpers.FolderNameEquals(dupeModNewFolderName, options.NewModFolderName!)) throw new ArgumentException("The new mod folder name and old folder name cannot be the same"); ReleaseLockedFiles(); var skinMod = await CreateSkinModWithOptionsAsync(options); var newModRenamed = false; - if (!options.NewModFolderName.IsNullOrEmpty() && skinMod.Name != options.NewModFolderName) + if (!options.NewModFolderName.IsNullOrEmpty() && + !ModFolderHelpers.FolderNameEquals(skinMod.Name, options.NewModFolderName)) { var tmpFolder = App.GetUniqueTmpFolder(); skinMod = await SkinMod.CreateModAsync(skinMod.CopyTo(tmpFolder.FullName).FullPath).ConfigureAwait(false); @@ -228,7 +288,8 @@ public async Task RenameAndAddAsync(AddModOptions options, ISkinMod du newModRenamed = true; } - if (!dupeModNewFolderName.IsNullOrEmpty() && dupeMod.Name != dupeModNewFolderName) + if (!dupeModNewFolderName.IsNullOrEmpty() && + !ModFolderHelpers.FolderNameEquals(dupeMod.Name, dupeModNewFolderName)) { _destinationModList.RenameMod(dupeMod, dupeModNewFolderName); } diff --git a/src/GIMI-ModManager.WinUI/ViewModels/ModInstallerVM.cs b/src/GIMI-ModManager.WinUI/ViewModels/ModInstallerVM.cs index 2edaad1c..e11007d0 100644 --- a/src/GIMI-ModManager.WinUI/ViewModels/ModInstallerVM.cs +++ b/src/GIMI-ModManager.WinUI/ViewModels/ModInstallerVM.cs @@ -22,6 +22,7 @@ using Microsoft.UI.Dispatching; using Serilog; using Constants = GIMI_ModManager.Core.Helpers.Constants; +using static GIMI_ModManager.WinUI.ViewModels.CloseRequestedArgs; namespace GIMI_ModManager.WinUI.ViewModels; @@ -51,7 +52,7 @@ public IAsyncRelayCommand AddModDialogCommand public event EventHandler? DuplicateModDialog; public event EventHandler? InstallerFinished; - public event EventHandler? CloseRequested; + public event EventHandler? CloseRequested; [ObservableProperty] private string _modCharacterName = string.Empty; @@ -121,7 +122,7 @@ public ModInstallerVM(ILogger logger, ImageHandlerService imageHandlerService, } public async Task InitializeAsync(ICharacterModList characterModList, DirectoryInfo modToInstall, - DispatcherQueue dispatcherQueue, ICharacterSkin? inGameSkin = null) + DispatcherQueue dispatcherQueue, ICharacterSkin? inGameSkin = null, InstallOptions? options = null) { _characterModList = characterModList; ModCharacterName = characterModList.Character.DisplayName; @@ -164,6 +165,7 @@ await Task.Run(() => dispatcherQueue.TryEnqueue(() => { SetShaderFixesFolder(fileSystemItem); }); } + var autoFoundImages = SkinModHelpers.DetectModPreviewImages(_modInstallation.ModFolder.FullName); if (autoFoundImages.Any()) @@ -174,6 +176,9 @@ await Task.Run(() => ?.GetByPath(autoFoundImages.FirstOrDefault()?.LocalPath ?? ""); SetModPreviewImage(fileSystemItem); }); + + if (options?.ModUrl is not null) + dispatcherQueue.TryEnqueue(() => { ModUrl = options.ModUrl.ToString(); }); }).ConfigureAwait(false); } @@ -240,7 +245,10 @@ private void SuccessfulInstall(ISkinMod newMod) }); } - _dispatcherQueue?.TryEnqueue(() => { _windowManagerService.GetWindow(_characterModList)?.Close(); }); + _dispatcherQueue?.TryEnqueue(() => + { + CloseRequested?.Invoke(this, new CloseRequestedArgs(CloseReasons.Success)); + }); App.MainWindow.DispatcherQueue.EnqueueAsync(() => _modNotificationManager.AddModNotification(new ModNotification() @@ -509,7 +517,7 @@ private async Task AddModAsync() catch (Exception e) { _logger.Error(e, "Failed to add mod"); - ErrorOccurred(); + ErrorOccurred(e); } finally { @@ -533,7 +541,7 @@ private async Task AddModAndReplaceAsync() catch (Exception e) { _logger.Error(e, "Failed to add mod"); - ErrorOccurred(); + ErrorOccurred(e); } finally { @@ -550,20 +558,21 @@ private bool canAddModAndRename() if (ModFolderName.IsNullOrEmpty() || DuplicateModFolderName.IsNullOrEmpty()) return false; - if (ModFolderName == DuplicateModFolderName) + if (ModFolderName.Equals(DuplicateModFolderName, StringComparison.OrdinalIgnoreCase)) return false; if (_duplicateMod is null) return false; - if (DuplicateModFolderName != _duplicateMod?.Name) + + if (!ModFolderHelpers.FolderNameEquals(DuplicateModFolderName, _duplicateMod.Name)) foreach (var skinEntry in _characterModList.Mods) { if (ModFolderHelpers.FolderNameEquals(skinEntry.Mod.Name, DuplicateModFolderName)) return false; } - if (ModFolderName != _modInstallation.ModFolder.Name) + if (!ModFolderHelpers.FolderNameEquals(ModFolderName, _modInstallation.ModFolder.Name)) foreach (var skinEntry in _characterModList.Mods) { if (ModFolderHelpers.FolderNameEquals(skinEntry.Mod.Name, ModFolderName)) @@ -587,7 +596,7 @@ private async Task AddModAndRenameAsync() catch (Exception e) { _logger.Error(e, "Failed to add mod"); - ErrorOccurred(); + ErrorOccurred(e); } finally { @@ -652,6 +661,12 @@ private async Task GetModInfo(string url, bool overrideCurrent = false) { var newImage = await Task.Run(() => _imageHandlerService.DownloadImageAsync(newImageUrl)); ModPreviewImagePath = new Uri(newImage.Path); + + if (LastSelectedImageFile is not null) + { + LastSelectedImageFile.RightIcon = null; + LastSelectedImageFile = null; + } } } catch (Exception e) @@ -707,7 +722,7 @@ public void Dispose() _modInstallation = null; } - private void ErrorOccurred() + private void ErrorOccurred(Exception e) { InstallerFinished?.Invoke(this, EventArgs.Empty); Win32.PlaySound("SystemAsterisk", nuint.Zero, @@ -715,7 +730,7 @@ private void ErrorOccurred() _notificationManager.ShowNotification("An error occurred", "An error occurred while adding the mod. See logs for more details", TimeSpan.FromSeconds(10)); - CloseRequested?.Invoke(this, EventArgs.Empty); + CloseRequested?.Invoke(this, new CloseRequestedArgs(CloseReasons.Error, e)); } @@ -828,6 +843,26 @@ private void Finally() } } +public class CloseRequestedArgs : EventArgs +{ + public CloseReasons CloseReason { get; } + public Exception? Exception { get; } + + public CloseRequestedArgs(CloseReasons closeReason, Exception? exception = null) + { + CloseReason = closeReason; + Exception = exception; + } + + + public enum CloseReasons + { + Canceled, + Success, + Error + } +} + public partial class RootFolder : ObservableObject { private readonly DirectoryInfo _folder; @@ -883,7 +918,7 @@ public FileSystemItem(FileSystemInfo fileSystemInfo, int recursionCount = 0) { _fileSystemInfo = fileSystemInfo; - if (recursionCount < 2) + if (recursionCount < 1) { _isExpanded = true; } diff --git a/src/GIMI-ModManager.WinUI/ViewModels/ModUpdateVM.cs b/src/GIMI-ModManager.WinUI/ViewModels/ModUpdateVM.cs index 162c5793..99f24dcf 100644 --- a/src/GIMI-ModManager.WinUI/ViewModels/ModUpdateVM.cs +++ b/src/GIMI-ModManager.WinUI/ViewModels/ModUpdateVM.cs @@ -1,25 +1,39 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using GIMI_ModManager.Core.Contracts.Entities; using GIMI_ModManager.Core.Contracts.Services; using GIMI_ModManager.Core.Entities.Mods.Exceptions; -using GIMI_ModManager.Core.Helpers; using GIMI_ModManager.Core.Services; +using GIMI_ModManager.Core.Services.GameBanana; +using GIMI_ModManager.Core.Services.GameBanana.Models; using GIMI_ModManager.WinUI.Services.ModHandling; using GIMI_ModManager.WinUI.Services.Notifications; +using Microsoft.UI.Dispatching; using Serilog; namespace GIMI_ModManager.WinUI.ViewModels; public partial class ModUpdateVM : ObservableRecipient { - private readonly GameBananaService _gameBananaService = App.GetService(); + private readonly GameBananaCoreService _gameBananaCoreService = App.GetService(); private readonly ISkinManagerService _skinManagerService = App.GetService(); private readonly ModNotificationManager _modNotificationManager = App.GetService(); + private readonly NotificationManager _notificationManager = App.GetService(); + private readonly ModInstallerService _modInstallerService = App.GetService(); + private readonly ArchiveService _archiveService = App.GetService(); + private readonly DispatcherQueue _dispatcherQueue; private readonly Guid _notificationId; + private ICharacterModList _characterModList = null!; private readonly WindowEx _window; private ModNotification? _notification; + private List _modFiles = new(); + private readonly CancellationToken _ct; + + private ModPageInfo? _modPageInfo; + + [ObservableProperty] private string _initializing = "true"; [ObservableProperty] private string _modName = string.Empty; @@ -31,70 +45,136 @@ public partial class ModUpdateVM : ObservableRecipient [ObservableProperty] private bool _isOpenDownloadButtonEnabled = false; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsNotBusy))] + [NotifyCanExecuteChangedFor(nameof(IgnoreAndCloseCommand), nameof(StartDownloadCommand), + nameof(StartInstallCommand))] + private bool _isWindowBusy = false; + + public bool IsNotBusy => !IsWindowBusy; - public ObservableCollection Results = new(); + + public readonly ObservableCollection ModFileInfos = new(); private readonly ILogger _logger = App.GetService().ForContext(); - public ModUpdateVM(Guid notificationId, WindowEx window) + public ModUpdateVM(Guid notificationId, WindowEx window, CancellationToken ctsToken) { + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); _notificationId = notificationId; _window = window; + _ct = ctsToken; Initialize(); } - private async void Initialize() { - ModsRetrievedResult? modResult = null; try { - _notification = - await _modNotificationManager.GetNotificationById(_notificationId) ?? - throw new InvalidOperationException(); - modResult = _notification.ModsRetrievedResult ?? - await _gameBananaService.GetAvailableMods(_notification.ModId); + await InternalInitialize(); + Initializing = "false"; } catch (Exception e) { - LogErrorAndClose(e); + await LogErrorAndClose(e); + } + } + + + private async Task InternalInitialize() + { + IsWindowBusy = true; + _notification = + await _modNotificationManager.GetNotificationById(_notificationId); + + if (_notification?.ModsRetrievedResult is null) + { + await LogErrorAndClose(new InvalidOperationException("Failed to get mod page info, mod info is missing")); return; } - var mod = _skinManagerService.GetModById(_notification.ModId); - if (mod is null) + var characterSkinEntry = _skinManagerService.GetModEntryById(_notification.ModId); + + if (characterSkinEntry is null) { - LogErrorAndClose(new InvalidOperationException($"Mod with id {_notification.ModId} not found")); + await LogErrorAndClose(new InvalidOperationException($"Mod with id {_notification.ModId} not found")); return; } + _characterModList = characterSkinEntry.ModList; + var mod = characterSkinEntry.Mod; + var modSettings = mod.Settings.GetSettingsLegacy().TryPickT0(out var settings, out _) ? settings : null; if (modSettings is null) { - LogErrorAndClose(new ModSettingsNotFoundException($"Mod settings not found for mod {mod.FullPath}")); + await LogErrorAndClose(new ModSettingsNotFoundException($"Mod settings not found for mod {mod.FullPath}")); + return; + } + + if (!await _gameBananaCoreService.HealthCheckAsync(_ct)) + { + await LogErrorAndClose( + new InvalidOperationException("Failed to get mod page info, GameBanana Api is not available"), + removeNotification: false); + return; + } + + _modPageInfo = + await _gameBananaCoreService.GetModProfileAsync(new GbModId(_notification.ModsRetrievedResult.ModId), _ct); + + if (_modPageInfo is null) + { + await LogErrorAndClose(new InvalidOperationException("Failed to get mod page info, mod does not exist")); return; } ModName = modSettings.CustomName ?? mod.Name; - ModPage = modResult.SitePageUrl; + if (_modPageInfo.ModPageUrl is not null) + ModPage = _modPageInfo.ModPageUrl; + ModPath = new Uri(mod.FullPath); - LastUpdateCheck = modResult.LastCheck; + LastUpdateCheck = _notification.ModsRetrievedResult.LastCheck; + + + _modFiles = _modPageInfo.Files.ToList(); + + foreach (var modFile in _modFiles) + { + var vm = new ModFileInfoVm(modFile, StartDownloadCommand, StartInstallCommand) + { + IsNew = modFile.DateAdded > LastUpdateCheck, + IsBusy = true + }; + ModFileInfos.Add(vm); + await InitializeModFileVmAsync(vm); + } - modResult.Mods.ForEach(x => Results.Add(x)); + IsWindowBusy = false; } - private void LogErrorAndClose(Exception e) + private async Task LogErrorAndClose(Exception e, bool removeNotification = true) { _logger.Error(e, "Failed to get mod update info"); App.MainWindow.DispatcherQueue.TryEnqueue(() => { App.GetService().ShowNotification("Failed to get mod update info", - $"Failed to get mod update info", TimeSpan.FromSeconds(10)); + e.Message, TimeSpan.FromSeconds(10)); }); _window.Close(); + + if (!removeNotification) + return; + + var notification = await _modNotificationManager.GetNotificationById(_notificationId); + if (notification is null) + { + return; + } + + await _modNotificationManager.RemoveModNotificationAsync(notification.Id); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(IsNotBusy))] private async Task IgnoreAndCloseAsync() { var notification = await _modNotificationManager.GetNotificationById(_notificationId); @@ -106,4 +186,254 @@ private async Task IgnoreAndCloseAsync() await _modNotificationManager.RemoveModNotificationAsync(notification.Id); _window.Close(); } + + + private async Task InitializeModFileVmAsync(ModFileInfoVm fileInfoVm) + { + ArgumentNullException.ThrowIfNull(fileInfoVm); + + var existingArchive = await _gameBananaCoreService.GetLocalModArchiveByMd5HashAsync(fileInfoVm.Md5Hash, _ct); + + _dispatcherQueue.TryEnqueue(() => + { + if (existingArchive is not null) + { + fileInfoVm.Status = ModFileInfoVm.InstallStatus.Downloaded; + fileInfoVm.DownloadProgress = 100; + fileInfoVm.ArchiveFile = new FileInfo(existingArchive.FullName); + } + + fileInfoVm.IsBusy = false; + }); + } + + private bool CanStartDownload(ModFileInfoVm? fileInfoVm) + { + if (fileInfoVm is null) + return false; + + var anyOtherDownloading = ModFileInfos.Any(x => + fileInfoVm.FileId != x.FileId && x.Status == ModFileInfoVm.InstallStatus.Downloading); + + var canDownload = IsNotBusy && !fileInfoVm.IsBusy && fileInfoVm.Status == ModFileInfoVm.InstallStatus.NotStarted + && !anyOtherDownloading && fileInfoVm.ArchiveFile is null; + + return canDownload; + } + + [RelayCommand(CanExecute = nameof(CanStartDownload))] + private async Task StartDownload(ModFileInfoVm fileInfoVm) + { + try + { + fileInfoVm.Status = ModFileInfoVm.InstallStatus.Downloading; + fileInfoVm.IsBusy = true; + + var identifier = new GbModFileIdentifier(new GbModId(fileInfoVm.ModId), new GbModFileId(fileInfoVm.FileId)); + + var archivePath = + await Task.Run(() => _gameBananaCoreService.DownloadModAsync(identifier, fileInfoVm.Progress, _ct), + _ct); + + fileInfoVm.ArchiveFile = new FileInfo(archivePath); + fileInfoVm.Status = ModFileInfoVm.InstallStatus.Downloaded; + } + catch (Exception) when (_ct.IsCancellationRequested) + { + Reset(); + } + catch (Exception e) + { + _logger.Error(e, "Failed to download mod file"); + + _notificationManager.ShowNotification("Failed to download mod file", + e.Message, TimeSpan.FromSeconds(10)); + + Reset(); + } + finally + { + fileInfoVm.IsBusy = false; + } + + void Reset() + { + fileInfoVm.Status = ModFileInfoVm.InstallStatus.NotStarted; + fileInfoVm.ArchiveFile = null; + fileInfoVm.DownloadProgress = 0; + } + } + + private bool CanInstall(ModFileInfoVm? fileInfoVm) + { + if (fileInfoVm is null) + return false; + + var anyOtherInstalling = ModFileInfos.Any(x => + fileInfoVm.FileId != x.FileId && x.Status == ModFileInfoVm.InstallStatus.Installing); + + var canInstall = IsNotBusy && !fileInfoVm.IsBusy && + fileInfoVm.Status == ModFileInfoVm.InstallStatus.Downloaded && + !anyOtherInstalling && fileInfoVm.ArchiveFile is not null; + + return canInstall; + } + + + [RelayCommand(CanExecute = nameof(CanInstall))] + private async Task StartInstall(ModFileInfoVm fileInfoVm) + { + IsWindowBusy = true; + fileInfoVm.IsBusy = true; + + fileInfoVm.Status = ModFileInfoVm.InstallStatus.Installing; + + try + { + var result = await Task.Run(async () => + { + var modFolder = _archiveService.ExtractArchive(fileInfoVm.ArchiveFile!.FullName, + App.GetUniqueTmpFolder().FullName); + + var archiveNameSections = Path.GetFileName(modFolder.Name).Split(ModArchiveRepository.Separator); + if (archiveNameSections.Length != 4) + throw new InvalidArchiveNameFormatException(); + + var modFolderName = archiveNameSections[0]; + var modFolderExt = Path.GetExtension(modFolder.Name); + + var modFolderParent = modFolder.Parent!; + + var zipRoot = Directory.CreateDirectory(Path.Combine(modFolderParent.FullName, "ArchiveRoot")); + + modFolder.MoveTo(Path.Combine(zipRoot.FullName, $"{modFolderName}{modFolderExt}")); + + var modUrl = _modPageInfo?.ModPageUrl; + + using var task = await _modInstallerService.StartModInstallationAsync(zipRoot, _characterModList, + setup: options => options.ModUrl = modUrl).ConfigureAwait(false); + + return await task.WaitForCloseAsync(_ct).ConfigureAwait(false); + }, _ct); + + if (result.CloseReason == CloseRequestedArgs.CloseReasons.Error) + { + if (result.Exception is not null) + { + throw new Exception("An error occured during mod install, see logs and inner exception", + result.Exception); + } + + throw new Exception("An error occured during mod install, see logs"); + } + + if (result.CloseReason == CloseRequestedArgs.CloseReasons.Canceled) + { + fileInfoVm.Status = ModFileInfoVm.InstallStatus.Downloaded; + return; + } + + fileInfoVm.Status = ModFileInfoVm.InstallStatus.Installed; + } + catch (TaskCanceledException) + { + fileInfoVm.Status = ModFileInfoVm.InstallStatus.Downloaded; + } + catch (Exception e) + { + _logger.Error(e, "Failed to install mod file"); + + _notificationManager.ShowNotification("Failed to install mod file", + e.InnerException?.Message ?? e.Message, TimeSpan.FromSeconds(10)); + + fileInfoVm.Status = ModFileInfoVm.InstallStatus.Downloaded; + } + finally + { + IsWindowBusy = false; + fileInfoVm.IsBusy = false; + } + } +} + +public partial class ModFileInfoVm : ObservableObject +{ + private readonly ModFileInfo _modFileInfo; + + public string ModId => _modFileInfo.ModId; + public string FileId => _modFileInfo.FileId; + public string FileName => _modFileInfo.FileName; + + public DateTime DateAdded => _modFileInfo.DateAdded; + + public string DateAddedTooltipFormat => $"Submitted: {DateAdded}"; + + public TimeSpan Age => DateTime.Now - DateAdded; + + public string AgeFormated + { + get + { + var age = Age; + + return (age) switch + { + { TotalHours: < 1 } => $"{age.Minutes} minutes ago", + { TotalDays: < 1 } => $"{age.Hours} hours ago", + _ => $"{Math.Round(age.TotalDays)} days ago" + }; + } + } + + public string Description => _modFileInfo.Description; + + public string Md5Hash => _modFileInfo.Md5Checksum; + [ObservableProperty] private bool _isNew; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(DownloadCommand), nameof(InstallCommand))] + [NotifyPropertyChangedFor(nameof(IsNotBusy))] + private bool _isBusy; + + public bool IsNotBusy => !IsBusy; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(DownloadCommand), nameof(InstallCommand))] + [NotifyPropertyChangedFor(nameof(ShowDownloadButton), nameof(ShowInstallButton))] + private InstallStatus _status = InstallStatus.NotStarted; + + [ObservableProperty] private int _downloadProgress; + + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(InstallCommand), nameof(DownloadCommand))] + private FileInfo? _archiveFile; + + public IProgress Progress { get; } + + + public bool ShowDownloadButton => Status is InstallStatus.NotStarted or InstallStatus.Downloading; + + public bool ShowInstallButton => + Status is InstallStatus.Downloaded or InstallStatus.Installing or InstallStatus.Installed; + + public ModFileInfoVm(ModFileInfo modFileInfo, + IAsyncRelayCommand downloadCommand, IAsyncRelayCommand installCommand) + { + _modFileInfo = modFileInfo; + DownloadCommand = downloadCommand; + InstallCommand = installCommand; + Progress = new Progress(i => DownloadProgress = i); + } + + public IAsyncRelayCommand DownloadCommand { get; } + public IAsyncRelayCommand InstallCommand { get; } + + + public enum InstallStatus + { + NotStarted, + Downloading, // Downloading from GameBanana + Downloaded, // Downloaded from GameBanana + Installing, // Installing to the game, mod installation window open + Installed // Installed to the game + } } \ No newline at end of file diff --git a/src/GIMI-ModManager.WinUI/ViewModels/SettingsViewModel.cs b/src/GIMI-ModManager.WinUI/ViewModels/SettingsViewModel.cs index 91602358..a0e5bfd8 100644 --- a/src/GIMI-ModManager.WinUI/ViewModels/SettingsViewModel.cs +++ b/src/GIMI-ModManager.WinUI/ViewModels/SettingsViewModel.cs @@ -17,6 +17,7 @@ using GIMI_ModManager.WinUI.Contracts.ViewModels; using GIMI_ModManager.WinUI.Helpers; using GIMI_ModManager.WinUI.Models.Options; +using GIMI_ModManager.WinUI.Models.Settings; using GIMI_ModManager.WinUI.Services; using GIMI_ModManager.WinUI.Services.AppManagement; using GIMI_ModManager.WinUI.Services.AppManagement.Updating; @@ -88,6 +89,8 @@ [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(IgnoreNewVersionCommand) [ObservableProperty] private bool _characterAsSkinsCheckbox = false; + [ObservableProperty] private int _maxCacheLimit; + private Dictionary _nameToLangCode = new(); public PathPicker PathToGIMIFolderPicker { get; } @@ -168,6 +171,9 @@ public SettingsViewModel( }; ElevatorService.CheckStatus(); + MaxCacheLimit = localSettingsService.ReadSetting(ModArchiveSettings.Key) + ?.MaxLocalArchiveCacheSizeGb ?? new ModArchiveSettings().MaxLocalArchiveCacheSizeGb; + SetCacheString(MaxCacheLimit); var cultures = CultureInfo.GetCultures(CultureTypes.AllCultures); cultures = cultures.Append(new CultureInfo("zh-cn")).ToArray(); @@ -707,6 +713,27 @@ await _localSettingsService.ReadOrCreateSettingAsync(ModArchiveSettings.Key); + + modArchiveSettings.MaxLocalArchiveCacheSizeGb = maxValue; + + await _localSettingsService.SaveSettingAsync(ModArchiveSettings.Key, modArchiveSettings); + + MaxCacheLimit = maxValue; + SetCacheString(maxValue); + } + [RelayCommand] private static Task ShowCleanModsFolderDialogAsync() diff --git a/src/GIMI-ModManager.WinUI/ViewModels/StartupViewModel.cs b/src/GIMI-ModManager.WinUI/ViewModels/StartupViewModel.cs index df532f79..fb7fd0f1 100644 --- a/src/GIMI-ModManager.WinUI/ViewModels/StartupViewModel.cs +++ b/src/GIMI-ModManager.WinUI/ViewModels/StartupViewModel.cs @@ -5,10 +5,12 @@ using GIMI_ModManager.Core.GamesService; using GIMI_ModManager.Core.Helpers; using GIMI_ModManager.Core.Services; +using GIMI_ModManager.Core.Services.GameBanana; using GIMI_ModManager.Core.Services.ModPresetService; using GIMI_ModManager.WinUI.Contracts.Services; using GIMI_ModManager.WinUI.Contracts.ViewModels; using GIMI_ModManager.WinUI.Models.Options; +using GIMI_ModManager.WinUI.Models.Settings; using GIMI_ModManager.WinUI.Services; using GIMI_ModManager.WinUI.Services.AppManagement; using GIMI_ModManager.WinUI.Services.Notifications; @@ -29,6 +31,7 @@ public partial class StartupViewModel : ObservableRecipient, INavigationAware private readonly ModPresetService _modPresetService; private readonly UserPreferencesService _userPreferencesService; private readonly SelectedGameService _selectedGameService; + private readonly ModArchiveRepository _modArchiveRepository; private const string _genshinModelImporterName = "Genshin-Impact-Model-Importer"; @@ -63,7 +66,7 @@ public partial class StartupViewModel : ObservableRecipient, INavigationAware public StartupViewModel(INavigationService navigationService, ILocalSettingsService localSettingsService, IWindowManagerService windowManagerService, ISkinManagerService skinManagerService, SelectedGameService selectedGameService, IGameService gameService, ModPresetService modPresetService, - UserPreferencesService userPreferencesService) + UserPreferencesService userPreferencesService, ModArchiveRepository modArchiveRepository) { _navigationService = navigationService; _localSettingsService = localSettingsService; @@ -73,6 +76,7 @@ public StartupViewModel(INavigationService navigationService, ILocalSettingsServ _gameService = gameService; _modPresetService = modPresetService; _userPreferencesService = userPreferencesService; + _modArchiveRepository = modArchiveRepository; PathToGIMIFolderPicker = new PathPicker(GimiFolderRootValidators.Validators); @@ -112,10 +116,15 @@ await _localSettingsService.SaveSettingAsync(ModManagerOptions.Section, await _skinManagerService.InitializeAsync(modManagerOptions.ModsFolderPath!, null, modManagerOptions.GimiRootFolderPath); + var modArchiveSettings = + await _localSettingsService.ReadOrCreateSettingAsync(ModArchiveSettings.Key); + var tasks = new List { _userPreferencesService.InitializeAsync(), - _modPresetService.InitializeAsync(_localSettingsService.ApplicationDataFolder) + _modPresetService.InitializeAsync(_localSettingsService.ApplicationDataFolder), + _modArchiveRepository.InitializeAsync(_localSettingsService.ApplicationDataFolder, + o => o.MaxDirectorySizeGb = modArchiveSettings.MaxLocalArchiveCacheSizeGb) }; await Task.WhenAll(tasks); diff --git a/src/GIMI-ModManager.WinUI/Views/DebugPage.xaml b/src/GIMI-ModManager.WinUI/Views/DebugPage.xaml index de4dc50c..cb5c01d2 100644 --- a/src/GIMI-ModManager.WinUI/Views/DebugPage.xaml +++ b/src/GIMI-ModManager.WinUI/Views/DebugPage.xaml @@ -14,17 +14,7 @@ - - - - - - - - - - - - + + \ No newline at end of file diff --git a/src/GIMI-ModManager.WinUI/Views/DebugPage.xaml.cs b/src/GIMI-ModManager.WinUI/Views/DebugPage.xaml.cs index e67cfaf1..9fc30284 100644 --- a/src/GIMI-ModManager.WinUI/Views/DebugPage.xaml.cs +++ b/src/GIMI-ModManager.WinUI/Views/DebugPage.xaml.cs @@ -1,5 +1,6 @@ -using GIMI_ModManager.Core.Services; -using GIMI_ModManager.WinUI.ViewModels; +using GIMI_ModManager.Core.Services.GameBanana; +using GIMI_ModManager.Core.Services.GameBanana.Models; +using GIMI_ModManager.WinUI.Contracts.Services; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -7,22 +8,31 @@ namespace GIMI_ModManager.WinUI.Views; public sealed partial class DebugPage : Page { - public DebugViewModel ViewModel { get; } = App.GetService(); + private readonly IApiGameBananaClient _apiGameBananaClient = App.GetService(); + private readonly ModArchiveRepository _modArchiveRepository = App.GetService(); + private readonly ILocalSettingsService _localSettingsService = App.GetService(); + private readonly GameBananaCoreService _gameBananaCoreService = App.GetService(); - public UserPreferencesService UserPreferencesService { get; } = App.GetService(); + public string ModId { get; set; } = "495878"; public DebugPage() { InitializeComponent(); + _ = Task.Run(() => _modArchiveRepository.InitializeAsync(_localSettingsService.ApplicationDataFolder)); } - private async void ButtonBase_OnClickSave(object sender, RoutedEventArgs e) + private async void ButtonBase_OnClick(object sender, RoutedEventArgs e) { - await UserPreferencesService.SaveModPreferencesAsync().ConfigureAwait(false); - } + var cts = new CancellationTokenSource(); - private async void ButtonBase_OnClickApply(object sender, RoutedEventArgs e) - { - await UserPreferencesService.SetModPreferencesAsync().ConfigureAwait(false); + var modId = new GbModId(ModId); + + var modInfos = await _apiGameBananaClient.GetModFilesInfoAsync(modId, cts.Token); + + var mod = modInfos!.Files.First(); + var modFileIdentifier = new GbModFileIdentifier(modId, new GbModFileId(mod.FileId)); + + var path = await Task.Run(() => _gameBananaCoreService.DownloadModAsync(modFileIdentifier, ct: cts.Token), + cts.Token); } } \ No newline at end of file diff --git a/src/GIMI-ModManager.WinUI/Views/ModInstallerPage.xaml.cs b/src/GIMI-ModManager.WinUI/Views/ModInstallerPage.xaml.cs index 114e11fb..f5bcf58e 100644 --- a/src/GIMI-ModManager.WinUI/Views/ModInstallerPage.xaml.cs +++ b/src/GIMI-ModManager.WinUI/Views/ModInstallerPage.xaml.cs @@ -2,6 +2,7 @@ using Windows.System; using GIMI_ModManager.Core.Contracts.Entities; using GIMI_ModManager.Core.GamesService.Interfaces; +using GIMI_ModManager.WinUI.Services.ModHandling; using GIMI_ModManager.WinUI.ViewModels; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -11,21 +12,21 @@ namespace GIMI_ModManager.WinUI.Views; public sealed partial class ModInstallerPage : Page, IDisposable { - public event EventHandler? CloseRequested; + public event EventHandler? CloseRequested; public ModInstallerVM ViewModel { get; } = App.GetService(); public ModInstallerPage(ICharacterModList characterModList, DirectoryInfo modToInstall, - ICharacterSkin? inGameSkin = null) + ICharacterSkin? inGameSkin = null, InstallOptions? options = null) { InitializeComponent(); ViewModel.DuplicateModDialog += OnDuplicateModFound; ViewModel.InstallerFinished += (_, _) => { DispatcherQueue.TryEnqueue(() => { IsEnabled = false; }); }; Loading += (_, _) => { - ViewModel.InitializeAsync(characterModList, modToInstall, DispatcherQueue, inGameSkin); + _ = ViewModel.InitializeAsync(characterModList, modToInstall, DispatcherQueue, inGameSkin, options); }; - ViewModel.CloseRequested += (_, _) => { CloseRequested?.Invoke(this, EventArgs.Empty); }; + ViewModel.CloseRequested += (_, e) => { CloseRequested?.Invoke(this, e); }; } private async void OnDuplicateModFound(object? sender, EventArgs e) diff --git a/src/GIMI-ModManager.WinUI/Views/ModUpdateAvailableWindow.xaml b/src/GIMI-ModManager.WinUI/Views/ModUpdateAvailableWindow.xaml index 777d7efc..0f47a017 100644 --- a/src/GIMI-ModManager.WinUI/Views/ModUpdateAvailableWindow.xaml +++ b/src/GIMI-ModManager.WinUI/Views/ModUpdateAvailableWindow.xaml @@ -4,11 +4,13 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:GIMI_ModManager.WinUI.Views.Controls" + xmlns:controls1="using:CommunityToolkit.WinUI.UI.Controls" xmlns:converters="using:CommunityToolkit.WinUI.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:GIMI_ModManager.WinUI.Views" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:services="using:GIMI_ModManager.Core.Services" + xmlns:viewModels="using:GIMI_ModManager.WinUI.ViewModels" xmlns:winUiEx="using:WinUIEx" x:Name="RootWindow" mc:Ignorable="d"> @@ -49,73 +51,122 @@ ToolTipService.ToolTip="Open Mod folder..." /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -