Skip to content

Commit b2c2a1c

Browse files
committed
only fetch CurseForge/Nexus exports when they change
1 parent 73f2c07 commit b2c2a1c

11 files changed

Lines changed: 124 additions & 23 deletions

src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/CurseForgeExportApiClient.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Net.Http;
23
using System.Threading.Tasks;
34
using Pathoschild.Http.Client;
45
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels;
@@ -26,13 +27,21 @@ public CurseForgeExportApiClient(string userAgent, string baseUrl)
2627
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
2728
}
2829

30+
/// <inheritdoc />
31+
public async Task<DateTimeOffset> FetchLastModifiedDateAsync()
32+
{
33+
IResponse response = await this.Client.SendAsync(HttpMethod.Head, "");
34+
35+
return this.ReadLastModified(response);
36+
}
37+
2938
/// <inheritdoc />
3039
public async Task<CurseForgeFullExport> FetchExportAsync()
3140
{
3241
IResponse response = await this.Client.GetAsync("");
3342

3443
CurseForgeFullExport export = await response.As<CurseForgeFullExport>();
35-
export.LastModified = response.Message.Content.Headers.LastModified ?? throw new InvalidOperationException("Can't fetch from CurseForge export API: expected Last-Modified header wasn't set.");
44+
export.LastModified = this.ReadLastModified(response);
3645

3746
return export;
3847
}
@@ -42,5 +51,17 @@ public void Dispose()
4251
{
4352
this.Client.Dispose();
4453
}
54+
55+
56+
/*********
57+
** Private methods
58+
*********/
59+
/// <summary>Read the <c>Last-Modified</c> header from an API response.</summary>
60+
/// <param name="response">The response from the CurseForge API.</param>
61+
/// <exception cref="InvalidOperationException">The response doesn't include the required <c>Last-Modified</c> header.</exception>
62+
private DateTimeOffset ReadLastModified(IResponse response)
63+
{
64+
return response.Message.Content.Headers.LastModified ?? throw new InvalidOperationException("Can't fetch from CurseForge export API: expected Last-Modified header wasn't set.");
65+
}
4566
}
4667
}

src/SMAPI.Toolkit/Framework/Clients/CurseForgeExport/ICurseForgeExportApiClient.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport
77
/// <summary>An HTTP client for fetching the mod export from the CurseForge export API.</summary>
88
public interface ICurseForgeExportApiClient : IDisposable
99
{
10+
/// <summary>Fetch the date when the export on the server was last modified.</summary>
11+
Task<DateTimeOffset> FetchLastModifiedDateAsync();
12+
1013
/// <summary>Fetch the latest export file from the CurseForge export API.</summary>
11-
public Task<CurseForgeFullExport> FetchExportAsync();
14+
Task<CurseForgeFullExport> FetchExportAsync();
1215
}
1316
}

src/SMAPI.Toolkit/Framework/Clients/NexusExport/INexusExportApiClient.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.NexusExport
77
/// <summary>An HTTP client for fetching the mod export from the Nexus Mods export API.</summary>
88
public interface INexusExportApiClient : IDisposable
99
{
10+
/// <summary>Fetch the date when the export on the server was last modified.</summary>
11+
Task<DateTimeOffset> FetchLastModifiedDateAsync();
12+
1013
/// <summary>Fetch the latest export file from the Nexus Mods export API.</summary>
11-
public Task<NexusFullExport> FetchExportAsync();
14+
Task<NexusFullExport> FetchExportAsync();
1215
}
1316
}

src/SMAPI.Toolkit/Framework/Clients/NexusExport/NexusExportApiClient.cs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System;
2+
using System.Net.Http;
13
using System.Threading.Tasks;
24
using Pathoschild.Http.Client;
35
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels;
@@ -25,18 +27,41 @@ public NexusExportApiClient(string userAgent, string baseUrl)
2527
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
2628
}
2729

30+
/// <inheritdoc />
31+
public async Task<DateTimeOffset> FetchLastModifiedDateAsync()
32+
{
33+
IResponse response = await this.Client.SendAsync(HttpMethod.Head, "");
34+
35+
return this.ReadLastModified(response);
36+
}
37+
2838
/// <inheritdoc />
2939
public async Task<NexusFullExport> FetchExportAsync()
3040
{
31-
return await this.Client
32-
.GetAsync("")
33-
.As<NexusFullExport>();
41+
IResponse response = await this.Client.GetAsync("");
42+
43+
NexusFullExport export = await response.As<NexusFullExport>();
44+
export.LastUpdated = this.ReadLastModified(response);
45+
46+
return export;
3447
}
3548

