Skip to content

Commit 7e7cb1a

Browse files
committed
migrate to new Nexus export API for update checks
1 parent 0804e11 commit 7e7cb1a

File tree

16 files changed

+465
-28
lines changed

16 files changed

+465
-28
lines changed

docs/release-notes.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
# Release notes
44
## Upcoming release
55
* For players:
6-
* Added option to disable Harmony/MonoMod fix for players with certain crashes.
7-
_This should only be changed if no installed mods use Harmony._
8-
* Fixed split-screen crash for non-English players when mods added translated variants of content assets.
6+
* Added option to disable Harmony fix for players with certain crashes.
7+
* Fixed crash for non-English players in split-screen mode when mods translate some vanilla assets.
98
* SMAPI no longer rewrites mods which use Harmony 1.x, to help reduce Harmony crashes.
109
_This should affect very few mods that still work otherwise, and any Harmony mod updated after July 2021 should be unaffected._
1110

11+
* For the update check server:
12+
* Rewrote update checks for mods on Nexus Mods to use a new Nexus API endpoint.
13+
_This should result in much faster update checks for Nexus, and less chance of update-check errors when the Nexus servers are under heavy load._
14+
1215
## 4.0.7
1316
Released 18 April 2024 for Stardew Valley 1.6.4 or later.
1417

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels;
4+
5+
namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport
6+
{
7+
/// <summary>An HTTP client for fetching the mod export from the Nexus Mods export API.</summary>
8+
public interface INexusExportApiClient : IDisposable
9+
{
10+
/// <summary>Fetch the latest export file from the Nexus Mods export API.</summary>
11+
public Task<NexusFullExport> FetchExportAsync();
12+
}
13+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Threading.Tasks;
2+
using Pathoschild.Http.Client;
3+
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels;
4+
5+
namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport
6+
{
7+
/// <inheritdoc cref="INexusExportApiClient" />
8+
public class NexusExportApiClient : INexusExportApiClient
9+
{
10+
/*********
11+
** Fields
12+
*********/
13+
/// <summary>The underlying HTTP client.</summary>
14+
private readonly IClient Client;
15+
16+
17+
/*********
18+
** Public methods
19+
*********/
20+
/// <summary>Construct an instance.</summary>
21+
/// <param name="userAgent">The user agent for the Nexus export API.</param>
22+
/// <param name="baseUrl">The base URL for the Nexus export API.</param>
23+
public NexusExportApiClient(string userAgent, string baseUrl)
24+
{
25+
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
26+
}
27+
28+
/// <inheritdoc />
29+
public async Task<NexusFullExport> FetchExportAsync()
30+
{
31+
return await this.Client
32+
.GetAsync("")
33+
.As<NexusFullExport>();
34+
}
35+
36+
/// <inheritdoc />
37+
public void Dispose()
38+
{
39+
this.Client.Dispose();
40+
}
41+
}
42+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System.Collections.Generic;
2+
using System.Diagnostics.CodeAnalysis;
3+
using Newtonsoft.Json;
4+
5+
namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels
6+
{
7+
/// <summary>The metadata for an uploaded file for a mod from the Nexus Mods export API.</summary>
8+
public class NexusFileExport
9+
{
10+
/// <summary>The unique internal file identifier.</summary>
11+
public long Uid { get; set; }
12+
13+
/// <summary>The file's display name.</summary>
14+
public string? Name { get; set; }
15+
16+
/// <summary>The file's display description.</summary>
17+
public string? Description { get; set; }
18+
19+
/// <summary>The file name that will be downloaded.</summary>
20+
[JsonProperty("uri")]
21+
public string? FileName { get; set; }
22+
23+
/// <summary>The file's semantic version.</summary>
24+
public string? Version { get; set; }
25+
26+
/// <summary>The file category ID.</summary>
27+
[JsonProperty("category_id")]
28+
public uint CategoryId { get; set; }
29+
30+
/// <summary>Whether this is the main Vortex file.</summary>
31+
public bool Primary { get; set; }
32+
33+
/// <summary>The file's size in bytes.</summary>
34+
[JsonProperty("size_in_byes")]
35+
public long? SizeInBytes { get; set; }
36+
37+
/// <summary>When the file was uploaded.</summary>
38+
[JsonProperty("uploaded_at")]
39+
public long UploadedAt { get; set; }
40+
41+
/// <summary>The extra fields returned by the export API, if any.</summary>
42+
[JsonExtensionData]
43+
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")]
44+
public Dictionary<string, object>? OtherFields;
45+
}
46+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Newtonsoft.Json;
4+
5+
namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels
6+
{
7+
/// <summary>The metadata for all Stardew Valley from the Nexus Mods export API.</summary>
8+
public class NexusFullExport
9+
{
10+
/// <summary>The mod data indexed by public mod ID.</summary>
11+
public Dictionary<uint, NexusModExport> Data { get; set; } = new();
12+
13+
/// <summary>When this export was last updated.</summary>
14+
[JsonProperty("last_updated")]
15+
public DateTimeOffset LastUpdated { get; set; }
16+
}
17+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using System.Collections.Generic;
2+
using System.Diagnostics.CodeAnalysis;
3+
using Newtonsoft.Json;
4+
5+
namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels
6+
{
7+
/// <summary>The metadata for a mod from the Nexus Mods export API.</summary>
8+
public class NexusModExport
9+
{
10+
/// <summary>The unique internal mod identifier (not the public mod ID).</summary>
11+
public long Uid { get; set; }
12+
13+
/// <summary>The mod's display name.</summary>
14+
public string? Name { get; set; }
15+
16+
/// <summary>The author display name set for the mod.</summary>
17+
public string? Author { get; set; }
18+
19+
/// <summary>The username for the user who uploaded the mod.</summary>
20+
public string? Uploader { get; set; }
21+
22+
/// <summary>The ID for the user who uploaded the mod.</summary>
23+
[JsonProperty("uploader_id")]
24+
public int UploaderId { get; set; }
25+
26+
/// <summary>The mod's semantic version.</summary>
27+
public string? Version { get; set; }
28+
29+
/// <summary>The category ID.</summary>
30+
[JsonProperty("category_id")]
31+
public int CategoryId { get; set; }
32+
33+
/// <summary>Whether the mod is published by the author.</summary>
34+
public bool Published { get; set; }
35+
36+
/// <summary>Whether the mod is hidden by moderators.</summary>
37+
public bool Moderated { get; set; }
38+
39+
/// <summary>Whether the mod page is visible to users.</summary>
40+
[JsonProperty("allow_view")]
41+
public bool AllowView { get; set; }
42+
43+
/// <summary>Whether the mod is marked as containing adult content.</summary>
44+
public bool Adult { get; set; }
45+
46+
/// <summary>The files uploaded for the mod.</summary>
47+
public Dictionary<uint, NexusFileExport> Files { get; set; } = new();
48+
49+
/// <summary>The extra fields returned by the export API, if any.</summary>
50+
[JsonExtensionData]
51+
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used to track any new data provided by the API.")]
52+
public Dictionary<string, object>? OtherFields;
53+
}
54+
}

src/SMAPI.Web/BackgroundService.cs

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@
44
using System.Threading.Tasks;
55
using Hangfire;
66
using Microsoft.Extensions.Hosting;
7+
using Microsoft.Extensions.Options;
78
using StardewModdingAPI.Toolkit;
9+
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport;
10+
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels;
811
using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
912
using StardewModdingAPI.Web.Framework.Caching.Mods;
13+
using StardewModdingAPI.Web.Framework.Caching.NexusExport;
1014
using StardewModdingAPI.Web.Framework.Caching.Wiki;
15+
using StardewModdingAPI.Web.Framework.Clients.Nexus;
16+
using StardewModdingAPI.Web.Framework.ConfigModels;
1117

1218
namespace StardewModdingAPI.Web
1319
{
@@ -27,10 +33,22 @@ internal class BackgroundService : IHostedService, IDisposable
2733
/// <summary>The cache in which to store mod data.</summary>
2834
private static IModCacheRepository? ModCache;
2935

36+
/// <summary>The cache in which to store mod data from the Nexus export API.</summary>
37+
private static INexusExportCacheRepository? NexusExportCache;
38+
39+
/// <summary>The HTTP client for fetching the mod export from the Nexus Mods export API.</summary>
40+
private static INexusExportApiClient? NexusExportApiClient;
41+
42+
/// <summary>The config settings for mod update checks.</summary>
43+
private static IOptions<ModUpdateCheckConfig>? UpdateCheckConfig;
44+
3045
/// <summary>Whether the service has been started.</summary>
31-
[MemberNotNullWhen(true, nameof(BackgroundService.JobServer), nameof(BackgroundService.WikiCache), nameof(BackgroundService.ModCache))]
46+
[MemberNotNullWhen(true, nameof(BackgroundService.JobServer), nameof(BackgroundService.ModCache), nameof(NexusExportApiClient), nameof(NexusExportCache), nameof(BackgroundService.UpdateCheckConfig), nameof(BackgroundService.WikiCache))]
3247
private static bool IsStarted { get; set; }
3348

49+
/// <summary>The number of minutes the Nexus export should be considered valid based on its last-updated date before it's ignored.</summary>
50+
private static int NexusExportStaleAge => (BackgroundService.UpdateCheckConfig?.Value.SuccessCacheMinutes ?? 0) + 10;
51+
3452

3553
/*********
3654
** Public methods
@@ -41,12 +59,20 @@ internal class BackgroundService : IHostedService, IDisposable
4159
/// <summary>Construct an instance.</summary>
4260
/// <param name="wikiCache">The cache in which to store wiki metadata.</param>
4361
/// <param name="modCache">The cache in which to store mod data.</param>
62+
/// <param name="nexusExportCache">The cache in which to store mod data from the Nexus export API.</param>
63+
/// <param name="nexusExportApiClient">The HTTP client for fetching the mod export from the Nexus Mods export API.</param>
4464
/// <param name="hangfireStorage">The Hangfire storage implementation.</param>
65+
/// <param name="updateCheckConfig">The config settings for mod update checks.</param>
4566
[SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The Hangfire reference forces it to initialize first, since it's needed by the background service.")]
46-
public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache, JobStorage hangfireStorage)
67+
public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache, INexusExportCacheRepository nexusExportCache, INexusExportApiClient nexusExportApiClient, JobStorage hangfireStorage, IOptions<ModUpdateCheckConfig> updateCheckConfig)
4768
{
4869
BackgroundService.WikiCache = wikiCache;
4970
BackgroundService.ModCache = modCache;
71+
BackgroundService.NexusExportCache = nexusExportCache;
72+
BackgroundService.NexusExportApiClient = nexusExportApiClient;
73+
BackgroundService.UpdateCheckConfig = updateCheckConfig;
74+
75+
_ = hangfireStorage; // this parameter is only received so it's initialized before the background service
5076
}
5177

5278
/// <summary>Start the service.</summary>
@@ -55,13 +81,19 @@ public Task StartAsync(CancellationToken cancellationToken)
5581
{
5682
this.TryInit();
5783

84+
bool enableNexusExport = BackgroundService.NexusExportApiClient is not DisabledNexusExportApiClient;
85+
5886
// set startup tasks
5987
BackgroundJob.Enqueue(() => BackgroundService.UpdateWikiAsync());
88+
if (enableNexusExport)
89+
BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync());
6090
BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync());
6191

6292
// set recurring tasks
63-
RecurringJob.AddOrUpdate(() => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes
64-
RecurringJob.AddOrUpdate(() => BackgroundService.RemoveStaleModsAsync(), "0 * * * *"); // hourly
93+
RecurringJob.AddOrUpdate("update wiki data", () => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes
94+
if (enableNexusExport)
95+
RecurringJob.AddOrUpdate("update Nexus export", () => BackgroundService.UpdateNexusExportAsync(), "*/10 * * * *");
96+
RecurringJob.AddOrUpdate("remove stale mods", () => BackgroundService.RemoveStaleModsAsync(), "2/10 * * * *"); // offset by 2 minutes so it runs after updates (e.g. 00:02, 00:12, etc)
6597

6698
BackgroundService.IsStarted = true;
6799

@@ -100,13 +132,34 @@ public static async Task UpdateWikiAsync()
100132
BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods);
101133
}
102134

135+
/// <summary>Update the cached Nexus mod dump.</summary>
136+
[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })]
137+
public static async Task UpdateNexusExportAsync()
138+
{
139+
if (!BackgroundService.IsStarted)
140+
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
141+
142+
NexusFullExport data = await BackgroundService.NexusExportApiClient.FetchExportAsync();
143+
144+
var cache = BackgroundService.NexusExportCache;
145+
cache.SetData(data);
146+
if (cache.IsStale(BackgroundService.NexusExportStaleAge))
147+
cache.SetData(null); // if the export is too old, fetch fresh mod data from the site/API instead
148+
}
149+
103150
/// <summary>Remove mods which haven't been requested in over 48 hours.</summary>
104151
public static Task RemoveStaleModsAsync()
105152
{
106153
if (!BackgroundService.IsStarted)
107154
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
108155

156+
// remove mods in mod cache
109157
BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48));
158+
159+
// remove stale export cache
160+
if (BackgroundService.NexusExportCache.IsStale(BackgroundService.NexusExportStaleAge))
161+
BackgroundService.NexusExportCache.SetData(null);
162+
110163
return Task.CompletedTask;
111164
}
112165

