Refactor launcher game importing

- Game importing happens on a separate thread to avoid UI blocking
- Server requests per-game are done in parallel to cut down on import times
- Media downloading is done in parallel
- Library import progress shown in title bar
- Enabled WAL mode for SQLite for better performance
- Fix potential issues with deferred imports causing infinite loops or lockups
This commit is contained in:
Pat Hartl 2026-04-27 22:42:44 -05:00
parent 71de97e04f
commit 232f86ba85
13 changed files with 385 additions and 93 deletions

View file

@ -160,6 +160,7 @@ public partial class App : Application
// Run database migrations
_logger?.LogInformation("Running database migrations...");
await databaseContext.Database.MigrateAsync().ConfigureAwait(false);
await databaseContext.EnableWalModeAsync().ConfigureAwait(false);
_logger?.LogInformation("Database migrations complete");
}

View file

@ -119,6 +119,7 @@ class Program
}
await databaseContext.Database.MigrateAsync().ConfigureAwait(false);
await databaseContext.EnableWalModeAsync().ConfigureAwait(false);
await commandLineService.ParseCommandLineAsync(args);
}
@ -187,6 +188,7 @@ internal class ScriptDebugApp : Application
}
await databaseContext.Database.MigrateAsync().ConfigureAwait(false);
await databaseContext.EnableWalModeAsync().ConfigureAwait(false);
await commandLineService.ParseCommandLineAsync(args).ConfigureAwait(false);
}

View file

@ -2,12 +2,14 @@ using System;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LANCommander.Launcher.Avalonia.Services;
using LANCommander.Launcher.Avalonia.ViewModels.Components;
using LANCommander.Launcher.Data.Models;
using LANCommander.Launcher.Services;
using LANCommander.Launcher.Services.Import;
using LANCommander.Launcher.Settings.Enums;
using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
@ -49,7 +51,7 @@ public partial class ShellViewModel : ViewModelBase
};
public bool IsRefreshVisible =>
(ContentView is GamesCollectionViewModel || ContentView is DepotViewModel) && !IsOfflineMode;
(ContentView is GamesCollectionViewModel || ContentView is DepotViewModel) && !IsOfflineMode && !IsSyncing;
public bool IsTitlebarTinted => true;
partial void OnContentViewChanged(ViewModelBase? oldValue, ViewModelBase? newValue)
@ -77,6 +79,29 @@ public partial class ShellViewModel : ViewModelBase
OnPropertyChanged(nameof(ContentViewTitle));
}
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSyncing))]
[NotifyPropertyChangedFor(nameof(IsImportIndeterminate))]
[NotifyPropertyChangedFor(nameof(ImportProgress))]
[NotifyPropertyChangedFor(nameof(IsRefreshVisible))]
private bool _isImportRunning;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsImportIndeterminate))]
[NotifyPropertyChangedFor(nameof(ImportProgress))]
private int _importIndex;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsImportIndeterminate))]
[NotifyPropertyChangedFor(nameof(ImportProgress))]
private int _importTotal;
public bool IsSyncing => IsImportRunning;
public bool IsImportIndeterminate => IsImportRunning && ImportTotal == 0;
public double ImportProgress => ImportTotal > 0 ? (double)ImportIndex / ImportTotal : 0;
[ObservableProperty]
private bool _isCheckingConnection;
@ -207,41 +232,117 @@ public partial class ShellViewModel : ViewModelBase
{
IsLoading = true;
if (!IsOfflineMode)
{
try
{
using var scope = _serviceProvider.CreateScope();
var importService = scope.ServiceProvider.GetRequiredService<ImportService>();
await importService.ImportLibraryAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Import failed");
}
}
// Load cached data from local DB first so the user sees games immediately
try
{
await GamesListViewModel.LoadGamesAsync();
await LibraryViewModel.LoadGamesAsync();
await DepotViewModel.LoadAsync();
await LoadViewModelsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load library data");
_logger.LogError(ex, "Failed to load cached library data");
}
finally
{
IsLoading = false;
}
// Import in the background, then refresh
if (!IsOfflineMode)
{
_ = ImportInBackgroundAsync();
}
}
private async Task ImportInBackgroundAsync()
{
IsImportRunning = true;
try
{
await Task.Run(async () =>
{
using var scope = _serviceProvider.CreateScope();
var importService = scope.ServiceProvider.GetRequiredService<ImportService>();
// Track progress on the UI thread
importService.OnImportStarted.EventRaised += OnImportStarted;
importService.OnImportStatusUpdate.EventRaised += OnImportStatusUpdated;
importService.OnImportComplete.EventRaised += OnImportCompleted;
await importService.ImportLibraryAsync();
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Background import failed");
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
ImportIndex = 0;
ImportTotal = 0;
IsImportRunning = false;
});
}
}
private async Task OnImportStarted(ImportStatusUpdate update)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
ImportIndex = 0;
ImportTotal = update.Total;
});
}
private async Task OnImportStatusUpdated(ImportStatusUpdate update)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
ImportIndex = update.Index;
});
// Refresh the library after each game's items finish importing
if (update.CurrentItem?.Type == nameof(SDK.Models.Manifest.Game))
{
await Dispatcher.UIThread.InvokeAsync(async () =>
{
try
{
await LibraryViewModel.LoadGamesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh library during import");
}
});
}
}
private async Task OnImportCompleted(ImportStatusUpdate update)
{
await Dispatcher.UIThread.InvokeAsync(async () =>
{
ImportIndex = update.Total;
await LoadViewModelsAsync();
ImportIndex = 0;
ImportTotal = 0;
});
}
private async Task LoadViewModelsAsync()
{
await GamesListViewModel.LoadGamesAsync();
await LibraryViewModel.LoadGamesAsync();
await DepotViewModel.LoadAsync();
}
[RelayCommand]
private async Task RefreshAsync()
{
if (IsOfflineMode) return;
await ImportAndLoadAsync();
if (IsOfflineMode || IsSyncing) return;
_ = ImportInBackgroundAsync();
}
[RelayCommand]

