Files
AlayaCore/AlayaCore/Services/ModService.cs
Ryan Macham 6938635ee4 Added Retry Policy and Launcher Error.
Included ErrorHelper for mapping.
2026-04-06 20:47:13 +01:00

478 lines
19 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AlayaCore.Abstractions.Interfaces;
using AlayaCore.Abstractions.Interfaces.Clients;
using AlayaCore.Abstractions.Interfaces.Policies;
using AlayaCore.Abstractions.Interfaces.Services;
using AlayaCore.Installation;
using AlayaCore.Models;
using AlayaCore.Models.Configuration;
using AlayaCore.Models.Manifests;
using AlayaCore.Models.Manifests.DTO;
using AlayaCore.Models.Progress;
using AlayaCore.Utilities.Enums;
using AlayaCore.Utilities.Extensions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AlayaCore.Services
{
public sealed class ModService : IModService
{
private const string INSTALLED_MODS_MANIFEST_FILE_NAME = "InstalledModsManifest.json";
private readonly IDownloadService _downloadService;
private readonly ModrinthConnectionOptions _options;
private readonly IHttpClient _httpClient;
private readonly IFileStore _fileStore;
private readonly IRetryPolicy _retryPolicy;
private readonly ILogger<ModService> _logger;
public ModService(
IDownloadService downloadService,
ModrinthConnectionOptions options,
IHttpClient httpClient,
IFileStore fileStore,
IRetryPolicy retryPolicy,
ILogger<ModService> logger)
{
_downloadService = downloadService ?? throw new ArgumentNullException(nameof(downloadService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_fileStore = fileStore ?? throw new ArgumentNullException(nameof(fileStore));
_retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task ProcessModsAsync(
ManifestModel manifest,
InstallEnvironment environment,
IProgress<DownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
if (manifest == null)
{
throw new ArgumentNullException(nameof(manifest));
}
if (environment == null)
{
throw new ArgumentNullException(nameof(environment));
}
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<ModFileEntry> installedMods = environment.InstalledModsManifest.Mods;
List<ModFileEntry> requiredMods = manifest.Files
.Where(file => file.Type == FileType.Mod)
.OrderBy(file => file.FileName, StringComparer.OrdinalIgnoreCase)
.ToList();
_logger.LogInformation(
"Starting mod sync. RequiredMods: {RequiredModCount}, InstalledModsManifestEntries: {InstalledModCount}",
requiredMods.Count,
installedMods.Count);
RemoveStaleMods(requiredMods, cancellationToken);
List<ModFileEntry> finalInstalledMods = new List<ModFileEntry>();
foreach (ModFileEntry requiredMod in requiredMods)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug(
"Processing required mod {FileName}. Expected SHA-512: {Sha512Hash}, Expected Size: {Size}",
requiredMod.FileName,
requiredMod.Sha512Hash,
requiredMod.Size);
ModFileEntry? installedMod = installedMods.FirstOrDefault(
mod => string.Equals(mod.FileName, requiredMod.FileName, StringComparison.OrdinalIgnoreCase));
string destinationPath = GetModDestinationPath(requiredMod);
bool isValidInstalledMod =
installedMod != null &&
IsInstalledModUpToDate(installedMod, requiredMod) &&
File.Exists(destinationPath) &&
_downloadService.VerifyFileHash(destinationPath, requiredMod.Sha512Hash);
if (isValidInstalledMod)
{
_logger.LogInformation(
"Mod {FileName} is already installed and valid. Skipping download.",
requiredMod.FileName);
finalInstalledMods.Add(installedMod!);
continue;
}
if (installedMod == null)
{
_logger.LogInformation(
"Mod {FileName} is missing locally and will be downloaded.",
requiredMod.FileName);
}
else
{
_logger.LogWarning(
"Mod {FileName} is present but invalid or outdated. Stored SHA-512: {InstalledHash}, Expected SHA-512: {RequiredHash}, Stored Size: {InstalledSize}, Expected Size: {RequiredSize}",
requiredMod.FileName,
installedMod.Sha512Hash,
requiredMod.Sha512Hash,
installedMod.Size,
requiredMod.Size);
}
Uri modUri = await ResolveModUrlAsync(requiredMod, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Downloading mod {FileName} from {ModUri} to {DestinationPath}.",
requiredMod.FileName,
modUri,
destinationPath);
await _downloadService.DownloadFileAsync(
modUri,
destinationPath,
requiredMod.Sha512Hash,
progress,
cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Download completed successfully for mod {FileName}.",
requiredMod.FileName);
finalInstalledMods.Add(new ModFileEntry(
requiredMod.FileName,
requiredMod.Type,
requiredMod.Sha512Hash,
requiredMod.Size));
}
await FlushInstalledModsManifestAsync(finalInstalledMods, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Mod sync completed successfully. Final installed mod count: {InstalledModCount}",
finalInstalledMods.Count);
}
private static bool IsInstalledModUpToDate(
ModFileEntry installedMod,
ModFileEntry requiredMod)
{
if (installedMod == null)
{
throw new ArgumentNullException(nameof(installedMod));
}
if (requiredMod == null)
{
throw new ArgumentNullException(nameof(requiredMod));
}
if (string.IsNullOrWhiteSpace(requiredMod.Sha512Hash))
{
return false;
}
return string.Equals(
installedMod.Sha512Hash,
requiredMod.Sha512Hash,
StringComparison.OrdinalIgnoreCase)
&& installedMod.Size == requiredMod.Size
&& installedMod.Type == requiredMod.Type;
}
private async Task<Uri> ResolveModUrlAsync(
ModFileEntry fileEntry,
CancellationToken cancellationToken = default)
{
if (fileEntry == null)
{
throw new ArgumentNullException(nameof(fileEntry));
}
if (string.IsNullOrWhiteSpace(fileEntry.Sha512Hash))
{
_logger.LogError(
"Failed to resolve mod URL because mod {FileName} did not contain a SHA-512 hash.",
fileEntry.FileName);
throw new InvalidOperationException($"Mod '{fileEntry.FileName}' does not contain a SHA-512 hash.");
}
string versionEndpoint = BuildVersionEndpoint(fileEntry.Sha512Hash);
_logger.LogDebug(
"Resolving mod URL for {FileName} using Modrinth endpoint {VersionEndpoint}.",
fileEntry.FileName,
versionEndpoint);
return await _retryPolicy.ExecuteAsync(
async token =>
{
using HttpResponseMessage response = await _httpClient.GetAsync(
new Uri(versionEndpoint, UriKind.Absolute),
HttpCompletionOption.ResponseContentRead,
token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
{
_logger.LogError(
"Mod metadata response for {FileName} from {VersionEndpoint} was empty.",
fileEntry.FileName,
versionEndpoint);
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' was empty.");
}
JObject jsonObject = JObject.Parse(json);
JArray? filesArray = jsonObject["files"] as JArray;
if (filesArray == null || filesArray.Count == 0)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain any files.",
fileEntry.FileName);
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain any files.");
}
JObject? selectedFile = filesArray
.OfType<JObject>()
.FirstOrDefault(file => file.Value<bool?>("primary") == true)
?? filesArray.OfType<JObject>().FirstOrDefault();
if (selectedFile == null)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a usable file entry.",
fileEntry.FileName);
throw new InvalidDataException($"The mod metadata response for '{fileEntry.FileName}' did not contain a usable file entry.");
}
JObject? hashesObject = selectedFile["hashes"] as JObject;
if (hashesObject == null)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a hashes object.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a hashes object.");
}
string? remoteSha512Hash = hashesObject.Value<string>("sha512");
if (string.IsNullOrWhiteSpace(remoteSha512Hash))
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid SHA-512 hash.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid SHA-512 hash.");
}
if (!string.Equals(remoteSha512Hash, fileEntry.Sha512Hash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"Mod metadata hash mismatch for {FileName}. Remote SHA-512: {RemoteHash}, Required SHA-512: {RequiredHash}",
fileEntry.FileName,
remoteSha512Hash,
fileEntry.Sha512Hash);
throw new InvalidDataException(
$"The mod metadata SHA-512 hash for '{fileEntry.FileName}' did not match the required manifest hash.");
}
long? size = selectedFile.Value<long?>("size");
if (!size.HasValue || size.Value <= 0)
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid file size.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file size.");
}
if (size.Value != fileEntry.Size)
{
_logger.LogError(
"Mod metadata size mismatch for {FileName}. Remote Size: {RemoteSize}, Required Size: {RequiredSize}",
fileEntry.FileName,
size.Value,
fileEntry.Size);
throw new InvalidDataException(
$"The mod metadata size for '{fileEntry.FileName}' did not match the required manifest size.");
}
string? modUrl = selectedFile.Value<string>("url");
if (string.IsNullOrWhiteSpace(modUrl))
{
_logger.LogError(
"Mod metadata response for {FileName} did not contain a valid file URL.",
fileEntry.FileName);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' did not contain a valid file URL.");
}
if (!Uri.TryCreate(modUrl, UriKind.Absolute, out Uri? result))
{
_logger.LogError(
"Mod metadata response for {FileName} contained an invalid file URL: {ModUrl}",
fileEntry.FileName,
modUrl);
throw new InvalidDataException(
$"The mod metadata response for '{fileEntry.FileName}' contained an invalid file URL.");
}
_logger.LogDebug(
"Resolved download URL for mod {FileName} to {ModUrl}.",
fileEntry.FileName,
result);
return result;
},
$"mod-metadata:{fileEntry.FileName}",
cancellationToken).ConfigureAwait(false);
}
private string BuildVersionEndpoint(string sha512Hash)
{
string baseUrl = _options.BaseApiUrl?.TrimEnd('/')
?? throw new InvalidOperationException("Modrinth base API URL is not configured.");
return $"{baseUrl}/version_file/{sha512Hash}";
}
private string GetModDestinationPath(ModFileEntry fileEntry)
{
if (fileEntry == null)
{
throw new ArgumentNullException(nameof(fileEntry));
}
if (string.IsNullOrWhiteSpace(fileEntry.FileName))
{
throw new ArgumentException("Mod file name cannot be null, empty, or whitespace.", nameof(fileEntry));
}
string modsDirectory = GetModsDirectoryPath();
string destinationPath = Path.Combine(modsDirectory, fileEntry.FileName);
_logger.LogDebug(
"Resolved destination path for mod {FileName} to {DestinationPath}.",
fileEntry.FileName,
destinationPath);
return destinationPath;
}
private string GetModsDirectoryPath()
{
string modsDirectory = _fileStore.GetOrCreate(FolderLocation.Mods);
_logger.LogDebug("Resolved mods directory to {ModsDirectory}.", modsDirectory);
return modsDirectory;
}
private void RemoveStaleMods(
IEnumerable<ModFileEntry> requiredMods,
CancellationToken cancellationToken)
{
if (requiredMods == null)
{
throw new ArgumentNullException(nameof(requiredMods));
}
string modsDirectory = _fileStore.Get(FolderLocation.Mods);
if (!Directory.Exists(modsDirectory))
{
_logger.LogDebug("Mods directory {ModsDirectory} does not exist. No stale mods need removal.", modsDirectory);
return;
}
HashSet<string> requiredFileNames = requiredMods
.Where(mod => !string.IsNullOrWhiteSpace(mod.FileName))
.Select(mod => mod.FileName)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (string filePath in Directory.GetFiles(modsDirectory))
{
cancellationToken.ThrowIfCancellationRequested();
string fileName = Path.GetFileName(filePath);
if (!requiredFileNames.Contains(fileName))
{
_logger.LogInformation(
"Removing stale mod file {FileName} at {FilePath}.",
fileName,
filePath);
File.Delete(filePath);
}
}
}
private async Task FlushInstalledModsManifestAsync(
IEnumerable<ModFileEntry> installedMods,
CancellationToken cancellationToken = default)
{
if (installedMods == null)
{
throw new ArgumentNullException(nameof(installedMods));
}
List<ModFileEntry> entries = installedMods.ToList();
InstalledModsManifestModel manifest = new InstalledModsManifestModel(entries);
string manifestsDirectory = _fileStore.GetOrCreate(FolderLocation.Manifests);
string manifestPath = Path.Combine(manifestsDirectory, INSTALLED_MODS_MANIFEST_FILE_NAME);
string temporaryManifestPath = manifestPath + ".tmp";
InstalledModsManifestDto dto = manifest.ToDto();
string json = JsonConvert.SerializeObject(dto, Formatting.Indented);
_logger.LogDebug(
"Writing installed mods manifest to temporary path {TemporaryManifestPath}. EntryCount: {EntryCount}",
temporaryManifestPath,
entries.Count);
await File.WriteAllTextAsync(temporaryManifestPath, json, cancellationToken).ConfigureAwait(false);
if (File.Exists(manifestPath))
{
_logger.LogDebug("Deleting previous installed mods manifest at {ManifestPath}.", manifestPath);
File.Delete(manifestPath);
}
File.Move(temporaryManifestPath, manifestPath);
_logger.LogInformation(
"Installed mods manifest updated successfully at {ManifestPath}. EntryCount: {EntryCount}",
manifestPath,
entries.Count);
}
}
}