src/SMAPI.Web/Framework/Caching/ICacheRepository.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ namespace StardewModdingAPI.Web.Framework.Caching
55
/// <summary>Encapsulates logic for accessing data in the cache.</summary>
66
internal interface ICacheRepository
77
{
8-
/// <summary>Whether cached data is stale.</summary>
8+
/// <summary>Get whether cached data is stale.</summary>
99
/// <param name="lastUpdated">The date when the data was updated.</param>
1010
/// <param name="staleMinutes">The age in minutes before data is considered stale.</param>
1111
bool IsStale(DateTimeOffset lastUpdated, int staleMinutes);
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System;
2+
using System.Diagnostics.CodeAnalysis;
3+
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels;
4+
5+
namespace StardewModdingAPI.Web.Framework.Caching.NexusExport
6+
{
7+
/// <summary>Manages cached mod data from the Nexus export API.</summary>
8+
internal interface INexusExportCacheRepository : ICacheRepository
9+
{
10+
/*********
11+
** Methods
12+
*********/
13+
/// <summary>Get whether the export data is currently available.</summary>
14+
bool IsLoaded();
15+
16+
/// <summary>Get when the export data was last fetched, or <c>null</c> if no data is currently available.</summary>
17+
DateTimeOffset? GetLastRefreshed();
18+
19+
/// <summary>Get the cached data for a mod, if it exists in the export.</summary>
20+
/// <param name="id">The Nexus mod ID.</param>
21+
/// <param name="mod">The fetched metadata.</param>
22+
bool TryGetMod(uint id, [NotNullWhen(true)] out NexusModExport? mod);
23+
24+
/// <summary>Set the cached data to use.</summary>
25+
/// <param name="export">The export received from the Nexus Mods API, or <c>null</c> to remove it.</param>
26+
void SetData(NexusFullExport? export);
27+
28+
/// <summary>Get whether the cached data is stale.</summary>
29+
/// <param name="staleMinutes">The age in minutes before data is considered stale.</param>
30+
bool IsStale(int staleMinutes);
31+
}
32+
}

0 commit comments

Comments
 (0)