3649
/// <inheritdoc />
3750
public void Dispose()
3851
{
3952
this.Client.Dispose();
4053
}
54+
55+
56+
/*********
57+
** Private methods
58+
*********/
59+
/// <summary>Read the <c>Last-Modified</c> header from an API response.</summary>
60+
/// <param name="response">The response from the Nexus API.</param>
61+
/// <exception cref="InvalidOperationException">The response doesn't include the required <c>Last-Modified</c> header.</exception>
62+
private DateTimeOffset ReadLastModified(IResponse response)
63+
{
64+
return response.Message.Content.Headers.LastModified ?? throw new InvalidOperationException("Can't fetch from Nexus export API: expected Last-Modified header wasn't set.");
65+
}
4166
}
4267
}

src/SMAPI.Web/BackgroundService.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,17 @@ public static async Task UpdateCurseForgeExportAsync()
167167
if (!BackgroundService.IsStarted)
168168
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
169169

170-
CurseForgeFullExport data = await BackgroundService.CurseForgeExportApiClient.FetchExportAsync();
171-
172170
var cache = BackgroundService.CurseForgeExportCache;
173-
cache.SetData(data);
171+
var client = BackgroundService.CurseForgeExportApiClient;
172+
173+
if (await cache.CanRefreshFromAsync(client, BackgroundService.ExportStaleAge))
174+
{
175+
CurseForgeFullExport data = await client.FetchExportAsync();
176+
cache.SetData(data);
177+
}
178+
174179
if (cache.IsStale(BackgroundService.ExportStaleAge))
175-
cache.SetData(null); // if the export is too old, fetch fresh mod data from the site/API instead
180+
cache.SetData(null); // if the export is too old, fetch fresh mod data from the API instead
176181
}
177182

178183
/// <summary>Update the cached Nexus mod dump.</summary>
@@ -182,10 +187,15 @@ public static async Task UpdateNexusExportAsync()
182187
if (!BackgroundService.IsStarted)
183188
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
184189

185-
NexusFullExport data = await BackgroundService.NexusExportApiClient.FetchExportAsync();
186-
187190
var cache = BackgroundService.NexusExportCache;
188-
cache.SetData(data);
191+
var client = BackgroundService.NexusExportApiClient;
192+
193+
if (await cache.CanRefreshFromAsync(client, BackgroundService.ExportStaleAge))
194+
{
195+
NexusFullExport data = await client.FetchExportAsync();
196+
cache.SetData(data);
197+
}
198+
189199
if (cache.IsStale(BackgroundService.ExportStaleAge))
190200
cache.SetData(null); // if the export is too old, fetch fresh mod data from the site/API instead
191201
}

src/SMAPI.Web/Framework/Caching/CurseForgeExport/CurseForgeExportCacheMemoryRepository.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
22
using System.Diagnostics.CodeAnalysis;
3+
using System.Threading.Tasks;
4+
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport;
35
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels;
46

57
namespace StardewModdingAPI.Web.Framework.Caching.CurseForgeExport
@@ -18,15 +20,23 @@ internal class CurseForgeExportCacheMemoryRepository : BaseCacheRepository, ICur
1820
** Public methods
1921
*********/
2022
/// <inheritdoc />
23+
[MemberNotNullWhen(true, nameof(CurseForgeExportCacheMemoryRepository.Data))]
2124
public bool IsLoaded()
2225
{
2326
return this.Data?.Mods.Count > 0;
2427
}
2528

2629
/// <inheritdoc />
27-
public DateTimeOffset? GetLastRefreshed()
30+
public async Task<bool> CanRefreshFromAsync(ICurseForgeExportApiClient client, int staleMinutes)
2831
{
29-
return this.Data?.LastModified;
32+
DateTimeOffset serverLastModified = await client.FetchLastModifiedDateAsync();
33+
34+
return
35+
!this.IsStale(serverLastModified, staleMinutes)
36+
&& (
37+
!this.IsLoaded()
38+
|| this.Data.LastModified < serverLastModified
39+
);
3040
}
3141