View file

@ -93,16 +93,28 @@
PointerPressed="TitleBarDragRegion_PointerPressed"
DoubleTapped="TitleBarDragRegion_DoubleTapped" />
<!-- Refresh button (visible when a games collection is active) -->
<!-- Refresh button (visible when a games collection is active and not syncing) -->
<Border Grid.Column="3"
IsVisible="{Binding ShellViewModel.IsRefreshVisible}"
Padding="0,0,2,0"
VerticalAlignment="Center">
<Button Classes="Text"
Padding="8,6"
Command="{Binding ShellViewModel.RefreshCommand}">
<Icon Type="Regular" Value="ArrowsClockwise" Width="16" Height="16" />
</Button>
<IconButton Classes="Text"
Command="{Binding ShellViewModel.RefreshCommand}"
IconType="Regular"
IconValue="ArrowsClockwise"
Width="40" Height="40" />
</Border>
<!-- Import progress bar (replaces refresh button while syncing) -->
<Border Grid.Column="3"
IsVisible="{Binding ShellViewModel.IsSyncing}"
Padding="4,0,4,0"
VerticalAlignment="Center">
<ProgressBar Value="{Binding ShellViewModel.ImportProgress}"
IsIndeterminate="{Binding ShellViewModel.IsImportIndeterminate}"
Minimum="0" Maximum="1"
Width="100" Height="4"
VerticalAlignment="Center" />
</Border>
<!-- Profile button (visible only when shell is active) -->

View file

@ -1,6 +1,7 @@
using LANCommander.Launcher.Data.Interceptors;
using LANCommander.Launcher.Data.Models;
using LANCommander.SDK;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@ -22,9 +23,27 @@ namespace LANCommander.Launcher.Data
{
var dbPath = AppPaths.GetConfigPath("LANCommander.db");
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = dbPath,
Cache = SqliteCacheMode.Shared,
}.ToString();
optionsBuilder.AddInterceptors(new AuditingInterceptor());
optionsBuilder.UseLoggerFactory(LoggerFactory);
optionsBuilder.UseSqlite($"Data Source={dbPath};Cache=Shared");
optionsBuilder.UseSqlite(connectionString, options =>
{
options.CommandTimeout(30);
});
}
/// <summary>
/// Enables WAL mode for concurrent reads during background writes.
/// Should be called once after the database is created/migrated.
/// </summary>
public async Task EnableWalModeAsync()
{
await Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;");
}
protected override void OnModelCreating(ModelBuilder builder)

