From 10789d82dbc8ca7a94267a491fd588c6756edec1 Mon Sep 17 00:00:00 2001 From: AnotherPillow <85362273+AnotherPillow@users.noreply.github.com> Date: Mon, 25 Aug 2025 19:03:42 +0100 Subject: [PATCH 1/4] collections, so far downloads and unarchives --- Stardrop/Models/Nexus/Web/CollectionResult.cs | 36 +++++ .../Nexus/Web/CollectionRevisionDownload.cs | 23 +++ Stardrop/Models/Nexus/Web/NXM.cs | 25 +++ Stardrop/Stardrop.csproj | 6 +- Stardrop/Utilities/External/NexusClient.cs | 77 ++++++++- .../Utilities/External/NexusGraphQLClient.cs | 68 ++++++++ Stardrop/Views/MainWindow.axaml.cs | 150 ++++++++++++++---- Stardrop/i18n/default.json | 5 + 8 files changed, 350 insertions(+), 40 deletions(-) create mode 100644 Stardrop/Models/Nexus/Web/CollectionResult.cs create mode 100644 Stardrop/Models/Nexus/Web/CollectionRevisionDownload.cs create mode 100644 Stardrop/Utilities/External/NexusGraphQLClient.cs diff --git a/Stardrop/Models/Nexus/Web/CollectionResult.cs b/Stardrop/Models/Nexus/Web/CollectionResult.cs new file mode 100644 index 00000000..4ef0c366 --- /dev/null +++ b/Stardrop/Models/Nexus/Web/CollectionResult.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace Stardrop.Models.Nexus.Web +{ + using System.Text.Json.Serialization; + + public class CollectionResult + { + [JsonPropertyName("collection")] + public Collection Collection { get; set; } + } + + public class Collection + { + [JsonPropertyName("gameId")] + public int GameId { get; set; } + + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("summary")] + public string Summary { get; set; } + + [JsonPropertyName("latestPublishedRevision")] + public LatestPublishedRevision LatestPublishedRevision { get; set; } + } + + public class LatestPublishedRevision + { + [JsonPropertyName("downloadLink")] + public string DownloadLink { get; set; } + } +} diff --git a/Stardrop/Models/Nexus/Web/CollectionRevisionDownload.cs b/Stardrop/Models/Nexus/Web/CollectionRevisionDownload.cs new file mode 100644 index 00000000..2cb0c040 --- /dev/null +++ b/Stardrop/Models/Nexus/Web/CollectionRevisionDownload.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Stardrop.Models.Nexus.Web +{ + public class CollectionRevisionDownloadLink + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("short_name")] + public string ShortName { get; set; } + + [JsonPropertyName("URI")] + public string Uri { get; set; } + } + + public class CollectionRevisionDownloadResult + { + [JsonPropertyName("download_links")] + public List DownloadLinks { get; set; } + } +} diff --git a/Stardrop/Models/Nexus/Web/NXM.cs b/Stardrop/Models/Nexus/Web/NXM.cs index 3310e6c9..658815a9 100644 --- a/Stardrop/Models/Nexus/Web/NXM.cs +++ b/Stardrop/Models/Nexus/Web/NXM.cs @@ -1,10 +1,35 @@ using System; +using System.Text.RegularExpressions; +using Stardrop.Utilities.External; namespace Stardrop.Models.Nexus.Web { public class NXM { + public enum NXMPurpose + { + Mod, + Collection, + Unknown + } + + public static NXMPurpose CalculatePurpose(NXM nxm) + { + if (nxm.Link is null) return NXMPurpose.Unknown; + + var modMatch = Regex.Match(Regex.Unescape(nxm.Link), NexusClient._nxmModPattern); + var collectionMatch = Regex.Match(Regex.Unescape(nxm.Link), NexusClient._nxmCollectionPattern); + + if (modMatch.Success) return NXMPurpose.Mod; + if (collectionMatch.Success) return NXMPurpose.Collection; + + return NXMPurpose.Unknown; + } + + public string? Link { get; set; } public DateTime Timestamp { get; set; } + + public NXMPurpose? Purpose { get; set; } } } diff --git a/Stardrop/Stardrop.csproj b/Stardrop/Stardrop.csproj index 3eb4c3a2..4d4fcb52 100644 --- a/Stardrop/Stardrop.csproj +++ b/Stardrop/Stardrop.csproj @@ -38,12 +38,16 @@ + + + + - + diff --git a/Stardrop/Utilities/External/NexusClient.cs b/Stardrop/Utilities/External/NexusClient.cs index 2317eb9f..96175af1 100644 --- a/Stardrop/Utilities/External/NexusClient.cs +++ b/Stardrop/Utilities/External/NexusClient.cs @@ -5,6 +5,7 @@ using Stardrop.Models.Nexus.Web; using System; using System.Collections.Generic; +using System.Dynamic; using System.IO; using System.Linq; using System.Net.Http; @@ -22,6 +23,7 @@ public static class Nexus private static readonly Uri _baseUrl = new Uri("https://api.nexusmods.com/v1/"); public static NexusClient? Client { get; private set; } + public static NexusGraphQLClient? GraphQLClient { get; private set; } public delegate void NexusClientChangedHandler(NexusClient? oldClient, NexusClient? newClient); public static event NexusClientChangedHandler? ClientChanged = null; @@ -83,6 +85,9 @@ public static class Nexus ClientChanged?.Invoke(oldClient: Client, newClient: nexusClient); Client = nexusClient; + + GraphQLClient = new NexusGraphQLClient(client); + return Client; } @@ -99,7 +104,8 @@ public static void ClearClient() public class NexusClient { - private const string _nxmPattern = @"nxm:\/\/(?stardewvalley)\/mods\/(?[0-9]+)\/files\/(?[0-9]+)\?key=(?.*)&expires=(?[0-9]+)&user_id=(?[0-9]+)"; + public static string _nxmModPattern = @"nxm:\/\/(?stardewvalley)\/mods\/(?[0-9]+)\/files\/(?[0-9]+)\?key=(?.*)&expires=(?[0-9]+)&user_id=(?[0-9]+)"; + public static string _nxmCollectionPattern = @"nxm:\/\/(?stardewvalley)\/collections\/(?[a-z0-9]+)\/revisions\/(?[0-9]+)"; private readonly HttpClient _client; private NexusUser _settings = null!; @@ -180,7 +186,7 @@ public async Task ValidateKey() return null; } - var match = Regex.Match(Regex.Unescape(nxmData.Link), _nxmPattern); + var match = Regex.Match(Regex.Unescape(nxmData.Link), _nxmModPattern); if (match.Success is false || match.Groups["domain"].ToString().ToLower() != "stardewvalley" || Int32.TryParse(match.Groups["mod"].ToString(), out int modId) is false) { return null; @@ -230,6 +236,19 @@ public async Task ValidateKey() return null; } + public async Task GetCollectionDetailsViaNXM(NXM nxmData) + { + if (nxmData.Link is null) return null; + + var match = Regex.Match(Regex.Unescape(nxmData.Link), _nxmCollectionPattern); + if (match.Success is false || match.Groups["domain"].ToString().ToLower() != "stardewvalley" || Int32.TryParse(match.Groups["revision"].ToString(), out int modId) is false) + { + return null; + } + + return await Nexus.GraphQLClient!.GetCollection(match.Groups["collection"].ToString());; + } + public async Task GetFileByVersion(int modId, string version, string? modFlag = null) { if (SemVersion.TryParse(version.Replace("v", String.Empty), SemVersionStyles.Any, out var targetVersion) is false) @@ -309,7 +328,7 @@ public async Task ValidateKey() return null; } - var match = Regex.Match(Regex.Unescape(nxmData.Link), _nxmPattern); + var match = Regex.Match(Regex.Unescape(nxmData.Link), _nxmModPattern); if (match.Success is false || match.Groups["domain"].ToString().ToLower() != "stardewvalley" || Int32.TryParse(match.Groups["mod"].ToString(), out int modId) is false || Int32.TryParse(match.Groups["file"].ToString(), out int fileId) is false) { return null; @@ -380,6 +399,54 @@ public async Task ValidateKey() return null; } + public async Task GetCollectionArchiveLink(string revisionDownloadLink) + { + try + { + var response = await _client.GetAsync(revisionDownloadLink); + + if (response.StatusCode == System.Net.HttpStatusCode.OK && response.Content is not null) + { + string content = (await response.Content.ReadAsStringAsync()).Trim(); + Program.helper.Log($"Response from Nexus Mods:\n{content}"); + CollectionRevisionDownloadResult downloadLinks = JsonSerializer.Deserialize(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (downloadLinks is null || downloadLinks.DownloadLinks is null || downloadLinks.DownloadLinks.Count == 0) + { + Program.helper.Log($"Unable to get the download link for Nexus Mods"); + Program.helper.Log($"Response from Nexus Mods:\n{content}"); + } + else + { + UpdateRequestCounts(response.Headers); + + return downloadLinks.DownloadLinks.First().Uri; + } + } + else + { + if (response.StatusCode != System.Net.HttpStatusCode.OK) + { + Program.helper.Log($"Bad status given from Nexus Mods collection: {response.StatusCode}"); + if (response.Content is not null) + { + Program.helper.Log($"Response from Nexus Mods collection:\n{await response.Content.ReadAsStringAsync()}"); + } + } + else if (response.Content is null) + { + Program.helper.Log($"No response from Nexus Mods on collection!"); + } + } + } + catch (Exception ex) + { + Program.helper.Log($"Failed to get the archive download link for Nexus Mods collection: {ex}", Helper.Status.Alert); + } + + return null; + } + public async Task DownloadFileAndGetPath(string uri, string fileName) { var requestUri = new Uri(uri); @@ -400,7 +467,7 @@ public async Task DownloadFileAndGetPath(string uri, string long? contentLength = response.Content.Headers.ContentLength; DownloadStarted?.Invoke(this, new ModDownloadStartedEventArgs(requestUri, fileName, contentLength, downloadCancellationSource)); - + var buffer = new byte[81920]; long totalBytesRead = 0; int bytesRead; @@ -409,7 +476,7 @@ public async Task DownloadFileAndGetPath(string uri, string await fileStream.WriteAsync(buffer, 0, bytesRead, downloadCancellationSource.Token); totalBytesRead += bytesRead; DownloadProgressChanged?.Invoke(this, new ModDownloadProgressEventArgs(requestUri, totalBytesRead)); - } + } DownloadCompleted?.Invoke(this, new ModDownloadCompletedEventArgs(requestUri)); return new(DownloadResultKind.Success, Path.Combine(Pathing.GetNexusPath(), fileName)); diff --git a/Stardrop/Utilities/External/NexusGraphQLClient.cs b/Stardrop/Utilities/External/NexusGraphQLClient.cs new file mode 100644 index 00000000..e5d5a889 --- /dev/null +++ b/Stardrop/Utilities/External/NexusGraphQLClient.cs @@ -0,0 +1,68 @@ +using System; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using GraphQL; +using GraphQL.Client.Http; +using GraphQL.Client.Serializer.Newtonsoft; +using Newtonsoft.Json; +using Stardrop.Models.Nexus.Web; + +namespace Stardrop.Utilities.External +{ + public class NexusGraphQLClient + { + private GraphQLHttpClient _client; + + public NexusGraphQLClient(HttpClient client) + { + _client = new GraphQLHttpClient( + "https://api.nexusmods.com/v2/graphql", + new NewtonsoftJsonSerializer(), + client + ); + } + + public async Task GetCollection(string slug) // slug is the id at the end of the url + { + Program.helper.Log("getting collection download link"); + GraphQLRequest query = new() + { + Query = @" + query Collection($slug: String!, $domain: String!) { + collection(slug: $slug, viewAdultContent: false, domainName: $domain) { + gameId + id + name + summary + latestPublishedRevision { + downloadLink + } + } + } + ", + Variables = new + { + slug = slug, + domain = "stardewvalley" + }, + OperationName = "Collection" + }; + + var res = await _client.SendQueryAsync(query); + + if (res.Errors != null && res.Errors.Length > 0) + { + foreach (var error in res.Errors) + { + Program.helper.Log($"Got error while getting collection download link: {error.Message}"); + } + + return null; + } + + return res.Data; + + } + } +} \ No newline at end of file diff --git a/Stardrop/Views/MainWindow.axaml.cs b/Stardrop/Views/MainWindow.axaml.cs index 5faebc03..46bbf601 100644 --- a/Stardrop/Views/MainWindow.axaml.cs +++ b/Stardrop/Views/MainWindow.axaml.cs @@ -29,6 +29,9 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using static Stardrop.Models.SMAPI.Web.ModEntryMetadata; +using SharpCompress.Archives; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Common; namespace Stardrop.Views { @@ -335,7 +338,10 @@ private async void MainWindow_Opened(object? sender, EventArgs e) if (String.IsNullOrEmpty(Program.nxmLink) is false) { - await ProcessNXMLink(new NXM() { Link = Program.nxmLink, Timestamp = DateTime.Now }); + NXM nxm = new() { Link = Program.nxmLink, Timestamp = DateTime.Now }; + nxm.Purpose = NXM.CalculatePurpose(nxm); + await ProcessNXMLink(nxm); + Program.nxmLink = null; } @@ -1629,7 +1635,6 @@ private async Task HandleModListRefresh() internal async Task ProcessNXMLink(NXM nxmLink) { - if (Nexus.Client is null) { await CreateWarningWindow(Program.translation.Get("ui.message.require_nexus_login"), Program.translation.Get("internal.ok")); @@ -1642,59 +1647,136 @@ internal async Task ProcessNXMLink(NXM nxmLink) return false; } - Program.helper.Log($"Processing NXM link: {nxmLink.Link}"); - var processedDownloadLink = await Nexus.Client.GetFileDownloadLink(nxmLink, EnumParser.GetDescription(Program.settings.PreferredNexusServer)); - Program.helper.Log($"Processed link: {processedDownloadLink}"); - - if (String.IsNullOrEmpty(processedDownloadLink)) + if (nxmLink.Purpose is null || nxmLink.Purpose is NXM.NXMPurpose.Unknown) { await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_to_get_download_link"), nxmLink.Link), Program.translation.Get("internal.ok")); return false; } - // Get the mod details - var modDetails = await Nexus.Client.GetModDetailsViaNXM(nxmLink); - if (modDetails is null || String.IsNullOrEmpty(modDetails.Name)) + if (nxmLink.Purpose is NXM.NXMPurpose.Mod) { - await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_to_get_mod_details"), nxmLink.Link), Program.translation.Get("internal.ok")); - return false; - } + Program.helper.Log($"Processing NXM link: {nxmLink.Link} as type: mod"); + var processedDownloadLink = await Nexus.Client.GetFileDownloadLink(nxmLink, EnumParser.GetDescription(Program.settings.PreferredNexusServer)); + Program.helper.Log($"Processed link: {processedDownloadLink}"); - var requestWindow = new MessageWindow(String.Format(Program.translation.Get("ui.message.confirm_nxm_install"), modDetails.Name)); - if (Program.settings.IsAskingBeforeAcceptingNXM is false || await requestWindow.ShowDialog(this)) - { - var downloadResult = await Nexus.Client.DownloadFileAndGetPath(processedDownloadLink, modDetails.Name); - if (downloadResult.ResultKind is DownloadResultKind.Failed) + if (String.IsNullOrEmpty(processedDownloadLink)) { - await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_nexus_install"), modDetails.Name), Program.translation.Get("internal.ok")); + await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_to_get_download_link"), nxmLink.Link), Program.translation.Get("internal.ok")); return false; } - if (downloadResult.ResultKind is DownloadResultKind.UserCanceled) + + // Get the mod details + var modDetails = await Nexus.Client.GetModDetailsViaNXM(nxmLink); + if (modDetails is null || String.IsNullOrEmpty(modDetails.Name)) { - // No need for a warning, this is something the user chose intentionally + await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_to_get_mod_details"), nxmLink.Link), Program.translation.Get("internal.ok")); return false; } - string downloadedFilePath = downloadResult.DownloadedModFilePath!; - var addedMods = await AddMods(new string[] { downloadedFilePath }); - await CheckForModUpdates(addedMods, useCache: true, skipCacheCheck: true); - await GetCachedModUpdates(_viewModel.Mods.ToList(), skipCacheCheck: true); + var requestWindow = new MessageWindow(String.Format(Program.translation.Get("ui.message.confirm_nxm_install"), modDetails.Name)); + if (Program.settings.IsAskingBeforeAcceptingNXM is false || await requestWindow.ShowDialog(this)) + { + var downloadResult = await Nexus.Client.DownloadFileAndGetPath(processedDownloadLink, modDetails.Name); + if (downloadResult.ResultKind is DownloadResultKind.Failed) + { + await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_nexus_install"), modDetails.Name), Program.translation.Get("internal.ok")); + return false; + } + if (downloadResult.ResultKind is DownloadResultKind.UserCanceled) + { + // No need for a warning, this is something the user chose intentionally + return false; + } + string downloadedFilePath = downloadResult.DownloadedModFilePath!; + + var addedMods = await AddMods(new string[] { downloadedFilePath }); + await CheckForModUpdates(addedMods, useCache: true, skipCacheCheck: true); + await GetCachedModUpdates(_viewModel.Mods.ToList(), skipCacheCheck: true); - // Delete the downloaded archived mod - if (File.Exists(downloadedFilePath)) + // Delete the downloaded archived mod + if (File.Exists(downloadedFilePath)) + { + File.Delete(downloadedFilePath); + } + + _viewModel.EvaluateRequirements(); + _viewModel.UpdateEndorsements(); + _viewModel.UpdateFilter(); + + // Let the user know that the mod was installed via NXM + await CreateWarningWindow(String.Format(Program.translation.Get("ui.message.succeeded_nexus_install"), modDetails.Name), Program.translation.Get("internal.ok")); + } + + return true; + } + else if (nxmLink.Purpose is NXM.NXMPurpose.Collection) + { + Program.helper.Log($"Processing NXM link: {nxmLink.Link} as type: collection"); + + CollectionResult? collection = await Nexus.Client.GetCollectionDetailsViaNXM(nxmLink); + + // Failed to get it for whatever reason + if (collection is null) { - File.Delete(downloadedFilePath); + await CreateWarningWindow(String.Format(Program.translation.Get("ui.message.failed_collection_get"), nxmLink.Link), Program.translation.Get("internal.ok")); + + return false; } - _viewModel.EvaluateRequirements(); - _viewModel.UpdateEndorsements(); - _viewModel.UpdateFilter(); + Program.helper.Log("Got collection download URL: " + collection.Collection.LatestPublishedRevision.DownloadLink); + + var requestWindow = new MessageWindow(String.Format(Program.translation.Get("ui.message.confirm_nxm_collection_install"), collection.Collection.Name)); + if (Program.settings.IsAskingBeforeAcceptingNXM is false || await requestWindow.ShowDialog(this)) + { + string? archiveDownloadUri = await Nexus.Client.GetCollectionArchiveLink(collection.Collection.LatestPublishedRevision.DownloadLink); + if (archiveDownloadUri is null) + { + await CreateWarningWindow(String.Format(Program.translation.Get("ui.message.failed_collection_get_archive"), collection.Collection.LatestPublishedRevision.DownloadLink), Program.translation.Get("internal.ok")); + return false; + } + + string collectionFilename = $"{collection.Collection.Name} - {collection.Collection.Id}.7z"; + string collectionFolderName = $"{collection.Collection.Name} - {collection.Collection.Id}"; + string collectionFolderPath = Path.Combine(Pathing.GetNexusPath(), collectionFolderName); + + if (!Directory.Exists(collectionFolderPath)) Directory.CreateDirectory(collectionFolderPath); + + NexusDownloadResult mainDownloadResult = await Nexus.Client.DownloadFileAndGetPath( + archiveDownloadUri, + collectionFilename + ); + + if (mainDownloadResult.DownloadedModFilePath is null) + { + await CreateWarningWindow(String.Format(Program.translation.Get("ui.message.failed_collection_download_archive"), archiveDownloadUri), Program.translation.Get("internal.ok")); + return false; + } + + Program.helper.Log($"Downloaded collection to {mainDownloadResult.DownloadedModFilePath}"); - // Let the user know that the mod was installed via NXM - await CreateWarningWindow(String.Format(Program.translation.Get("ui.message.succeeded_nexus_install"), modDetails.Name), Program.translation.Get("internal.ok")); + using (var archive = SevenZipArchive.Open(mainDownloadResult.DownloadedModFilePath)) + { + foreach (var entry in archive.Entries) + { + if (!entry.IsDirectory) + { + entry.WriteToDirectory( + collectionFolderPath, + new ExtractionOptions() + { + ExtractFullPath = true, + Overwrite = true + } + ); + } + } + } + } + + return true; } - return true; + return false; } private void SetLockState(bool isWindowLocked, string? lockReason = null) diff --git a/Stardrop/i18n/default.json b/Stardrop/i18n/default.json index 2beb0c66..8faa4660 100644 --- a/Stardrop/i18n/default.json +++ b/Stardrop/i18n/default.json @@ -227,6 +227,11 @@ "ui.message.confirm_nxm_install": "Would you like to install the following mod:\n\n{0}\n\nYou can disable this confirmation in the settings menu.", "ui.message.require_nexus_login": "This action requires being signed into the Nexus Mods API.", "ui.message.succeeded_nexus_install": "Successfully installed the following mod via Nexus Mods:\n\n{0}", + + "ui.message.failed_collection_get": "Failed to get collection download for:\n\n{0}", + "ui.message.confirm_nxm_collection_install": "Would you like to install the following collection:\n\n{0}\n\nYou can disable this confirmation in the settings menu.", + "ui.message.failed_collection_get_archive": "Failed to get collection archive download for:\n\n{0}", + "ui.message.failed_collection_download_archive": "Failed to download collection archive for:\n\n{0}", // Window Names "ui.window.settings.name": "Settings", From 4c2f48c78f1e074941cab3355092634b473b6d97 Mon Sep 17 00:00:00 2001 From: AnotherPillow <85362273+AnotherPillow@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:57:17 +0100 Subject: [PATCH 2/4] try download --- Stardrop/Models/Nexus/CollectionIndex.cs | 157 +++++++++++++++++++++++ Stardrop/Views/MainWindow.axaml.cs | 70 ++++++++++ Stardrop/i18n/default.json | 1 + 3 files changed, 228 insertions(+) create mode 100644 Stardrop/Models/Nexus/CollectionIndex.cs diff --git a/Stardrop/Models/Nexus/CollectionIndex.cs b/Stardrop/Models/Nexus/CollectionIndex.cs new file mode 100644 index 00000000..2492ef6f --- /dev/null +++ b/Stardrop/Models/Nexus/CollectionIndex.cs @@ -0,0 +1,157 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Text.Json.Serialization; + +namespace Stardrop.Models.Nexus +{ + public class CollectionInfo + { + [JsonPropertyName("author")] + public string Author { get; set; } + [JsonPropertyName("authorUrl")] + public string AuthorUrl { get; set; } + [JsonPropertyName("name")] + public string Name { get; set; } + [JsonPropertyName("description")] + public string Description { get; set; } + [JsonPropertyName("installInstructions")] + public string InstallInstructions { get; set; } + [JsonPropertyName("domainName")] + public string DomainName { get; set; } + [JsonPropertyName("gameVersions")] + public List GameVersions { get; set; } + } + + public class CollectionConfig + { + [JsonPropertyName("recommendNewProfile")] + public bool RecommendNewProfile { get; set; } + } + + + public enum CollectionModRuleType + { + Before, + Conflicts, + After + } + + public class CollectionModRuleSource + { + [JsonPropertyName("fileExpression")] + public string FileExpression { get; set; } + [JsonPropertyName("fileMD5")] + public string? FileMD5 { get; set; } + [JsonPropertyName("tag")] + public string? Tag { get; set; } + + [JsonPropertyName("versionMatch")] + public string VersionMatch { get; set; } + + [JsonPropertyName("logicalFileName")] + public string LogicalFileName { get; set; } + } + + public class CollectionModRuleReference + { + [JsonPropertyName("fileExpression")] + public string? FileExpression { get; set; } + [JsonPropertyName("fileMD5")] + public string? FileMD5 { get; set; } + + [JsonPropertyName("versionMatch")] + public string VersionMatch { get; set; } + + [JsonPropertyName("idHint")] + public string? IdHint { get; set; } + [JsonPropertyName("logicalFileName")] + public string? LogicalFileName { get; set; } + } + + public class CollectionModRule + { + [JsonPropertyName("type")] + public CollectionModRuleType Type { get; set; } + + [JsonPropertyName("source")] + public CollectionModRuleSource Source { get; set; } + + [JsonPropertyName("reference")] + public CollectionModRuleReference Reference { get; set; } + } + + public class CollectionModDetails + { + [JsonPropertyName("category")] + public string Category { get; set; } + [JsonPropertyName("type")] + public string Type { get; set; } + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum CollectionModSourceType + { + Nexus, + Direct, + Browse // manual download via web browser, picky suggested ignoring this one. + } + + public class CollectionModSource + { + [JsonPropertyName("type")] + public CollectionModSourceType Type { get; set; } + [JsonPropertyName("md5")] + public string MD5Checksum { get; set; } + [JsonPropertyName("fileSize")] + public int Size { get; set; } + [JsonPropertyName("logicalFilename")] + public string LogicalFilename { get; set; } + [JsonPropertyName("updatePolicy")] + public string UpdatePolicy { get; set; } + + // "nexus" type only + [JsonPropertyName("modId")] + public int? ModId { get; set; } + [JsonPropertyName("fileId")] + public int? FileId { get; set; } + [JsonPropertyName("tag")] + public string? Tag { get; set; } + + // "browse" and "direct" type only + [JsonPropertyName("url")] + public string? Url { get; set; } + } + + public class CollectionMods + { + [JsonPropertyName("name")] + public string Name { get; set; } + [JsonPropertyName("version")] + public string Version { get; set; } + [JsonPropertyName("optional")] + public bool Optional { get; set; } + [JsonPropertyName("domainName")] + public string DomainName { get; set; } + [JsonPropertyName("author")] + public string Author { get; set; } + [JsonPropertyName("phase")] + public int Phase { get; set; } + [JsonPropertyName("details")] + public CollectionModDetails Details { get; set; } + [JsonPropertyName("source")] + public CollectionModSource Source { get; set; } + + } + + public class CollectionIndex + { + [JsonPropertyName("info")] + public CollectionInfo Info { get; set; } + [JsonPropertyName("collectionConfig")] + public CollectionConfig Config { get; set; } + [JsonPropertyName("modRules")] + public List ModRules { get; set; } + [JsonPropertyName("mods")] + public List Mods { get; set; } + } +} \ No newline at end of file diff --git a/Stardrop/Views/MainWindow.axaml.cs b/Stardrop/Views/MainWindow.axaml.cs index 46bbf601..c3119ce4 100644 --- a/Stardrop/Views/MainWindow.axaml.cs +++ b/Stardrop/Views/MainWindow.axaml.cs @@ -32,6 +32,7 @@ using SharpCompress.Archives; using SharpCompress.Archives.SevenZip; using SharpCompress.Common; +using Stardrop.Models.Nexus; namespace Stardrop.Views { @@ -1738,6 +1739,7 @@ internal async Task ProcessNXMLink(NXM nxmLink) string collectionFilename = $"{collection.Collection.Name} - {collection.Collection.Id}.7z"; string collectionFolderName = $"{collection.Collection.Name} - {collection.Collection.Id}"; string collectionFolderPath = Path.Combine(Pathing.GetNexusPath(), collectionFolderName); + string collectionJsonPath = Path.Combine(collectionFolderPath, "collection.json"); if (!Directory.Exists(collectionFolderPath)) Directory.CreateDirectory(collectionFolderPath); @@ -1754,6 +1756,7 @@ internal async Task ProcessNXMLink(NXM nxmLink) Program.helper.Log($"Downloaded collection to {mainDownloadResult.DownloadedModFilePath}"); + // unarchive the 7z, the collection.json will be in Nexus/Name - ID/collection.json using (var archive = SevenZipArchive.Open(mainDownloadResult.DownloadedModFilePath)) { foreach (var entry in archive.Entries) @@ -1771,6 +1774,27 @@ internal async Task ProcessNXMLink(NXM nxmLink) } } } + + string collectionJsonString = await File.ReadAllTextAsync(collectionJsonPath); + + CollectionIndex? index = JsonSerializer.Deserialize(collectionJsonString); + + if (index is null) { + await CreateWarningWindow(Program.translation.Get("ui.message.failed_read_collection_index"), Program.translation.Get("internal.ok")); + return false; + } + + foreach (CollectionMods mod in index.Mods) + { + string? installPath = await InstallModViaCollectionEntry(mod); + if (installPath is null) + { + Program.helper.Log($"Failed to install {mod.Name}, skipping."); + continue; + } + } + + } return true; @@ -2255,6 +2279,52 @@ private void UpdateProfile(Profile profile) return downloadResult.DownloadedModFilePath; } + private async Task InstallModViaCollectionEntry(CollectionMods mod) + { + + if (mod.Source.Type == CollectionModSourceType.Browse + || mod.Source.Type == CollectionModSourceType.Direct + ) + { + Program.helper.Log($"mod {mod.Name} comes from an unsupported source: {mod.Source.Type}"); + } + + var modId = mod.Source.FileId; + if (modId is null || Nexus.Client is null) + { + await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.unable_nexus_install"), mod.Name), Program.translation.Get("internal.ok")); + return null; + } + + var modFile = await Nexus.Client.GetFileByVersion(modId.Value, mod.Version); + if (modFile is null) + { + await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_nexus_install"), mod.Name), Program.translation.Get("internal.ok")); + return null; + } + + var modDownloadLink = await Nexus.Client.GetFileDownloadLink(modId.Value, modFile.Id, serverName: EnumParser.GetDescription(Program.settings.PreferredNexusServer)); + if (modDownloadLink is null) + { + await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_nexus_install"), mod.Name), Program.translation.Get("internal.ok")); + return null; + } + + var downloadResult = await Nexus.Client.DownloadFileAndGetPath(modDownloadLink, modFile.Name); + if (downloadResult.ResultKind is DownloadResultKind.UserCanceled) + { + // No warning, as the user triggered this intentionally + return null; + } + if (downloadResult.ResultKind is DownloadResultKind.Failed) + { + await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_nexus_install"), mod.Name), Program.translation.Get("internal.ok")); + return null; + } + + return downloadResult.DownloadedModFilePath; + } + public bool TryDeleteMod(Mod mod, int retries = 3) { try diff --git a/Stardrop/i18n/default.json b/Stardrop/i18n/default.json index 8faa4660..848d5824 100644 --- a/Stardrop/i18n/default.json +++ b/Stardrop/i18n/default.json @@ -232,6 +232,7 @@ "ui.message.confirm_nxm_collection_install": "Would you like to install the following collection:\n\n{0}\n\nYou can disable this confirmation in the settings menu.", "ui.message.failed_collection_get_archive": "Failed to get collection archive download for:\n\n{0}", "ui.message.failed_collection_download_archive": "Failed to download collection archive for:\n\n{0}", + "ui.message.failed_read_collection_index": "Failed to read collection index.", // Window Names "ui.window.settings.name": "Settings", From ae838aa912ebc53d26b0f3410c101fa2336fb37b Mon Sep 17 00:00:00 2001 From: AnotherPillow <85362273+AnotherPillow@users.noreply.github.com> Date: Thu, 11 Sep 2025 14:29:22 +0100 Subject: [PATCH 3/4] Collections install now - no bundled mods or overrides --- Stardrop/Models/Nexus/CollectionIndex.cs | 29 ++++++++++++++++------ Stardrop/Utilities/External/NexusClient.cs | 6 ++--- Stardrop/Views/MainWindow.axaml.cs | 25 ++++++++++++++----- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/Stardrop/Models/Nexus/CollectionIndex.cs b/Stardrop/Models/Nexus/CollectionIndex.cs index 2492ef6f..5499fe92 100644 --- a/Stardrop/Models/Nexus/CollectionIndex.cs +++ b/Stardrop/Models/Nexus/CollectionIndex.cs @@ -29,6 +29,7 @@ public class CollectionConfig } + [JsonConverter(typeof(JsonStringEnumConverter))] public enum CollectionModRuleType { Before, @@ -93,29 +94,41 @@ public enum CollectionModSourceType { Nexus, Direct, - Browse // manual download via web browser, picky suggested ignoring this one. + Browse, // manual download via web browser, picky suggested ignoring this one. + Bundle } public class CollectionModSource { + [JsonPropertyName("updatePolicy")] + public string UpdatePolicy { get; set; } [JsonPropertyName("type")] public CollectionModSourceType Type { get; set; } - [JsonPropertyName("md5")] - public string MD5Checksum { get; set; } [JsonPropertyName("fileSize")] public int Size { get; set; } + + // "bundle" type only + [JsonPropertyName("adultContent")] + public bool? AdultContent { get; set; } + [JsonPropertyName("fileExpression")] + public string? FileExpression { get; set; } + + // "bundle" or "nexus" type only + [JsonPropertyName("tag")] + public string? Tag { get; set; } + + // everything except bundle only + [JsonPropertyName("md5")] + public string? MD5Checksum { get; set; } [JsonPropertyName("logicalFilename")] - public string LogicalFilename { get; set; } - [JsonPropertyName("updatePolicy")] - public string UpdatePolicy { get; set; } + public string? LogicalFilename { get; set; } + // "nexus" type only [JsonPropertyName("modId")] public int? ModId { get; set; } [JsonPropertyName("fileId")] public int? FileId { get; set; } - [JsonPropertyName("tag")] - public string? Tag { get; set; } // "browse" and "direct" type only [JsonPropertyName("url")] diff --git a/Stardrop/Utilities/External/NexusClient.cs b/Stardrop/Utilities/External/NexusClient.cs index 96175af1..e7ea4f9d 100644 --- a/Stardrop/Utilities/External/NexusClient.cs +++ b/Stardrop/Utilities/External/NexusClient.cs @@ -249,7 +249,7 @@ public async Task ValidateKey() return await Nexus.GraphQLClient!.GetCollection(match.Groups["collection"].ToString());; } - public async Task GetFileByVersion(int modId, string version, string? modFlag = null) + public async Task GetFileByVersion(int modId, string version, string? modFlag = null, bool? ignoreCategory = false) { if (SemVersion.TryParse(version.Replace("v", String.Empty), SemVersionStyles.Any, out var targetVersion) is false) { @@ -257,7 +257,7 @@ public async Task ValidateKey() return null; } - Program.helper.Log($"Requesting version {version} of mod {modId}{(String.IsNullOrEmpty(modFlag) is false ? $" with flag {modFlag}" : String.Empty)}"); + Program.helper.Log($"Requesting version {version} of mod {modId}{(String.IsNullOrEmpty(modFlag) is false ? $" with flag {modFlag}" : String.Empty)} {(ignoreCategory == true ? $" ignoring categories" : " respecting categories")}"); try { @@ -281,7 +281,7 @@ public async Task ValidateKey() { selectedFile = file; } - else if (String.IsNullOrEmpty(modFlag) is true && String.IsNullOrEmpty(file.Category) is false && file.Category.Equals("MAIN", StringComparison.OrdinalIgnoreCase)) + else if (String.IsNullOrEmpty(modFlag) is true && String.IsNullOrEmpty(file.Category) is false && (ignoreCategory == true ? true : file.Category.Equals("MAIN", StringComparison.OrdinalIgnoreCase))) { selectedFile = file; } diff --git a/Stardrop/Views/MainWindow.axaml.cs b/Stardrop/Views/MainWindow.axaml.cs index c3119ce4..35e48c03 100644 --- a/Stardrop/Views/MainWindow.axaml.cs +++ b/Stardrop/Views/MainWindow.axaml.cs @@ -1784,16 +1784,21 @@ internal async Task ProcessNXMLink(NXM nxmLink) return false; } + List updateFilePaths = new List(); foreach (CollectionMods mod in index.Mods) { - string? installPath = await InstallModViaCollectionEntry(mod); - if (installPath is null) + string? downloadFilePath = await InstallModViaCollectionEntry(mod); + if (String.IsNullOrEmpty(downloadFilePath)) { Program.helper.Log($"Failed to install {mod.Name}, skipping."); continue; } + Program.helper.Log($"Downloaded {mod.Name} to {downloadFilePath}"); + updateFilePaths.Add(downloadFilePath); } + var addedMods = await AddMods(updateFilePaths.ToArray()); + } @@ -2289,21 +2294,29 @@ private void UpdateProfile(Profile profile) Program.helper.Log($"mod {mod.Name} comes from an unsupported source: {mod.Source.Type}"); } - var modId = mod.Source.FileId; - if (modId is null || Nexus.Client is null) + var modId = mod.Source.ModId; + var fileId = mod.Source.FileId; + + if (modId == 2400) + { + Program.helper.Log("Collection won't install SMAPI."); + return null; + } + + if (modId is null || fileId is null || Nexus.Client is null) { await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.unable_nexus_install"), mod.Name), Program.translation.Get("internal.ok")); return null; } - var modFile = await Nexus.Client.GetFileByVersion(modId.Value, mod.Version); + var modFile = await Nexus.Client.GetFileByVersion(modId.Value, mod.Version, ignoreCategory: true); if (modFile is null) { await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_nexus_install"), mod.Name), Program.translation.Get("internal.ok")); return null; } - var modDownloadLink = await Nexus.Client.GetFileDownloadLink(modId.Value, modFile.Id, serverName: EnumParser.GetDescription(Program.settings.PreferredNexusServer)); + var modDownloadLink = await Nexus.Client.GetFileDownloadLink(modId.Value, fileId.Value, serverName: EnumParser.GetDescription(Program.settings.PreferredNexusServer)); if (modDownloadLink is null) { await CreateWarningWindow(String.Format(Program.translation.Get("ui.warning.failed_nexus_install"), mod.Name), Program.translation.Get("internal.ok")); From 29ae50171f3df6fd25675c9a8aafdad118694e42 Mon Sep 17 00:00:00 2001 From: AnotherPillow <85362273+AnotherPillow@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:46:07 +0100 Subject: [PATCH 4/4] fix unsupported source trying to download --- Stardrop/Views/MainWindow.axaml.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Stardrop/Views/MainWindow.axaml.cs b/Stardrop/Views/MainWindow.axaml.cs index 35e48c03..393001ec 100644 --- a/Stardrop/Views/MainWindow.axaml.cs +++ b/Stardrop/Views/MainWindow.axaml.cs @@ -2291,7 +2291,8 @@ private void UpdateProfile(Profile profile) || mod.Source.Type == CollectionModSourceType.Direct ) { - Program.helper.Log($"mod {mod.Name} comes from an unsupported source: {mod.Source.Type}"); + Program.helper.Log($"mod {mod.Name} comes from an unsupported source type: {mod.Source.Type}"); + return null; } var modId = mod.Source.ModId;