diff --git a/source/Reloaded.Mod.Launcher.Lib/Reloaded.Mod.Launcher.Lib.csproj b/source/Reloaded.Mod.Launcher.Lib/Reloaded.Mod.Launcher.Lib.csproj index 2182b25f..c5a839d0 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Reloaded.Mod.Launcher.Lib.csproj +++ b/source/Reloaded.Mod.Launcher.Lib/Reloaded.Mod.Launcher.Lib.csproj @@ -15,6 +15,7 @@ + all diff --git a/source/Reloaded.Mod.Launcher.Lib/Utility/JxlImageConverter.cs b/source/Reloaded.Mod.Launcher.Lib/Utility/JxlImageConverter.cs new file mode 100644 index 00000000..5f9ddaac --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Utility/JxlImageConverter.cs @@ -0,0 +1,32 @@ +using PhotoSauce.MagicScaler; +using PhotoSauce.NativeCodecs.Libjxl; + +namespace Reloaded.Mod.Launcher.Lib.Utility; + +/// +/// Converts image to JPEG XL format. +/// +public class JxlImageConverter : IImageConverter +{ + private static readonly IEncoderOptions EncoderOptions = new JxlLosslessEncoderOptions(JxlEncodeSpeed.Squirrel, JxlDecodeSpeed.Slowest); + + static JxlImageConverter() + { + CodecManager.Configure(codecs => { codecs.UseLibjxl(); }); + } + + /// + public MemoryStream Convert(Span source, out string extension) + { + var settings = new ProcessImageSettings(); + settings.TrySetEncoderFormat(ImageMimeTypes.Jxl); + settings.EncoderOptions = EncoderOptions; + + var output = new MemoryStream(); + MagicImageProcessor.ProcessImage(source, output, settings); + + output.Position = 0; + extension = ".jxl"; + return output; + } +} \ No newline at end of file diff --git a/source/Reloaded.Mod.Loader.Tests/Reloaded.Mod.Loader.Tests.csproj b/source/Reloaded.Mod.Loader.Tests/Reloaded.Mod.Loader.Tests.csproj index e92327fc..10ea4465 100644 --- a/source/Reloaded.Mod.Loader.Tests/Reloaded.Mod.Loader.Tests.csproj +++ b/source/Reloaded.Mod.Loader.Tests/Reloaded.Mod.Loader.Tests.csproj @@ -56,6 +56,7 @@ + diff --git a/source/Reloaded.Mod.Loader.Tests/SETUP/TestEnvironmoent.cs b/source/Reloaded.Mod.Loader.Tests/SETUP/TestEnvironmoent.cs index 69661f65..d4b59b2e 100644 --- a/source/Reloaded.Mod.Loader.Tests/SETUP/TestEnvironmoent.cs +++ b/source/Reloaded.Mod.Loader.Tests/SETUP/TestEnvironmoent.cs @@ -1,4 +1,4 @@ -using Environment = Reloaded.Mod.Shared.Environment; +using System.Diagnostics; using Paths = Reloaded.Mod.Loader.IO.Paths; namespace Reloaded.Mod.Loader.Tests.SETUP; @@ -92,7 +92,7 @@ public TestEnvironmoent() ThisApplication = new ApplicationConfig(IdOfThisApp, "Reloaded Mod Loader Tests", - Environment.CurrentProcessLocation.Value, + Path.GetFullPath(Process.GetCurrentProcess().MainModule!.FileName!), new[] { TestModConfigA.ModId, TestModConfigB.ModId, TestModConfigD.ModId }); ConfigurationPathOfThisApp = Path.Combine(TestConfig.GetApplicationConfigDirectory(), IdOfThisApp, ApplicationConfig.ConfigFileName); diff --git a/source/Reloaded.Mod.Loader.Tests/Update/Pack/AutoPackageCreatorTests.cs b/source/Reloaded.Mod.Loader.Tests/Update/Pack/AutoPackageCreatorTests.cs new file mode 100644 index 00000000..230c70f5 --- /dev/null +++ b/source/Reloaded.Mod.Loader.Tests/Update/Pack/AutoPackageCreatorTests.cs @@ -0,0 +1,76 @@ +using Reloaded.Mod.Launcher.Lib.Utility; +using Reloaded.Mod.Loader.Tests.Update.Pack.Mocks; +using Reloaded.Mod.Loader.Update.Packs; +using Sewer56.Update.Resolvers.GameBanana; + +namespace Reloaded.Mod.Loader.Tests.Update.Pack; + +public class AutoPackageCreatorTests +{ + [Fact] + public void CannotCreate_WithMissingUpdateInfo() + { + using var tempFolderAlloc = new TemporaryFolderAllocation(); + var conf = CreateTestConfig(tempFolderAlloc.FolderPath); + + Assert.False(AutoPackCreator.ValidateCanCreate(new []{ conf }, out var incompatible)); + Assert.Single(incompatible); + } + + [Fact] + public void CanCreate_WithUpdateInfo() + { + using var tempFolderAlloc = new TemporaryFolderAllocation(); + var conf = CreateTestConfig(tempFolderAlloc.FolderPath); + AddGitHubUpdateResolver(conf); + + Assert.True(AutoPackCreator.ValidateCanCreate(new []{ conf }, out var incompatible)); + Assert.Empty(incompatible); + } + + [Fact] + public async Task AutoPackCreator_CanImportFromModSearch() + { + // Arrange + using var tempMod = new TemporaryFolderAllocation(); + var modConf = CreateTestConfig(tempMod.FolderPath); + AddGitHubUpdateResolver(modConf); + + var provider = GetGameBananaPackageProvider(); + + // Act + var pack = await AutoPackCreator.CreateAsync(new ModConfig[] { modConf.Config }, + new DummyImageConverter(), + new List() { provider}); + var built = pack.Build(out var package); + + // Assert + File.WriteAllBytes("build.zip", built.ToArray()); + Assert.Single(package.Items); + Assert.True(package.Items[0].ImageFiles.Count > 1); + } + + private PathTuple CreateTestConfig(string folder) + { + return new PathTuple(Path.Combine(folder, ModConfig.ConfigFileName), new ModConfig() + { + ModId = "sonicheroes.essentials.graphics", + ModName = "Graphics Essentials" // so it finds on GameBanana + }); + } + + private void AddGitHubUpdateResolver(PathTuple modTuple) + { + var factory = new GitHubReleasesUpdateResolverFactory(); + factory.SetConfiguration(modTuple, new GitHubReleasesUpdateResolverFactory.GitHubConfig() + { + RepositoryName = "Heroes.Graphics.Essentials.ReloadedII", + UserName = "Sewer56" + }); + } + + private GameBananaPackageProvider GetGameBananaPackageProvider() + { + return new GameBananaPackageProvider(6061); + } +} \ No newline at end of file diff --git a/source/Reloaded.Mod.Loader.Tests/Update/Pack/Mocks/DummyImageConverter.cs b/source/Reloaded.Mod.Loader.Tests/Update/Pack/Mocks/DummyImageConverter.cs new file mode 100644 index 00000000..e60f1b05 --- /dev/null +++ b/source/Reloaded.Mod.Loader.Tests/Update/Pack/Mocks/DummyImageConverter.cs @@ -0,0 +1,10 @@ +namespace Reloaded.Mod.Loader.Tests.Update.Pack.Mocks; + +public class DummyImageConverter : IImageConverter +{ + public MemoryStream Convert(Span source, out string extension) + { + extension = ".org"; + return new MemoryStream(source.ToArray()); + } +} \ No newline at end of file diff --git a/source/Reloaded.Mod.Loader.Update/Interfaces/IImageConverter.cs b/source/Reloaded.Mod.Loader.Update/Interfaces/IImageConverter.cs new file mode 100644 index 00000000..8e9e87cf --- /dev/null +++ b/source/Reloaded.Mod.Loader.Update/Interfaces/IImageConverter.cs @@ -0,0 +1,15 @@ +namespace Reloaded.Mod.Loader.Update.Interfaces; + +/// +/// Interface that can be used to provide support for converting images. +/// +public interface IImageConverter +{ + /// + /// Converts the image to new format. + /// + /// Span containing the source image to be converted. + /// The extension for the image. + /// The converted image in a stream. Stream should have position 0 and end at end of file. + public MemoryStream Convert(Span source, out string extension); +} \ No newline at end of file diff --git a/source/Reloaded.Mod.Loader.Update/Packs/AutoPackCreator.cs b/source/Reloaded.Mod.Loader.Update/Packs/AutoPackCreator.cs new file mode 100644 index 00000000..075e8c8d --- /dev/null +++ b/source/Reloaded.Mod.Loader.Update/Packs/AutoPackCreator.cs @@ -0,0 +1,135 @@ +namespace Reloaded.Mod.Loader.Update.Packs; + +/// +/// Utility class for automatic creation of packages based on a given sample input of mods. +/// +public static class AutoPackCreator +{ + /// + /// Checks if all mods in given list can be used in the pack by verifying they have enabled updates. + /// + /// List of mod configurations to check. + /// List of mods to check for compatibility. + /// True if mods can be packed, else false. + public static bool ValidateCanCreate(IEnumerable> configurations, out List> incompatibleMods) + { + incompatibleMods = new List>(); + foreach (var config in configurations) + { + if (!PackageResolverFactory.HasAnyConfiguredResolver(config)) + incompatibleMods.Add(config); + } + + return incompatibleMods.Count <= 0; + } + + /// + /// Automatically creates a package. + /// + /// The configurations used to create the config. Must at least have update data, ModId and ModName. + /// Used for converting images. + /// Providers that can be used to search for packages. + /// Token to cancel the operation. + public static async Task CreateAsync(IEnumerable configurations, IImageConverter imageConverter, IList packageProviders, CancellationToken token = default) + { + var builder = new ReloadedPackBuilder(); + var imageDownloader = new ImageCacheService(); + + builder.SetName("My Autogenerated Package"); + builder.SetReadme("You should probably add description here."); + + foreach (var config in configurations) + { + var itemBuilder = builder.AddModItem(config.ModId); + itemBuilder.SetName(config.ModName); + itemBuilder.SetPluginData(config.PluginData); + + var bestPkg = await GetBestPackageForTemplateAsync(config.ModId, config.ModName, packageProviders, token); + + // Add images if possible + if (bestPkg.images != null) + { + foreach (var image in bestPkg.images) + { + var file = await imageDownloader.GetOrDownloadFileFromUrl(image.Uri, imageDownloader.ModPreviewExpiration, false, token); + var converted = imageConverter.Convert(file, out string ext); + itemBuilder.AddImage(converted, ext, image.Caption); + } + } + + // Add readme if possible + if (!string.IsNullOrEmpty(bestPkg.markdownReadme)) + itemBuilder.SetReadme(bestPkg.markdownReadme); + } + + return builder; + } + + private static async Task<(DownloadableImage[]? images, string markdownReadme)> GetBestPackageForTemplateAsync(string modId, string modName, + IList downloadablePackageProviders, CancellationToken cancellationToken) + { + (DownloadableImage[]? images, string markdownReadme) result = new(); + + // We will get all packages with highest version and copy images & readme down the road. + NuGetVersion? highestVersion = null; + List itemsForHighestVersion = new List(); + + // Note: Some sources don't support e.g. images. + // We prioritise sources based on whether they contain images, then by readme. + // If preferred source does not contain both readme and image, we stitch them together from sources that do. + foreach (var provider in downloadablePackageProviders) + { + // Override if better than current best. + var candidates = await provider.SearchForModAsync(modId, modName, 50, 4, true, cancellationToken); + foreach (var candidate in candidates) + { + // Find items for highest version + if (candidate.Version != null) + { + if (highestVersion == null) + highestVersion = candidate.Version; + + if (candidate.Version == highestVersion) + itemsForHighestVersion.Add(candidate); + + if (candidate.Version > highestVersion) + { + itemsForHighestVersion.Clear(); + highestVersion = candidate.Version; + itemsForHighestVersion.Add(candidate); + } + } + + // Set images if more than existing best result. + if (candidate.Images != null) + { + // Assign images if unassigned + result.images ??= candidate.Images; + + // Prefer source with more images. + if (candidate.Images.Length > result.images.Length) + result.images = candidate.Images; + } + + // Set description if unassigned. + if (candidate.MarkdownReadme != null) + result.markdownReadme ??= candidate.MarkdownReadme; + } + } + + // Override supported items from + foreach (var item in itemsForHighestVersion) + { + // Set description if unassigned. + if (item.MarkdownReadme != null) + result.markdownReadme = item.MarkdownReadme; + + // Set images to ones from newest version in case older version had more images. + // But only if more than 1 image. We want to filter out entries with only thumbnail. + if (item.Images is { Length: > 1 }) + result.images = item.Images; + } + + return result; + } +} \ No newline at end of file