View file

@ -4,6 +4,7 @@ using LANCommander.Launcher.Models;
using LANCommander.SDK;
using LANCommander.SDK.Extensions;
using LANCommander.SDK.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics;
@ -23,6 +24,16 @@ namespace LANCommander.Launcher.Services
{
public Dictionary<Guid, Process> RunningProcesses = new Dictionary<Guid, Process>();
public async Task<Dictionary<Guid, DateTime>> GetImportedOnMapAsync(IEnumerable<Guid> ids)
{
var idSet = ids.ToHashSet();
return await Context.Set<Game>()
.Where(g => idSet.Contains(g.Id))
.Select(g => new { g.Id, g.ImportedOn })
.ToDictionaryAsync(g => g.Id, g => g.ImportedOn);
}
public delegate Task OnUninstallCompleteHandler(Game game);
public event OnUninstallCompleteHandler OnUninstallComplete;

View file

@ -9,4 +9,6 @@ public interface IImportItemInfo
string Name { get; set; }
bool Processed { get; set; }
BaseManifest Manifest { get; set; }
int DeferCount { get; set; }
Guid? GameId { get; set; }
}

View file

@ -8,6 +8,8 @@ namespace LANCommander.Launcher.Services.Import;
public class ImportContext
{
private const int MaxDeferCount = 3;
private int Processed;
private int Total;
@ -15,8 +17,10 @@ public class ImportContext
public AsyncEventHandler<ImportStatusUpdate> OnImportComplete { get; set; } = new();
public AsyncEventHandler<ImportStatusUpdate> OnImportError { get; set; } = new();
public AsyncEventHandler<ImportStatusUpdate> OnImportStatusUpdate { get; set; } = new();
private Queue<IImportItemInfo> Queue { get; } = new();
public List<IImportItemInfo> FailedItems { get; } = new();
internal List<PendingMediaDownload> PendingMediaDownloads { get; } = new();
private readonly CollectionImporter _collections;
private readonly DeveloperImporter _developers;
@ -31,6 +35,7 @@ public class ImportContext
private readonly ToolImporter _tools;
private readonly ILogger<ImportContext> _logger;
private readonly MediaService _mediaService;
public ImportContext(IServiceProvider serviceProvider)
{
@ -45,37 +50,40 @@ public class ImportContext
_publishers = serviceProvider.GetRequiredService<PublisherImporter>();
_tags = serviceProvider.GetRequiredService<TagImporter>();
_logger = serviceProvider.GetRequiredService<ILogger<ImportContext>>();
_mediaService = serviceProvider.GetRequiredService<MediaService>();
SetupContextOnImporters();
}
public async Task AddAsync(Game game)
{
await AddAsync(game, game.Collections, _collections);
await AddAsync(game, game.Developers, _developers);
await AddAsync(game, game.Engine, _engines);
await AddAsync(game, game.Genres, _genres);
await AddAsync(game, game.Media, _media);
await AddAsync(game, game.MultiplayerModes, _multiplayerModes);
await AddAsync(game, game.Platforms, _platforms);
await AddAsync(game, game.Publishers, _publishers);
await AddAsync(game, game.Tags, _tags);
await AddAsync(game, game, _games);
var gameId = game.Id;
await AddAsync(game, game.Collections, _collections, gameId);
await AddAsync(game, game.Developers, _developers, gameId);
await AddAsync(game, game.Engine, _engines, gameId);
await AddAsync(game, game.Genres, _genres, gameId);
await AddAsync(game, game.Media, _media, gameId);
await AddAsync(game, game.MultiplayerModes, _multiplayerModes, gameId);
await AddAsync(game, game.Platforms, _platforms, gameId);
await AddAsync(game, game.Publishers, _publishers, gameId);
await AddAsync(game, game.Tags, _tags, gameId);
await AddAsync(game, game, _games, gameId);
}
public async Task AddAsync(Tool tool)
{
await AddAsync(tool, tool, _tools);
await AddAsync(tool, tool, _tools, null);
}
private async Task AddAsync<TRecord>(BaseManifest manifest, IEnumerable<TRecord> records, BaseImporter<TRecord> importer)
private async Task AddAsync<TRecord>(BaseManifest manifest, IEnumerable<TRecord> records, BaseImporter<TRecord> importer, Guid? gameId)
where TRecord : class
{
foreach (var record in records)
await AddAsync(manifest, record, importer);
await AddAsync(manifest, record, importer, gameId);
}
private async Task AddAsync<TRecord>(BaseManifest manifest, TRecord? record, BaseImporter<TRecord> importer)
private async Task AddAsync<TRecord>(BaseManifest manifest, TRecord? record, BaseImporter<TRecord> importer, Guid? gameId)
where TRecord : class
{
if (record != null && !InQueue(record, importer) && await importer.CanImportAsync(record))
@ -83,6 +91,7 @@ public class ImportContext
var importInfo = await importer.GetImportInfoAsync(record, manifest);
importInfo.Key = importer.GetKey(record);
importInfo.GameId = gameId;
_logger.LogInformation("Queuing item {ItemName} for import with key {Key}", importInfo.Name, importInfo.Key);
@ -105,7 +114,7 @@ public class ImportContext
Total = Total,
})!;
int deferred = 0;
int consecutiveDefers = 0;
while (Queue.Count > 0)
{
@ -119,24 +128,54 @@ public class ImportContext
Total = Total,
})!;
var success = await TryImportAsync(queueItem);
var result = await TryImportAsync(queueItem);
if (success)
switch (result)
{
_logger.LogInformation("Successfully imported item {ItemName}", queueItem.Name);
Processed++;
deferred = 0;
continue;
case ImportResult.Success:
_logger.LogInformation("Successfully imported item {ItemName}", queueItem.Name);
Processed++;
consecutiveDefers = 0;
break;
case ImportResult.Deferred:
queueItem.DeferCount++;
if (queueItem.DeferCount >= MaxDeferCount)
{
_logger.LogWarning("Item {ItemName} exceeded max defer count, marking as failed", queueItem.Name);
FailedItems.Add(queueItem);
Processed++;
consecutiveDefers = 0;
}
else
{
_logger.LogInformation("Deferring item {ItemName} for later import (attempt {Count})", queueItem.Name, queueItem.DeferCount);
Queue.Enqueue(queueItem);
consecutiveDefers++;
if (consecutiveDefers >= Queue.Count)
{
_logger.LogWarning("Import deadlocked: moving all remaining {Count} items to failed", Queue.Count);
while (Queue.Count > 0)
FailedItems.Add(Queue.Dequeue());
}
}
break;
case ImportResult.Failed:
_logger.LogWarning("Item {ItemName} failed permanently, skipping", queueItem.Name);
FailedItems.Add(queueItem);
Processed++;
consecutiveDefers = 0;
break;
}
_logger.LogInformation("Deferring item {ItemName} for later import", queueItem.Name);
Queue.Enqueue(queueItem);
deferred++;
if (deferred >= Queue.Count)
throw new InvalidOperationException("Import deadlocked: remaining jobs cannot be satisfied.");
}
if (FailedItems.Count > 0)
_logger.LogWarning("Import completed with {FailedCount} failed items: {Items}",
FailedItems.Count, string.Join(", ", FailedItems.Select(f => f.Name)));
await OnImportComplete?.InvokeAsync(new ImportStatusUpdate
{
Index = Total,
@ -144,6 +183,38 @@ public class ImportContext
})!;
}
public async Task DownloadPendingMediaAsync(int maxConcurrency = 4)
{
if (PendingMediaDownloads.Count == 0)
return;
_logger.LogInformation("Downloading {Count} media files with concurrency {Concurrency}",
PendingMediaDownloads.Count, maxConcurrency);
var semaphore = new SemaphoreSlim(maxConcurrency);
var tasks = PendingMediaDownloads.Select(async pending =>
{
await semaphore.WaitAsync();
try
{
await _mediaService.DownloadAsync(pending.Media);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download media {MediaId}", pending.Media.Id);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
_logger.LogInformation("Media download complete");
}
private void SetupContextOnImporters()
{
_collections.UseContext(this);
@ -158,11 +229,11 @@ public class ImportContext
_tags.UseContext(this);
}
private async Task<bool> TryImportAsync(IImportItemInfo queueItem)
private async Task<ImportResult> TryImportAsync(IImportItemInfo queueItem)
{
try
{
return queueItem.Type switch
var success = queueItem.Type switch
{
nameof(Collection) => await _collections.ImportAsync(queueItem),
"Developer" => await _developers.ImportAsync(queueItem),
@ -176,6 +247,8 @@ public class ImportContext
nameof(Tag) => await _tags.ImportAsync(queueItem),
_ => throw new InvalidOperationException($"No importer found for type {queueItem.Type}"),
};
return success ? ImportResult.Success : ImportResult.Deferred;
}
catch (Exception ex)
{
@ -188,8 +261,15 @@ public class ImportContext
Total = Total,
Error = ex.Message,
})!;
}
return false;
return ImportResult.Failed;
}
}
private enum ImportResult
{
Success,
Deferred,
Failed,
}
}