3242
/// <inheritdoc />

src/SMAPI.Web/Framework/Caching/CurseForgeExport/ICurseForgeExportCacheRepository.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
using System;
21
using System.Diagnostics.CodeAnalysis;
2+
using System.Threading.Tasks;
3+
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport;
34
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels;
45

56
namespace StardewModdingAPI.Web.Framework.Caching.CurseForgeExport
@@ -13,8 +14,10 @@ internal interface ICurseForgeExportCacheRepository : ICacheRepository
1314
/// <summary>Get whether the export data is currently available.</summary>
1415
bool IsLoaded();
1516

16-
/// <summary>Get when the export data was last fetched, or <c>null</c> if no data is currently available.</summary>
17-
DateTimeOffset? GetLastRefreshed();
17+
/// <summary>Get whether newer non-stale data can be fetched from the server.</summary>
18+
/// <param name="client">The CurseForge API client.</param>
19+
/// <param name="staleMinutes">The age in minutes before data is considered stale.</param>
20+
Task<bool> CanRefreshFromAsync(ICurseForgeExportApiClient client, int staleMinutes);
1821

1922
/// <summary>Get the cached data for a mod, if it exists in the export.</summary>
2023
/// <param name="id">The CurseForge mod ID.</param>

src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
using System;
21
using System.Diagnostics.CodeAnalysis;
2+
using System.Threading.Tasks;
3+
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport;
34
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels;
45

56
namespace StardewModdingAPI.Web.Framework.Caching.NexusExport
@@ -13,8 +14,10 @@ internal interface INexusExportCacheRepository : ICacheRepository
1314
/// <summary>Get whether the export data is currently available.</summary>
1415
bool IsLoaded();
1516

16-
/// <summary>Get when the export data was last fetched, or <c>null</c> if no data is currently available.</summary>
17-
DateTimeOffset? GetLastRefreshed();
17+
/// <summary>Get whether newer non-stale data can be fetched from the server.</summary>
18+
/// <param name="client">The Nexus API client.</param>
19+
/// <param name="staleMinutes">The age in minutes before data is considered stale.</param>
20+
Task<bool> CanRefreshFromAsync(INexusExportApiClient client, int staleMinutes);
1821

1922
/// <summary>Get the cached data for a mod, if it exists in the export.</summary>
2023
/// <param name="id">The Nexus mod ID.</param>

src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
22
using System.Diagnostics.CodeAnalysis;
3+
using System.Threading.Tasks;
4+
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport;
35
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels;
46

57
namespace StardewModdingAPI.Web.Framework.Caching.NexusExport
@@ -18,15 +20,23 @@ internal class NexusExportCacheMemoryRepository : BaseCacheRepository, INexusExp
1820
** Public methods
1921
*********/
2022
/// <inheritdoc />
23+
[MemberNotNullWhen(true, nameof(NexusExportCacheMemoryRepository.Data))]
2124
public bool IsLoaded()
2225
{
2326
return this.Data?.Data.Count > 0;
2427
}
2528

2629
/// <inheritdoc />
27-
public DateTimeOffset? GetLastRefreshed()
30+
public async Task<bool> CanRefreshFromAsync(INexusExportApiClient client, int staleMinutes)
2831
{
29-
return this.Data?.LastUpdated;
32+
DateTimeOffset serverLastModified = await client.FetchLastModifiedDateAsync();
33+
34+
return
35+
!this.IsStale(serverLastModified, staleMinutes)
36+
&& (
37+
!this.IsLoaded()
38+
|| this.Data.LastUpdated < serverLastModified
39+
);
3040
}
3141

3242
/// <inheritdoc />

src/SMAPI.Web/Framework/Clients/CurseForge/DisabledCurseForgeExportApiClient.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Threading.Tasks;
23
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport;
34
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels;
@@ -10,6 +11,12 @@ internal class DisabledCurseForgeExportApiClient : ICurseForgeExportApiClient
1011
/*********
1112
** Public methods
1213
*********/
14+
/// <inheritdoc />
15+
public Task<DateTimeOffset> FetchLastModifiedDateAsync()
16+
{
17+
return Task.FromResult(DateTimeOffset.MinValue);
18+
}
19+
1320
/// <inheritdoc />
1421
public Task<CurseForgeFullExport> FetchExportAsync()
1522
{

0 commit comments

Comments
 (0)