33using System . Threading ;
44using System . Threading . Tasks ;
55using Hangfire ;
6+ using Hangfire . Console ;
7+ using Hangfire . Server ;
8+ using Humanizer ;
69using Microsoft . Extensions . Hosting ;
710using Microsoft . Extensions . Options ;
811using StardewModdingAPI . Toolkit ;
912using StardewModdingAPI . Toolkit . Framework . Clients . CurseForgeExport ;
10- using StardewModdingAPI . Toolkit . Framework . Clients . CurseForgeExport . ResponseModels ;
1113using StardewModdingAPI . Toolkit . Framework . Clients . NexusExport ;
12- using StardewModdingAPI . Toolkit . Framework . Clients . NexusExport . ResponseModels ;
1314using StardewModdingAPI . Toolkit . Framework . Clients . Wiki ;
15+ using StardewModdingAPI . Web . Framework . Caching ;
1416using StardewModdingAPI . Web . Framework . Caching . CurseForgeExport ;
1517using StardewModdingAPI . Web . Framework . Caching . Mods ;
1618using StardewModdingAPI . Web . Framework . Caching . NexusExport ;
@@ -108,19 +110,19 @@ public Task StartAsync(CancellationToken cancellationToken)
108110 bool enableNexusExport = BackgroundService . NexusExportApiClient is not DisabledNexusExportApiClient ;
109111
110112 // set startup tasks
111- BackgroundJob . Enqueue ( ( ) => BackgroundService . UpdateWikiAsync ( ) ) ;
113+ BackgroundJob . Enqueue ( ( ) => BackgroundService . UpdateWikiAsync ( null ) ) ;
112114 if ( enableCurseForgeExport )
113- BackgroundJob . Enqueue ( ( ) => BackgroundService . UpdateCurseForgeExportAsync ( ) ) ;
115+ BackgroundJob . Enqueue ( ( ) => BackgroundService . UpdateCurseForgeExportAsync ( null ) ) ;
114116 if ( enableNexusExport )
115- BackgroundJob . Enqueue ( ( ) => BackgroundService . UpdateNexusExportAsync ( ) ) ;
117+ BackgroundJob . Enqueue ( ( ) => BackgroundService . UpdateNexusExportAsync ( null ) ) ;
116118 BackgroundJob . Enqueue ( ( ) => BackgroundService . RemoveStaleModsAsync ( ) ) ;
117119
118120 // set recurring tasks
119- RecurringJob . AddOrUpdate ( "update wiki data" , ( ) => BackgroundService . UpdateWikiAsync ( ) , "*/10 * * * *" ) ; // every 10 minutes
121+ RecurringJob . AddOrUpdate ( "update wiki data" , ( ) => BackgroundService . UpdateWikiAsync ( null ) , "*/10 * * * *" ) ; // every 10 minutes
120122 if ( enableCurseForgeExport )
121- RecurringJob . AddOrUpdate ( "update CurseForge export" , ( ) => BackgroundService . UpdateCurseForgeExportAsync ( ) , "*/10 * * * *" ) ;
123+ RecurringJob . AddOrUpdate ( "update CurseForge export" , ( ) => BackgroundService . UpdateCurseForgeExportAsync ( null ) , "*/10 * * * *" ) ;
122124 if ( enableNexusExport )
123- RecurringJob . AddOrUpdate ( "update Nexus export" , ( ) => BackgroundService . UpdateNexusExportAsync ( ) , "*/10 * * * *" ) ;
125+ RecurringJob . AddOrUpdate ( "update Nexus export" , ( ) => BackgroundService . UpdateNexusExportAsync ( null ) , "*/10 * * * *" ) ;
124126 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)
125127
126128 BackgroundService . IsStarted = true ;
@@ -150,54 +152,48 @@ public void Dispose()
150152 ** Tasks
151153 ****/
152154 /// <summary>Update the cached wiki metadata.</summary>
153- [ AutomaticRetry ( Attempts = 3 , DelaysInSeconds = new [ ] { 30 , 60 , 120 } ) ]
154- public static async Task UpdateWikiAsync ( )
155+ /// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
156+ [ AutomaticRetry ( Attempts = 3 , DelaysInSeconds = [ 30 , 60 , 120 ] ) ]
157+ public static async Task UpdateWikiAsync ( PerformContext ? context )
155158 {
156159 if ( ! BackgroundService . IsStarted )
157160 throw new InvalidOperationException ( $ "Must call { nameof ( BackgroundService . StartAsync ) } before scheduling tasks.") ;
158161
162+ context . WriteLine ( "Fetching data from wiki..." ) ;
159163 WikiModList wikiCompatList = await new ModToolkit ( ) . GetWikiCompatibilityListAsync ( ) ;
164+
165+ context . WriteLine ( "Saving data..." ) ;
160166 BackgroundService . WikiCache . SaveWikiData ( wikiCompatList . StableVersion , wikiCompatList . BetaVersion , wikiCompatList . Mods ) ;
167+
168+ context . WriteLine ( "Done!" ) ;
161169 }
162170
163171 /// <summary>Update the cached CurseForge mod dump.</summary>
164- [ AutomaticRetry ( Attempts = 3 , DelaysInSeconds = new [ ] { 30 , 60 , 120 } ) ]
165- public static async Task UpdateCurseForgeExportAsync ( )
172+ /// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
173+ [ AutomaticRetry ( Attempts = 3 , DelaysInSeconds = [ 30 , 60 , 120 ] ) ]
174+ public static async Task UpdateCurseForgeExportAsync ( PerformContext ? context )
166175 {
167- if ( ! BackgroundService . IsStarted )
168- throw new InvalidOperationException ( $ "Must call { nameof ( BackgroundService . StartAsync ) } before scheduling tasks.") ;
169-
170- var cache = BackgroundService . CurseForgeExportCache ;
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-
179- if ( cache . IsStale ( BackgroundService . ExportStaleAge ) )
180- cache . SetData ( null ) ; // if the export is too old, fetch fresh mod data from the API instead
176+ await UpdateExportAsync (
177+ context ,
178+ BackgroundService . CurseForgeExportCache ! ,
179+ BackgroundService . CurseForgeExportApiClient ! ,
180+ client => client . FetchLastModifiedDateAsync ( ) ,
181+ async ( cache , client ) => cache . SetData ( await client . FetchExportAsync ( ) )
182+ ) ;
181183 }
182184
183185 /// <summary>Update the cached Nexus mod dump.</summary>
184- [ AutomaticRetry ( Attempts = 3 , DelaysInSeconds = new [ ] { 30 , 60 , 120 } ) ]
185- public static async Task UpdateNexusExportAsync ( )
186+ /// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
187+ [ AutomaticRetry ( Attempts = 3 , DelaysInSeconds = [ 30 , 60 , 120 ] ) ]
188+ public static async Task UpdateNexusExportAsync ( PerformContext ? context )
186189 {
187- if ( ! BackgroundService . IsStarted )
188- throw new InvalidOperationException ( $ "Must call { nameof ( BackgroundService . StartAsync ) } before scheduling tasks.") ;
189-
190- var cache = BackgroundService . NexusExportCache ;
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-
199- if ( cache . IsStale ( BackgroundService . ExportStaleAge ) )
200- cache . SetData ( null ) ; // if the export is too old, fetch fresh mod data from the site/API instead
190+ await UpdateExportAsync (
191+ context ,
192+ BackgroundService . NexusExportCache ! ,
193+ BackgroundService . NexusExportApiClient ! ,
194+ client => client . FetchLastModifiedDateAsync ( ) ,
195+ async ( cache , client ) => cache . SetData ( await client . FetchExportAsync ( ) )
196+ ) ;
201197 }
202198
203199 /// <summary>Remove mods which haven't been requested in over 48 hours.</summary>
@@ -209,10 +205,6 @@ public static Task RemoveStaleModsAsync()
209205 // remove mods in mod cache
210206 BackgroundService . ModCache . RemoveStaleMods ( TimeSpan . FromHours ( 48 ) ) ;
211207
212- // remove stale export cache
213- if ( BackgroundService . NexusExportCache . IsStale ( BackgroundService . ExportStaleAge ) )
214- BackgroundService . NexusExportCache . SetData ( null ) ;
215-
216208 return Task . CompletedTask ;
217209 }
218210
@@ -229,5 +221,74 @@ private void TryInit()
229221
230222 BackgroundService . JobServer = new BackgroundJobServer ( ) ;
231223 }
224+
225+ /// <summary>Update the cached mods export for a site.</summary>
226+ /// <typeparam name="TCacheRepository">The export cache repository type.</typeparam>
227+ /// <typeparam name="TExportApiClient">The export API client.</typeparam>
228+ /// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
229+ /// <param name="cache">The export cache to update.</param>
230+ /// <param name="client">The export API with which to fetch data from the remote API.</param>
231+ /// <param name="fetchLastModifiedDateAsync">Fetch the date when the export on the server was last modified.</param>
232+ /// <param name="fetchDataAsync">Fetch the latest export file from the Nexus Mods export API.</param>
233+ /// <exception cref="InvalidOperationException">The <see cref="StartAsync"/> method wasn't called before running this task.</exception>
234+ private static async Task UpdateExportAsync < TCacheRepository , TExportApiClient > ( PerformContext ? context , TCacheRepository cache , TExportApiClient client , Func < TExportApiClient , Task < DateTimeOffset > > fetchLastModifiedDateAsync , Func < TCacheRepository , TExportApiClient , Task > fetchDataAsync )
235+ where TCacheRepository : IExportCacheRepository
236+ {
237+ if ( ! BackgroundService . IsStarted )
238+ throw new InvalidOperationException ( $ "Must call { nameof ( BackgroundService . StartAsync ) } before scheduling tasks.") ;
239+
240+ // refresh data
241+ context . WriteLine ( "Checking if we can refresh the data..." ) ;
242+ if ( BackgroundService . CanRefreshFromExportApi ( await fetchLastModifiedDateAsync ( client ) , cache , out string ? failReason ) )
243+ {
244+ context . WriteLine ( "Fetching data..." ) ;
245+ await fetchDataAsync ( cache , client ) ;
246+ context . WriteLine ( $ "Cache updated. The data was last modified { BackgroundService . FormatDateModified ( cache . GetLastModified ( ) ) } .") ;
247+ }
248+ else
249+ context . WriteLine ( $ "Skipped data fetch: { failReason } .") ;
250+
251+ // clear if stale
252+ if ( cache . IsStale ( BackgroundService . ExportStaleAge ) )
253+ {
254+ context . WriteLine ( "The cached data is stale, clearing cache..." ) ;
255+ cache . Clear ( ) ;
256+ }
257+
258+ context . WriteLine ( "Done!" ) ;
259+ }
260+
261+ /// <summary>Get whether newer non-stale data can be fetched from the server.</summary>
262+ /// <param name="serverModified">The last-modified data from the remote API.</param>
263+ /// <param name="repository">The repository to update.</param>
264+ /// <param name="failReason">The reason to log if we can't fetch data.</param>
265+ private static bool CanRefreshFromExportApi ( DateTimeOffset serverModified , IExportCacheRepository repository , [ NotNullWhen ( false ) ] out string ? failReason )
266+ {
267+ if ( repository . IsStale ( serverModified , BackgroundService . ExportStaleAge ) )
268+ {
269+ failReason = $ "server was last modified { BackgroundService . FormatDateModified ( serverModified ) } , which exceeds the { BackgroundService . ExportStaleAge } -minute-stale limit";
270+ return false ;
271+ }
272+
273+ if ( repository . IsLoaded ( ) )
274+ {
275+ DateTimeOffset localModified = repository . GetLastModified ( ) ;
276+ if ( localModified >= serverModified )
277+ {
278+ failReason = $ "server was last modified { BackgroundService . FormatDateModified ( serverModified ) } , which { ( serverModified == localModified ? "matches our cached data" : $ "is older than our cached { BackgroundService . FormatDateModified ( localModified ) } ") } ";
279+ return false ;
280+ }
281+ }
282+
283+ failReason = null ;
284+ return true ;
285+ }
286+
287+ /// <summary>Format a 'date modified' value for the task logs.</summary>
288+ /// <param name="date">The date to log.</param>
289+ private static string FormatDateModified ( DateTimeOffset date )
290+ {
291+ return $ "{ date : O} (age: { ( DateTimeOffset . UtcNow - date ) . Humanize ( ) } )";
292+ }
232293 }
233294}
0 commit comments