246 lines
9.4 KiB
C#
246 lines
9.4 KiB
C#
using LANCommander.Launcher.Data;
|
|
using LANCommander.Launcher.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using LANCommander.Launcher.Services.Import;
|
|
using LANCommander.Launcher.Services.Import.Factories;
|
|
using LANCommander.SDK;
|
|
using LANCommander.SDK.Services;
|
|
|
|
namespace LANCommander.Launcher.Services
|
|
{
|
|
public class ImportService(
|
|
ILogger<ImportService> logger,
|
|
ImportContextFactory importContextFactory,
|
|
GameClient gameClient,
|
|
ToolClient toolClient,
|
|
LibraryClient libraryClient,
|
|
PlaySessionClient playSessionClient,
|
|
DatabaseContext dbContext,
|
|
GameService gameService,
|
|
AuthenticationService authenticationService) : BaseService(logger)
|
|
{
|
|
private const int MaxConcurrentManifestFetches = 8;
|
|
|
|
private ImportProgress _importProgress = new();
|
|
public ImportProgress Progress => _importProgress;
|
|
|
|
public AsyncEventHandler<ImportStatusUpdate> OnImportStarted { get; } = new();
|
|
public AsyncEventHandler<ImportStatusUpdate> OnImportStatusUpdate { get; } = new();
|
|
public AsyncEventHandler<ImportStatusUpdate> OnImportComplete { get; } = new();
|
|
public AsyncEventHandler<ImportStatusUpdate> OnImportError { get; } = new();
|
|
|
|
public async Task ImportAsync()
|
|
{
|
|
await ImportLibraryAsync();
|
|
}
|
|
|
|
public async Task ImportLibraryAsync()
|
|
{
|
|
var remoteLibrary = await libraryClient.GetAsync();
|
|
|
|
Logger?.LogInformation("Starting library import");
|
|
|
|
var importContext = importContextFactory.Create();
|
|
|
|
importContext.OnImportStarted = OnImportStarted;
|
|
importContext.OnImportComplete = OnImportComplete;
|
|
importContext.OnImportError = OnImportError;
|
|
importContext.OnImportStatusUpdate = OnImportStatusUpdate;
|
|
|
|
// 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 manifest = await gameClient.GetManifestAsync(game.Id);
|
|
|
|
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();
|
|
|
|
// Make the local library membership match the remote library exactly
|
|
await ReconcileLibraryMembershipAsync(remoteLibrary.Select(g => g.Id).ToList());
|
|
|
|
// Sync play sessions for all library games
|
|
await SyncPlaySessionsAsync(remoteLibrary.Select(g => g.Id));
|
|
}
|
|
|
|
internal async Task ReconcileLibraryMembershipAsync(IReadOnlyCollection<Guid> remoteGameIds)
|
|
{
|
|
// The library endpoint returns an empty list both when the library is genuinely
|
|
// empty and when it fails server-side. Treating an empty result as authoritative
|
|
// would wipe the entire local library on a transient error, so skip reconciliation
|
|
// in that case.
|
|
if (remoteGameIds.Count == 0)
|
|
{
|
|
Logger?.LogDebug("Skipping library reconciliation because the remote library returned no games");
|
|
return;
|
|
}
|
|
|
|
var userId = authenticationService.GetUserId();
|
|
|
|
var library = await dbContext.Libraries
|
|
.Include(l => l.Games)
|
|
.FirstOrDefaultAsync(l => l.UserId == userId);
|
|
|
|
if (library == null)
|
|
return;
|
|
|
|
var remoteGameIdSet = remoteGameIds.ToHashSet();
|
|
|
|
// Remove games that are no longer present in the remote library.
|
|
var staleGames = library.Games
|
|
.Where(g => !remoteGameIdSet.Contains(g.Id))
|
|
.ToList();
|
|
|
|
foreach (var staleGame in staleGames)
|
|
library.Games.Remove(staleGame);
|
|
|
|
// Add games that belong in the remote library and already exist locally but are
|
|
// missing from the local library. The import above skips games whose cached records
|
|
// are unchanged, so toggling "Enable User Libraries" off on the server (which makes
|
|
// the endpoint return every game) would otherwise leave previously-dropped games
|
|
// hidden from the library view.
|
|
var localGameIds = library.Games
|
|
.Select(g => g.Id)
|
|
.ToHashSet();
|
|
|
|
var missingGameIds = remoteGameIdSet
|
|
.Where(id => !localGameIds.Contains(id))
|
|
.ToList();
|
|
|
|
var gamesToAdd = missingGameIds.Count == 0
|
|
? new List<Data.Models.Game>()
|
|
: await dbContext.Games
|
|
.Where(g => missingGameIds.Contains(g.Id))
|
|
.ToListAsync();
|
|
|
|
foreach (var game in gamesToAdd)
|
|
library.Games.Add(game);
|
|
|
|
if (staleGames.Count == 0 && gamesToAdd.Count == 0)
|
|
return;
|
|
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
if (staleGames.Count > 0)
|
|
Logger?.LogInformation("Removed {Count} game(s) from the local library that are no longer present on the server", staleGames.Count);
|
|
|
|
if (gamesToAdd.Count > 0)
|
|
Logger?.LogInformation("Added {Count} game(s) to the local library that were already cached locally", gamesToAdd.Count);
|
|
}
|
|
|
|
public async Task ImportGameAsync(Guid gameId)
|
|
{
|
|
var importContext = importContextFactory.Create();
|
|
|
|
var manifest = await gameClient.GetManifestAsync(gameId);
|
|
|
|
await importContext.AddAsync(manifest);
|
|
await importContext.ImportQueueAsync();
|
|
await importContext.DownloadPendingMediaAsync();
|
|
|
|
await SyncPlaySessionsAsync([gameId]);
|
|
}
|
|
|
|
private async Task SyncPlaySessionsAsync(IEnumerable<Guid> gameIds)
|
|
{
|
|
try
|
|
{
|
|
var localSessionIds = (await dbContext.Set<Data.Models.PlaySession>()
|
|
.Select(ps => ps.Id)
|
|
.ToListAsync())
|
|
.ToHashSet();
|
|
|
|
foreach (var gameId in gameIds)
|
|
{
|
|
try
|
|
{
|
|
var remoteSessions = await playSessionClient.GetAsync(gameId);
|
|
|
|
if (remoteSessions == null)
|
|
continue;
|
|
|
|
foreach (var remote in remoteSessions)
|
|
{
|
|
if (!localSessionIds.Contains(remote.Id))
|
|
{
|
|
dbContext.Set<Data.Models.PlaySession>().Add(new Data.Models.PlaySession
|
|
{
|
|
Id = remote.Id,
|
|
GameId = remote.GameId,
|
|
UserId = remote.UserId,
|
|
Start = remote.Start,
|
|
End = remote.End,
|
|
CreatedOn = remote.CreatedOn,
|
|
UpdatedOn = remote.UpdatedOn,
|
|
});
|
|
|
|
localSessionIds.Add(remote.Id);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger?.LogError(ex, "Failed to sync play sessions for game {GameId}", gameId);
|
|
}
|
|
}
|
|
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger?.LogError(ex, "Failed to sync play sessions");
|
|
}
|
|
}
|
|
|
|
public async Task ImportToolAsync(Guid toolId)
|
|
{
|
|
var importContext = importContextFactory.Create();
|
|
|
|
var manifest = await toolClient.GetManifestAsync(toolId);
|
|
|
|
await importContext.AddAsync(manifest);
|
|
await importContext.ImportQueueAsync();
|
|
}
|
|
}
|
|
}
|