LANCommander/LANCommander.Server/UI/Pages/Games/Components/MetadataLookupPanel.razor
2026-06-04 01:29:31 -05:00

513 lines
19 KiB
Text

@using AntDesign.TableModels
@using LANCommander.Server.ImportExport.Factories
@using LANCommander.Server.Services.Providers.Metadata
@inject MetadataService MetadataService
@inject GameService GameService
@inject ImportContextFactory ImportContextFactory
@inject IMessageService MessageService
@inject ILogger<MetadataLookupPanel> Logger
<Spin Spinning="_importing" Tip="@_importStatus">
<Flex Gap="FlexGap.Small" Style="margin-bottom: 12px;">
<Input @bind-Value="Search" BindOnInput="true" OnkeyDown="SearchFieldOnKeyDown" AutoFocus="AutoFocus" />
<Button OnClick="() => SearchForGame(Search)" Type="ButtonType.Primary" Disabled="@(String.IsNullOrWhiteSpace(Search) || !_availableProviders.Any())">Search</Button>
</Flex>
@if (_stage == ModalStage.Search)
{
@if (_errorMessage != null)
{
<Result Status="ResultStatus.Error" Title="Search Failed" SubTitle="@_errorMessage">
<Extra>
<Button OnClick="DismissError">Dismiss</Button>
</Extra>
</Result>
}
else if (_hasSearched)
{
<Table
@ref="ResultsTable"
TItem="MetadataSearchResult<SDK.Models.Manifest.Game>"
DataSource="@(_results?.Results ?? [])"
HidePagination="true"
Loading="_loading"
OnRowClick="OnRowClicked"
@bind-SelectedRows="SelectedResults"
Responsive>
<Selection Key="@context.Id" Type="SelectionType.Radio"/>
<PropertyColumn Property="g => g.Data.Title" Title="Title"/>
<PropertyColumn Property="g => g.Data.ReleasedOn" Title="Released">
@(context.Data.ReleasedOn != default ? context.Data.ReleasedOn.ToString("MM/dd/yyyy") : "—")
</PropertyColumn>
<PropertyColumn Property="g => g.Data.Platforms" Title="Platforms">
@string.Join(", ", context.Data.Platforms?.Select(p => p.Name) ?? [])
</PropertyColumn>
<PropertyColumn Property="g => g.Data.Developers" Title="Developers">
@string.Join(", ", context.Data.Developers?.Select(d => d.Name) ?? [])
</PropertyColumn>
</Table>
@if (_results?.More ?? false)
{
<Button Type="ButtonType.Primary" OnClick="ShowMore" Block>Show More</Button>
}
}
<Flex Gap="FlexGap.Small" Justify="FlexJustify.End" Style="margin-top: 12px;">
<Select Value="_selectedProvider" ValueChanged="OnProviderChanged" DataSource="_availableProviders" TItem="string" TItemValue="string"/>
@if (_availableSubProviders?.Any() == true)
{
<Select Value="_selectedSubProvider" ValueChanged="OnSubProviderChanged" TItem="string" TItemValue="string">
@foreach (var sp in _availableSubProviders)
{
<SelectOption Value="@sp.Slug" Label="@sp.Name" TItemValue="string" TItem="string" />
}
</Select>
}
@if (ShowCancel)
{
<Button OnClick="HandleCancel" Style="margin-left: 0;">Cancel</Button>
}
</Flex>
@ChildContent
}
else if (_stage == ModalStage.Merge)
{
@if (_errorMessage != null)
{
<Result Status="ResultStatus.Error" Title="Import Failed" SubTitle="@_errorMessage">
<Extra>
<Button OnClick="DismissError">Dismiss</Button>
</Extra>
</Result>
}
else
{
<div class="ant-table">
<div class="ant-table-container">
<div class="ant-table-content">
<table class="metadata-merge-table">
<thead class="ant-table-thead">
<tr>
<th>Import</th>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody class="ant-table-tbody">
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importTitle" /></td>
<td class="ant-table-cell">Title</td>
<td class="ant-table-cell">@_fetchedManifest?.Title</td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importDescription" /></td>
<td class="ant-table-cell">Description</td>
<td class="ant-table-cell"><span class="metadata-merge-truncate">@_fetchedManifest?.Description</span></td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importReleasedOn" /></td>
<td class="ant-table-cell">Released On</td>
<td class="ant-table-cell">@(_fetchedManifest?.ReleasedOn.ToString("MM/dd/yyyy"))</td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importSingleplayer" /></td>
<td class="ant-table-cell">Singleplayer</td>
<td class="ant-table-cell">@(_fetchedManifest?.Singleplayer == true ? "Yes" : "No")</td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importEngine" /></td>
<td class="ant-table-cell">Engine</td>
<td class="ant-table-cell">@(_fetchedManifest?.Engine?.Name ?? "—")</td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importDevelopers" /></td>
<td class="ant-table-cell">Developers</td>
<td class="ant-table-cell">
@foreach (var item in _fetchedManifest?.Developers ?? [])
{
<Tag>@item.Name</Tag>
}
</td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importPublishers" /></td>
<td class="ant-table-cell">Publishers</td>
<td class="ant-table-cell">
@foreach (var item in _fetchedManifest?.Publishers ?? [])
{
<Tag>@item.Name</Tag>
}
</td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importGenres" /></td>
<td class="ant-table-cell">Genres</td>
<td class="ant-table-cell">
@foreach (var item in _fetchedManifest?.Genres ?? [])
{
<Tag>@item.Name</Tag>
}
</td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importTags" /></td>
<td class="ant-table-cell">Tags</td>
<td class="ant-table-cell">
@foreach (var item in _fetchedManifest?.Tags ?? [])
{
<Tag>@item.Name</Tag>
}
</td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importMultiplayerModes" /></td>
<td class="ant-table-cell">Multiplayer Modes</td>
<td class="ant-table-cell">@(_fetchedManifest?.MultiplayerModes?.Count ?? 0) mode(s)</td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importSavePaths" /></td>
<td class="ant-table-cell">Save Paths</td>
<td class="ant-table-cell">@(_fetchedManifest?.SavePaths?.Count ?? 0) path(s)</td>
</tr>
<tr class="ant-table-row">
<td class="ant-table-cell"><Checkbox @bind-Checked="_importExternalIds" /></td>
<td class="ant-table-cell">External IDs</td>
<td class="ant-table-cell">
@foreach (var item in _fetchedManifest?.ExternalIds ?? [])
{
<Tag>@item.Provider: @item.ExternalId</Tag>
}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
}
<Flex Gap="FlexGap.Small" Justify="FlexJustify.End" Style="margin-top: 12px;">
<Button OnClick="ImportWithMerge" Loading="_loading" Type="ButtonType.Primary">Import</Button>
<Button OnClick="BackToSearch" Disabled="_loading">Back</Button>
@if (ShowCancel)
{
<Button OnClick="HandleCancel" Disabled="_loading">Cancel</Button>
}
</Flex>
}
</Spin>
@code {
private enum ModalStage { Search, Merge }
[Parameter] public Guid GameId { get; set; }
[Parameter] public EventCallback<Guid> OnImported { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public bool AutoFocus { get; set; }
[Parameter] public bool ShowCancel { get; set; } = true;
[Parameter] public string Search { get; set; } = string.Empty;
[Parameter] public EventCallback<string> SearchChanged { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
ITable? ResultsTable;
MetadataSearchResultsCollection<SDK.Models.Manifest.Game>? _results;
IEnumerable<MetadataSearchResult<SDK.Models.Manifest.Game>> SelectedResults { get; set; }
bool _loading = false;
bool _hasSearched = false;
int _limit = 10;
int _offset = 0;
string _selectedProvider = string.Empty;
IEnumerable<string> _availableProviders = [];
string? _selectedSubProvider;
IEnumerable<(string Slug, string Name)>? _availableSubProviders;
ModalStage _stage = ModalStage.Search;
string? _errorMessage = null;
LANCommander.SDK.Models.Manifest.Game? _fetchedManifest = null;
bool _importing = false;
string _importStatus = "Importing...";
bool _importTitle = true;
bool _importDescription = true;
bool _importReleasedOn = true;
bool _importSingleplayer = true;
bool _importEngine = true;
bool _importDevelopers = true;
bool _importPublishers = true;
bool _importGenres = true;
bool _importTags = true;
bool _importMultiplayerModes = true;
bool _importSavePaths = true;
bool _importExternalIds = true;
protected override async Task OnInitializedAsync()
{
_availableProviders = MetadataService.GetProviderNames();
_selectedProvider = _availableProviders.FirstOrDefault() ?? string.Empty;
await UpdateSubProvidersAsync();
}
private async Task OnProviderChanged(string provider)
{
_selectedProvider = provider;
_errorMessage = null;
await UpdateSubProvidersAsync();
if (!string.IsNullOrWhiteSpace(Search))
await SearchForGame(Search);
}
private async Task OnSubProviderChanged(string subProvider)
{
_selectedSubProvider = subProvider;
_errorMessage = null;
if (!string.IsNullOrWhiteSpace(Search))
await SearchForGame(Search);
}
private async Task UpdateSubProvidersAsync()
{
var provider = MetadataService.GetProvider(_selectedProvider);
if (provider is not null)
{
_availableSubProviders = await provider.GetSubProvidersAsync();
_selectedSubProvider = _availableSubProviders?.FirstOrDefault().Slug;
}
else
{
_availableSubProviders = null;
_selectedSubProvider = null;
}
}
private async Task OnRowClicked(RowData<MetadataSearchResult<SDK.Models.Manifest.Game>> row)
{
ResultsTable?.SetSelection([row.Data.Id]);
await BeginMerge();
}
private async Task SearchFieldOnKeyDown(KeyboardEventArgs e)
{
if ((e.Code == "Enter" || e.Code == "NumpadEnter") && !String.IsNullOrWhiteSpace(Search))
await SearchForGame(Search);
}
private async Task SearchForGame(string title, bool newSearch = true)
{
var provider = MetadataService.GetProvider(_selectedProvider);
if (provider is null)
{
MessageService.Error($"Provider \"{_selectedProvider}\" is not available");
return;
}
try
{
_loading = true;
_hasSearched = true;
_errorMessage = null;
if (newSearch)
{
_results = null;
_offset = 0;
}
_results = _availableSubProviders?.Any() == true
? await provider.SearchGamesAsync(title, _selectedSubProvider, _limit, _offset)
: await provider.SearchGamesAsync(title, _limit, _offset);
_loading = false;
}
catch (Exception ex)
{
Logger.LogError(ex, "An error occurred searching {Provider}", _selectedProvider);
_loading = false;
_errorMessage = ex.Message;
}
}
private async Task BeginMerge()
{
var provider = MetadataService.GetProvider(_selectedProvider);
if (provider is null)
return;
_loading = true;
_errorMessage = null;
try
{
var selected = SelectedResults.First();
var manifest = await provider.GetGameAsync(selected.Id);
if (manifest is null)
{
_errorMessage = "Could not retrieve game metadata from the provider.";
_loading = false;
return;
}
_fetchedManifest = manifest;
_importTitle = true;
_importDescription = !string.IsNullOrWhiteSpace(manifest.Description);
_importReleasedOn = manifest.ReleasedOn != default;
_importSingleplayer = true;
_importEngine = manifest.Engine != null;
_importDevelopers = manifest.Developers?.Any() == true;
_importPublishers = manifest.Publishers?.Any() == true;
_importGenres = manifest.Genres?.Any() == true;
_importTags = manifest.Tags?.Any() == true;
_importMultiplayerModes = manifest.MultiplayerModes?.Any() == true;
_importSavePaths = manifest.SavePaths?.Any() == true;
_importExternalIds = manifest.ExternalIds?.Any() == true;
_stage = ModalStage.Merge;
}
catch (Exception ex)
{
Logger.LogError(ex, "An error occurred fetching metadata from {Provider}", _selectedProvider);
_errorMessage = ex.Message;
}
finally
{
_loading = false;
}
}
private async Task ImportWithMerge()
{
if (_fetchedManifest is null)
return;
_loading = true;
_importing = true;
_importStatus = "Importing...";
_errorMessage = null;
try
{
LANCommander.Server.Data.Models.Game? existing = null;
if (GameId != Guid.Empty)
existing = await GameService.GetAsync(GameId);
var manifest = _fetchedManifest;
manifest.Id = GameId == Guid.Empty ? Guid.NewGuid() : GameId;
if (!_importTitle && existing != null)
manifest.Title = existing.Title;
if (!_importDescription && existing != null)
manifest.Description = existing.Description;
if (!_importReleasedOn && existing != null)
manifest.ReleasedOn = existing.ReleasedOn ?? default;
if (!_importSingleplayer && existing != null)
manifest.Singleplayer = existing.Singleplayer;
if (!_importEngine)
manifest.Engine = null;
if (!_importDevelopers)
manifest.Developers = new List<LANCommander.SDK.Models.Manifest.Company>();
if (!_importPublishers)
manifest.Publishers = new List<LANCommander.SDK.Models.Manifest.Company>();
if (!_importGenres)
manifest.Genres = new List<LANCommander.SDK.Models.Manifest.Genre>();
if (!_importTags)
manifest.Tags = new List<LANCommander.SDK.Models.Manifest.Tag>();
if (!_importMultiplayerModes)
manifest.MultiplayerModes = new List<LANCommander.SDK.Models.Manifest.MultiplayerMode>();
if (!_importSavePaths)
manifest.SavePaths = new List<LANCommander.SDK.Models.Manifest.SavePath>();
if (!_importExternalIds)
manifest.ExternalIds = new List<LANCommander.SDK.Models.Manifest.GameExternalId>();
using var context = ImportContextFactory.Create();
await context.InitializeMetadataUpdateAsync(manifest);
await context.ImportQueueAsync();
_loading = false;
await OnImported.InvokeAsync(manifest.Id);
_importing = false;
Reset();
}
catch (Exception ex)
{
Logger.LogError(ex, "An error occurred importing metadata from {Provider}", _selectedProvider);
_errorMessage = ex.Message;
_loading = false;
_importing = false;
}
}
public async Task UpdateStatusAsync(string status)
{
_importStatus = status;
await InvokeAsync(StateHasChanged);
}
private void BackToSearch()
{
_stage = ModalStage.Search;
_errorMessage = null;
_fetchedManifest = null;
SelectedResults = [];
}
private void DismissError()
{
_errorMessage = null;
}
private void Reset()
{
_results = null;
_limit = 10;
_offset = 0;
_hasSearched = false;
_stage = ModalStage.Search;
_errorMessage = null;
_fetchedManifest = null;
}
private async Task HandleCancel()
{
Reset();
await OnCancel.InvokeAsync();
}
private async Task ShowMore()
{
_offset += _results?.Limit ?? _limit;
await SearchForGame(Search, false);
}
}