View file

@ -10,4 +10,11 @@ public class ImportItemInfo<T> : IImportItemInfo where T : class
public bool Processed { get; set; }
public BaseManifest Manifest { get; set; }
public T Record { get; set; }
public int DeferCount { get; set; }
public Guid? GameId { get; set; }
/// <summary>
/// Cached local entity loaded during ExistsAsync to avoid redundant DB lookups.
/// </summary>
public object? ExistingEntity { get; set; }
}

View file

@ -72,7 +72,8 @@ public class GameImporter(
public override async Task<bool> UpdateAsync(ImportItemInfo<Game> importItemInfo)
{
var existing = await gameService.GetAsync(importItemInfo.Record.Id);
var existing = importItemInfo.ExistingEntity as Data.Models.Game
?? await gameService.GetAsync(importItemInfo.Record.Id);
try
{
@ -87,10 +88,10 @@ public class GameImporter(
existing.CreatedOn = importItemInfo.Record.CreatedOn;
existing.ImportedOn = DateTime.UtcNow;
existing.LatestVersion = importItemInfo.Record.Version;
await gameService.UpdateAsync(existing);
await UpdateRelationships(importItemInfo.Record);
await UpdateRelationships(importItemInfo.Record, existing);
if (await libraryService.IsInstalledAsync(existing.Id) && existing.LatestVersion == existing.InstalledVersion)
await ManifestHelper.WriteAsync(importItemInfo.Record, existing.InstallDirectory);
@ -102,9 +103,9 @@ public class GameImporter(
}
}
private async Task UpdateRelationships(Game manifest)
private async Task UpdateRelationships(Game manifest, Data.Models.Game? cachedGame = null)
{
var game = await gameService.GetAsync(manifest.Id);
var game = cachedGame ?? await gameService.GetAsync(manifest.Id);
await gameService.SyncRelatedCollectionAsync(
game,
@ -143,5 +144,14 @@ public class GameImporter(
r => t => t.Name == r.Name);
}
public override async Task<bool> ExistsAsync(ImportItemInfo<Game> importItemInfo) => await gameService.ExistsAsync(importItemInfo.Record.Id);
public override async Task<bool> ExistsAsync(ImportItemInfo<Game> importItemInfo)
{
var existing = await gameService.GetAsync(importItemInfo.Record.Id);
if (existing != null)
{
importItemInfo.ExistingEntity = existing;
return true;
}
return false;
}
}

View file

@ -38,9 +38,9 @@ public class MediaImporter(
if (importItemInfo.Manifest is not Game game)
return false;
if (ImportContext is null)
{
throw new InvalidOperationException("The ImportContext has not been set. Ensure that the UseContext method is called before importing.");
if (ImportContext is null)
{
throw new InvalidOperationException("The ImportContext has not been set. Ensure that the UseContext method is called before importing.");
}
if (ImportContext.InQueue(game, gameImporter))
@ -61,14 +61,15 @@ public class MediaImporter(
media = await mediaService.AddAsync(media);
await mediaService.DownloadAsync(media);
// Defer download to parallel batch after queue processing
ImportContext.PendingMediaDownloads.Add(new PendingMediaDownload { Media = media });
return true;
}
catch(InvalidOperationException ex)
{
logger.LogError(ex, "Failed to add media due to invalid operation | {Key}", GetKey(importItemInfo.Record));
return false;
catch(InvalidOperationException ex)
{
logger.LogError(ex, "Failed to add media due to invalid operation | {Key}", GetKey(importItemInfo.Record));
return false;
}
catch (Exception ex)
{
@ -86,9 +87,10 @@ public class MediaImporter(
if (ImportContext.InQueue(game, gameImporter))
return false;
var existing = await mediaService.GetAsync(importItemInfo.Record.Id);
var existing = importItemInfo.ExistingEntity as Data.Models.Media
?? await mediaService.GetAsync(importItemInfo.Record.Id);
existing.FileId = importItemInfo.Record.FileId;
existing.Game = await gameService.GetAsync(game.Id);
existing.CreatedOn = importItemInfo.Record.CreatedOn;
@ -99,11 +101,21 @@ public class MediaImporter(
existing.Crc32 = importItemInfo.Record.Crc32 ?? string.Empty;
await mediaService.UpdateAsync(existing);
await mediaService.DownloadAsync(existing);
// Defer download to parallel batch after queue processing
ImportContext.PendingMediaDownloads.Add(new PendingMediaDownload { Media = existing });
return true;
}
public override async Task<bool> ExistsAsync(ImportItemInfo<Media> importItemInfo) =>
await mediaService.GetAsync(importItemInfo.Record.Id) != null;
public override async Task<bool> ExistsAsync(ImportItemInfo<Media> importItemInfo)
{
var existing = await mediaService.GetAsync(importItemInfo.Record.Id);
if (existing != null)
{
importItemInfo.ExistingEntity = existing;
return true;
}
return false;
}
}

View file

@ -0,0 +1,8 @@
using LANCommander.Launcher.Data.Models;
namespace LANCommander.Launcher.Services.Import;
public class PendingMediaDownload
{
public Media Media { get; set; }
}

View file

@ -15,6 +15,8 @@ namespace LANCommander.Launcher.Services
LibraryClient libraryClient,
GameService gameService) : BaseService(logger)
{
private const int MaxConcurrentManifestFetches = 8;
private ImportProgress _importProgress = new();
public ImportProgress Progress => _importProgress;
@ -41,29 +43,54 @@ namespace LANCommander.Launcher.Services
importContext.OnImportError = OnImportError;
importContext.OnImportStatusUpdate = OnImportStatusUpdate;
foreach (var game in remoteLibrary)
// Pre-fetch all local import timestamps in a single query
var gameIds = remoteLibrary.Select(g => g.Id);
var importedOnMap = await gameService.GetImportedOnMapAsync(gameIds);
// Filter to only games that need importing
var gamesToImport = remoteLibrary.Where(game =>
{
if (importedOnMap.TryGetValue(game.Id, out var importedOn) && game.UpdatedOn <= importedOn)
{
Logger?.LogDebug("Skipping unchanged game {GameId}", game.Id);
return false;
}
return true;
}).ToList();
Logger?.LogInformation("Importing {Count} games ({Skipped} skipped as unchanged)",
gamesToImport.Count, remoteLibrary.Count() - gamesToImport.Count);
// Fetch manifests concurrently
var semaphore = new SemaphoreSlim(MaxConcurrentManifestFetches);
var addLock = new object();
var tasks = gamesToImport.Select(async game =>
{
await semaphore.WaitAsync();
try
{
var existing = await gameService.GetAsync(game.Id);
if (existing != null && game.UpdatedOn <= existing.ImportedOn)
{
Logger?.LogDebug("Skipping unchanged game {GameId}", game.Id);
continue;
}
var manifest = await gameClient.GetManifestAsync(game.Id);
await importContext.AddAsync(manifest);
lock (addLock)
{
importContext.AddAsync(manifest).GetAwaiter().GetResult();
}
}
catch (Exception ex)
{
Logger?.LogError(ex, "Could not add game with ID {GameId} to import queue", game.Id);
}
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
await importContext.ImportQueueAsync();
await importContext.DownloadPendingMediaAsync();
}
public async Task ImportGameAsync(Guid gameId)