Files
archived-discord-bot/CompatBot/Commands/CompatList.cs
2025-03-15 20:55:14 +05:00

636 lines
30 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using CompatApiClient;
using CompatApiClient.Compression;
using CompatApiClient.POCOs;
using CompatApiClient.Utils;
using CompatBot.Database;
using CompatBot.Database.Providers;
using CompatBot.EventHandlers;
using CompatBot.Utils.Extensions;
using CompatBot.Utils.ResultFormatters;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.TeamFoundation.Build.WebApi;
namespace CompatBot.Commands;
internal sealed partial class CompatList
{
private static readonly Client Client = new();
private static readonly GithubClient.Client GithubClient = new(Config.GithubToken);
private static readonly SemaphoreSlim UpdateCheck = new(1, 1);
private static string? lastUpdateInfo, lastFullBuildNumber;
private const string Rpcs3UpdateStateKey = "Rpcs3UpdateState";
private const string Rpcs3UpdateBuildKey = "Rpcs3UpdateBuild";
private static UpdateInfo? cachedUpdateInfo;
[GeneratedRegex(@"v(?<version>\d+\.\d+\.\d+)-(?<build>\d+)-(?<commit>[0-9a-f]+)\b", RegexOptions.Singleline | RegexOptions.ExplicitCapture)]
private static partial Regex UpdateVersionRegex();
[GeneratedRegex(@"\b(demo|trial)\b", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
internal static partial Regex TrialNamePattern();
static CompatList()
{
using var db = new BotDb();
lastUpdateInfo = db.BotState.FirstOrDefault(k => k.Key == Rpcs3UpdateStateKey)?.Value;
lastFullBuildNumber = db.BotState.FirstOrDefault(k => k.Key == Rpcs3UpdateBuildKey)?.Value;
//lastUpdateInfo = "8022";
if (lastUpdateInfo is {Length: >0} strPr && int.TryParse(strPr, out var pr))
{
try
{
var prInfo = GithubClient.GetPrInfoAsync(pr, Config.Cts.Token).ConfigureAwait(false).GetAwaiter().GetResult();
cachedUpdateInfo = Client.GetUpdateAsync(Config.Cts.Token, prInfo?.MergeCommitSha).ConfigureAwait(false).GetAwaiter().GetResult();
if (cachedUpdateInfo?.CurrentBuild == null)
return;
cachedUpdateInfo.LatestBuild = cachedUpdateInfo.CurrentBuild;
cachedUpdateInfo.CurrentBuild = null;
}
catch { }
}
}
[Command("compatibility")]
[Description("Search the game compatibility list")]
public async ValueTask Compat(SlashCommandContext ctx, [Description("Game title or product code to look up")] string title)
{
if (await ContentFilter.FindTriggerAsync(FilterContext.Chat, title).ConfigureAwait(false) is not null)
{
await ctx.RespondAsync("Invalid game title or product code.", true).ConfigureAwait(false);
return;
}
var ephemeral = !ctx.Channel.IsSpamChannel();
await ctx.DeferResponseAsync(ephemeral).ConfigureAwait(false);
var productCodes = ProductCodeLookup.GetProductIds(title);
if (productCodes.Count > 0)
{
var formattedResults = await ProductCodeLookup.LookupProductCodeAndFormatAsync(ctx.Client, productCodes).ConfigureAwait(false);
await ctx.RespondAsync(embed: formattedResults[0].builder, ephemeral: ephemeral).ConfigureAwait(false);
return;
}
try
{
title = title.TrimEager().Truncate(40);
var requestBuilder = RequestBuilder.Start().SetSearch(title);
await DoRequestAndRespondAsync(ctx, ephemeral, requestBuilder).ConfigureAwait(false);
}
catch (Exception e)
{
Config.Log.Error(e, "Failed to get compat list info");
}
}
//[Command("latest")]
[Description("Links to the latest RPCS3 build")]
public sealed class UpdatesCheck
{
/*
[Command("build"), DefaultGroupCommand]
public Task Latest(SlashCommandContext ctx) => CheckForRpcs3Updates(ctx.Client, ctx.Channel);
[Command("since")]
[Description("Show additional info about changes since specified update")]
public Task Since(SlashCommandContext ctx, [Description("Commit hash of the update, such as `46abe0f31`")] string commit)
=> CheckForRpcs3Updates(ctx.Client, ctx.Channel, commit);
[Command("clear"), RequiresBotModRole]
[Description("Clears update info cache that is used to post new build announcements")]
public Task Clear(SlashCommandContext ctx)
{
lastUpdateInfo = null;
lastFullBuildNumber = null;
return CheckForRpcs3Updates(ctx.Client, null);
}
[Command("set"), RequiresBotModRole]
[Description("Sets update info cache that is used to check if new updates are available")]
public Task Set(SlashCommandContext ctx, string lastUpdatePr)
{
lastUpdateInfo = lastUpdatePr;
lastFullBuildNumber = null;
return CheckForRpcs3Updates(ctx.Client, null);
}
*/
public static async ValueTask<bool> CheckForRpcs3Updates(DiscordClient discordClient, DiscordChannel? channel, string? sinceCommit = null, DiscordMessage? emptyBotMsg = null)
{
var updateAnnouncement = channel is null;
var updateAnnouncementRestore = emptyBotMsg != null;
var info = await Client.GetUpdateAsync(Config.Cts.Token, sinceCommit).ConfigureAwait(false);
if (info?.ReturnCode != 1 && sinceCommit != null)
info = await Client.GetUpdateAsync(Config.Cts.Token).ConfigureAwait(false);
if (updateAnnouncementRestore && info?.CurrentBuild != null)
info.LatestBuild = info.CurrentBuild;
var embed = await info.AsEmbedAsync(discordClient, updateAnnouncement).ConfigureAwait(false);
if (info == null || embed.Color.Value.Value == Config.Colors.Maintenance.Value)
{
if (updateAnnouncementRestore)
{
Config.Log.Debug($"Failed to get update info for commit {sinceCommit}: {JsonSerializer.Serialize(info)}");
return false;
}
embed = await cachedUpdateInfo.AsEmbedAsync(discordClient, updateAnnouncement).ConfigureAwait(false);
}
else if (!updateAnnouncementRestore)
{
if (cachedUpdateInfo?.LatestBuild?.Datetime is string previousBuildTimeStr
&& info.LatestBuild?.Datetime is string newBuildTimeStr
&& DateTime.TryParse(previousBuildTimeStr, out var previousBuildTime)
&& DateTime.TryParse(newBuildTimeStr, out var newBuildTime)
&& newBuildTime > previousBuildTime)
cachedUpdateInfo = info;
}
if (!updateAnnouncement)
{
await channel!.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false);
return true;
}
if (updateAnnouncementRestore)
{
if (embed.Title == "Error")
return false;
Config.Log.Debug($"Restoring update announcement for build {sinceCommit}: {embed.Title}\n{embed.Description}");
await emptyBotMsg!.ModifyAsync(embed: embed.Build()).ConfigureAwait(false);
return true;
}
var latestUpdatePr = info?.LatestBuild?.Pr?.ToString();
var match = (
from field in embed.Fields
let m = UpdateVersionRegex().Match(field.Value)
where m.Success
select m
).FirstOrDefault();
var latestUpdateBuild = match?.Groups["build"].Value;
if (string.IsNullOrEmpty(latestUpdatePr)
|| lastUpdateInfo == latestUpdatePr
|| !await UpdateCheck.WaitAsync(0).ConfigureAwait(false))
return false;
try
{
if (!string.IsNullOrEmpty(lastFullBuildNumber)
&& !string.IsNullOrEmpty(latestUpdateBuild)
&& int.TryParse(lastFullBuildNumber, out var lastSaveBuild)
&& int.TryParse(latestUpdateBuild, out var latestBuild)
&& latestBuild <= lastSaveBuild)
return false;
var compatChannel = await discordClient.GetChannelAsync(Config.BotChannelId).ConfigureAwait(false);
var botMember = await discordClient.GetMemberAsync(compatChannel.Guild, discordClient.CurrentUser).ConfigureAwait(false);
if (botMember == null)
return false;
if (!compatChannel.PermissionsFor(botMember).HasPermission(DiscordPermission.SendMessages))
{
NewBuildsMonitor.Reset();
return false;
}
if (embed.Color.Value.Value == Config.Colors.Maintenance.Value)
return false;
await CheckMissedBuildsBetweenAsync(discordClient, compatChannel, lastUpdateInfo, latestUpdatePr, Config.Cts.Token).ConfigureAwait(false);
await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false);
lastUpdateInfo = latestUpdatePr;
lastFullBuildNumber = latestUpdateBuild;
await using var db = new BotDb();
var currentState = await db.BotState.FirstOrDefaultAsync(k => k.Key == Rpcs3UpdateStateKey).ConfigureAwait(false);
if (currentState == null)
await db.BotState.AddAsync(new() {Key = Rpcs3UpdateStateKey, Value = latestUpdatePr}).ConfigureAwait(false);
else
currentState.Value = latestUpdatePr;
var savedLastBuild = await db.BotState.FirstOrDefaultAsync(k => k.Key == Rpcs3UpdateBuildKey).ConfigureAwait(false);
if (savedLastBuild == null)
await db.BotState.AddAsync(new() {Key = Rpcs3UpdateBuildKey, Value = latestUpdateBuild}).ConfigureAwait(false);
else
savedLastBuild.Value = latestUpdateBuild;
await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false);
NewBuildsMonitor.Reset();
return true;
}
catch (Exception e)
{
Config.Log.Warn(e, "Failed to check for RPCS3 update info");
}
finally
{
UpdateCheck.Release();
}
return false;
}
private static async ValueTask CheckMissedBuildsBetweenAsync(DiscordClient discordClient, DiscordChannel compatChannel, string? previousUpdatePr, string? latestUpdatePr, CancellationToken cancellationToken)
{
if (!int.TryParse(previousUpdatePr, out var oldestPr)
|| !int.TryParse(latestUpdatePr, out var newestPr))
return;
var mergedPrs = await GithubClient.GetClosedPrsAsync(cancellationToken).ConfigureAwait(false); // this will cache 30 latest PRs
var newestPrCommit = await GithubClient.GetPrInfoAsync(newestPr, cancellationToken).ConfigureAwait(false);
var oldestPrCommit = await GithubClient.GetPrInfoAsync(oldestPr, cancellationToken).ConfigureAwait(false);
if (newestPrCommit?.MergedAt == null || oldestPrCommit?.MergedAt == null)
return;
mergedPrs = mergedPrs?.Where(pri => pri.MergedAt.HasValue)
.OrderBy(pri => pri.MergedAt!.Value)
.SkipWhile(pri => pri.Number != oldestPr)
.Skip(1)
.TakeWhile(pri => pri.Number != newestPr)
.ToList();
if (mergedPrs is null or {Count: 0})
return;
var failedBuilds = await Config.GetAzureDevOpsClient().GetMasterBuildsAsync(
oldestPrCommit.MergeCommitSha,
newestPrCommit.MergeCommitSha,
oldestPrCommit.MergedAt?.DateTime,
cancellationToken
).ConfigureAwait(false);
foreach (var mergedPr in mergedPrs)
{
var updateInfo = await Client.GetUpdateAsync(cancellationToken, mergedPr.MergeCommitSha).ConfigureAwait(false)
?? new UpdateInfo {ReturnCode = -1};
if (updateInfo.ReturnCode is 0 or 1) // latest or known build
{
updateInfo.LatestBuild = updateInfo.CurrentBuild;
updateInfo.CurrentBuild = null;
var embed = await updateInfo.AsEmbedAsync(discordClient, true).ConfigureAwait(false);
await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false);
}
else if (updateInfo.ReturnCode == -1) // unknown build
{
var masterBuildInfo = failedBuilds?.FirstOrDefault(b => b.Commit?.Equals(mergedPr.MergeCommitSha, StringComparison.InvariantCultureIgnoreCase) is true);
var buildTime = masterBuildInfo?.FinishTime;
if (masterBuildInfo != null)
{
updateInfo = new()
{
ReturnCode = 1,
LatestBuild = new()
{
Datetime = buildTime?.ToString("yyyy-MM-dd HH:mm:ss"),
Pr = mergedPr.Number,
Windows = new() {Download = masterBuildInfo.WindowsBuildDownloadLink ?? ""},
Linux = new() { Download = masterBuildInfo.LinuxBuildDownloadLink ?? "" },
Mac = new() { Download = masterBuildInfo.MacBuildDownloadLink ?? "" },
},
};
}
else
{
updateInfo = new()
{
ReturnCode = 1,
LatestBuild = new()
{
Pr = mergedPr.Number,
Windows = new() {Download = ""},
Linux = new() { Download = "" },
Mac = new() { Download = "" },
},
};
}
var embed = await updateInfo.AsEmbedAsync(discordClient, true).ConfigureAwait(false);
embed.Color = Config.Colors.PrClosed;
embed.ClearFields();
var reason = masterBuildInfo?.Result switch
{
BuildResult.Succeeded => "Built",
BuildResult.PartiallySucceeded => "Built",
BuildResult.Failed => "Failed to build",
BuildResult.Canceled => "Cancelled",
_ => null,
};
if (buildTime.HasValue && reason != null)
embed.WithFooter($"{reason} on {buildTime:u} ({(DateTime.UtcNow - buildTime.Value).AsTimeDeltaDescription()} ago)");
else
embed.WithFooter(reason ?? "Never built");
await compatChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false);
}
}
}
}
private static async ValueTask DoRequestAndRespondAsync(SlashCommandContext ctx, bool ephemeral, RequestBuilder requestBuilder)
{
Config.Log.Info(requestBuilder.Build());
CompatResult? result = null;
try
{
var remoteSearchTask = Client.GetCompatResultAsync(requestBuilder, Config.Cts.Token);
var localResult = GetLocalCompatResult(requestBuilder);
result = localResult;
var remoteResult = await remoteSearchTask.ConfigureAwait(false);
result = remoteResult?.Append(localResult);
}
catch
{
if (result == null)
{
await ctx.RespondAsync(embed: TitleInfo.CommunicationError.AsEmbed(null), ephemeral).ConfigureAwait(false);
return;
}
}
#if DEBUG
await Task.Delay(5_000).ConfigureAwait(false);
#endif
if (result?.Results?.Count == 1)
{
var formattedResults = await ProductCodeLookup.LookupProductCodeAndFormatAsync(ctx.Client, [..result.Results.Keys]).ConfigureAwait(false);
await ctx.RespondAsync(embed: formattedResults[0].builder, ephemeral: ephemeral).ConfigureAwait(false);
}
else if (result != null)
{
var builder = new StringBuilder();
foreach (var msg in FormatSearchResults(ctx, result))
builder.AppendLine(msg);
var formattedResults = AutosplitResponseHelper.AutosplitMessage(builder.ToString(), blockStart: null, blockEnd: null);
await ctx.RespondAsync(formattedResults[0], ephemeral).ConfigureAwait(false);
}
}
internal static CompatResult GetLocalCompatResult(RequestBuilder requestBuilder)
{
var timer = Stopwatch.StartNew();
var title = requestBuilder.Search;
using var db = new ThumbnailDb();
var matches = db.Thumbnail
.AsNoTracking()
.AsEnumerable()
.Select(t => (thumb: t, coef: title.GetFuzzyCoefficientCached(t.Name)))
.OrderByDescending(i => i.coef)
.Take(requestBuilder.AmountRequested)
.ToList();
var result = new CompatResult
{
RequestBuilder = requestBuilder,
ReturnCode = 0,
SearchTerm = requestBuilder.Search,
Results = matches.ToDictionary(i => i.thumb.ProductCode, i => new TitleInfo
{
Status = i.thumb.CompatibilityStatus?.ToString() ?? "Unknown",
Title = i.thumb.Name,
Date = i.thumb.CompatibilityChangeDate?.AsUtc().ToString("yyyy-MM-dd"),
})
};
timer.Stop();
Config.Log.Debug($"Local compat list search time: {timer.ElapsedMilliseconds} ms");
return result;
}
private static IEnumerable<string> FormatSearchResults(SlashCommandContext ctx, CompatResult compatResult)
{
var returnCode = ApiConfig.ReturnCodes[compatResult.ReturnCode];
var request = compatResult.RequestBuilder;
if (returnCode.overrideAll)
yield return string.Format(returnCode.info, ctx.User.Mention);
else
{
var authorMention = ctx.Channel.IsPrivate ? "You" : ctx.User.Mention;
var result = new StringBuilder();
result.AppendLine($"{authorMention} searched for: ***{request.Search?.Sanitize(replaceBackTicks: true)}***");
if (request.Search?.Contains("persona", StringComparison.InvariantCultureIgnoreCase) is true
|| request.Search?.Contains("p5", StringComparison.InvariantCultureIgnoreCase) is true)
result.AppendLine("Did you try searching for **__Unnamed__** instead?");
result.AppendFormat(returnCode.info, compatResult.SearchTerm);
yield return result.ToString();
result.Clear();
if (!returnCode.displayResults)
yield break;
var sortedList = compatResult.GetSortedList();
var trimmedList = sortedList.Where(i => i.score > 0).ToList();
if (trimmedList.Count > 0)
sortedList = trimmedList;
var searchTerm = request.Search ?? @"¯\\\_(ツ)\_/¯";
var searchHits = sortedList.Where(t => t.score > 0.5
|| (t.info.Title?.StartsWith(searchTerm, StringComparison.InvariantCultureIgnoreCase) ?? false)
|| (t.info.AlternativeTitle?.StartsWith(searchTerm, StringComparison.InvariantCultureIgnoreCase) ?? false));
foreach (var title in searchHits.Select(t => t.info.Title).Distinct())
StatsStorage.IncGameStat(title);
foreach (var resultInfo in sortedList.Take(request.AmountRequested))
{
var info = resultInfo.AsString();
#if DEBUG
info = $"{StringUtils.InvisibleSpacer}`{CompatApiResultUtils.GetScore(request.Search, resultInfo.info):0.000000}` {info}";
#endif
result.AppendLine(info);
}
yield return result.ToString();
}
}
public static string FixGameTitleSearch(string title)
{
title = title.Trim(80);
if (title.Equals("persona 5", StringComparison.InvariantCultureIgnoreCase)
|| title.Equals("p5", StringComparison.InvariantCultureIgnoreCase))
title = "unnamed";
else if (title.Equals("nnk", StringComparison.InvariantCultureIgnoreCase))
title = "ni no kuni: wrath of the white witch";
else if (title.Contains("mgs4", StringComparison.InvariantCultureIgnoreCase))
title = title.Replace("mgs4", "mgs4gotp", StringComparison.InvariantCultureIgnoreCase);
else if (title.Contains("metal gear solid 4", StringComparison.InvariantCultureIgnoreCase))
title = title.Replace("metal gear solid 4", "mgs4gotp", StringComparison.InvariantCultureIgnoreCase);
else if (title.Contains("lbp", StringComparison.InvariantCultureIgnoreCase))
title = title.Replace("lbp", "littlebigplanet ", StringComparison.InvariantCultureIgnoreCase).TrimEnd();
return title;
}
public static async Task ImportCompatListAsync()
{
var list = await Client.GetCompatListSnapshotAsync(Config.Cts.Token).ConfigureAwait(false);
if (list is null)
return;
await using var db = new ThumbnailDb();
foreach (var kvp in list.Results)
{
var (productCode, info) = kvp;
var dbItem = await db.Thumbnail.FirstOrDefaultAsync(t => t.ProductCode == productCode).ConfigureAwait(false);
if (dbItem is null
&& await Client.GetCompatResultAsync(RequestBuilder.Start().SetSearch(productCode), Config.Cts.Token).ConfigureAwait(false) is {} compatItemSearchResult
&& compatItemSearchResult.Results.TryGetValue(productCode, out var compatItem))
{
dbItem = (await db.Thumbnail.AddAsync(new()
{
ProductCode = productCode,
Name = compatItem.Title,
}).ConfigureAwait(false)).Entity;
}
if (dbItem is null)
{
Config.Log.Debug($"Missing product code {productCode} in {nameof(ThumbnailDb)}");
dbItem = new();
}
if (Enum.TryParse(info.Status, out CompatStatus status))
{
dbItem.CompatibilityStatus = status;
if (info.Date is string d
&& DateTime.TryParseExact(d, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date))
dbItem.CompatibilityChangeDate = date.Ticks;
}
else
Config.Log.Debug($"Failed to parse game compatibility status {info.Status}");
}
await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false);
}
public static async ValueTask ImportMetacriticScoresAsync()
{
var scoreJson = "metacritic_ps3.json";
string json;
if (File.Exists(scoreJson))
json = await File.ReadAllTextAsync(scoreJson).ConfigureAwait(false);
else
{
Config.Log.Warn($"Missing {scoreJson}, trying to get an online copy…");
using var httpClient = HttpClientFactory.Create(new CompressionMessageHandler());
try
{
json = await httpClient.GetStringAsync($"https://raw.githubusercontent.com/RPCS3/discord-bot/master/{scoreJson}").ConfigureAwait(false);
}
catch (Exception e)
{
Config.Log.Warn(e, $"Failed to get online copy of {scoreJson}");
return;
}
}
var scoreList = JsonSerializer.Deserialize<List<Metacritic>>(json) ?? [];
Config.Log.Debug($"Importing {scoreList.Count} Metacritic items");
var duplicates = new List<Metacritic>();
duplicates.AddRange(
scoreList.Where(i => i.Title.StartsWith("Disney") || i.Title.StartsWith("DreamWorks") || i.Title.StartsWith("PlayStation"))
.Select(i => i.WithTitle(i.Title.Split(' ', 2)[1]))
);
duplicates.AddRange(
scoreList.Where(i => i.Title.Contains("A Telltale Game"))
.Select(i => i.WithTitle(i.Title.Substring(0, i.Title.IndexOf("A Telltale Game", StringComparison.Ordinal) - 1).TrimEnd(' ', '-', ':')))
);
duplicates.AddRange(
scoreList.Where(i => i.Title.StartsWith("Ratchet & Clank Future"))
.Select(i => i.WithTitle(i.Title.Replace("Ratchet & Clank Future", "Ratchet & Clank")))
);
duplicates.AddRange(
scoreList.Where(i => i.Title.StartsWith("MLB "))
.Select(i => i.WithTitle($"Major League Baseball {i.Title[4..]}"))
);
duplicates.AddRange(
scoreList.Where(i => i.Title.Contains("HAWX"))
.Select(i => i.WithTitle(i.Title.Replace("HAWX", "H.A.W.X")))
);
await using var db = new ThumbnailDb();
foreach (var mcScore in scoreList.Where(s => s.CriticScore > 0 || s.UserScore > 0))
{
if (Config.Cts.IsCancellationRequested)
return;
var item = db.Metacritic.FirstOrDefault(i => i.Title == mcScore.Title);
if (item == null)
item = (await db.Metacritic.AddAsync(mcScore).ConfigureAwait(false)).Entity;
else
{
item.CriticScore = mcScore.CriticScore;
item.UserScore = mcScore.UserScore;
item.Notes = mcScore.Notes;
}
await db.SaveChangesAsync().ConfigureAwait(false);
var title = mcScore.Title;
var matches = db.Thumbnail
//.Where(t => t.MetacriticId == null)
.AsEnumerable()
.Select(t => (thumb: t, coef: t.Name.GetFuzzyCoefficientCached(title)))
.Where(i => i.coef > 0.90)
.OrderByDescending(i => i.coef)
.ToList();
if (Config.Cts.IsCancellationRequested)
return;
if (matches.Any(m => m.coef > 0.99))
matches = matches.Where(m => m.coef > 0.99).ToList();
else if (matches.Any(m => m.coef > 0.95))
matches = matches.Where(m => m.coef > 0.95).ToList();
if (matches.Count == 0)
{
try
{
var searchResult = await Client.GetCompatResultAsync(RequestBuilder.Start().SetSearch(title), Config.Cts.Token).ConfigureAwait(false);
var compatListMatches = searchResult?.Results
.Select(i => (productCode: i.Key, titleInfo: i.Value, coef: Math.Max(title.GetFuzzyCoefficientCached(i.Value.Title), title.GetFuzzyCoefficientCached(i.Value.AlternativeTitle))))
.Where(i => i.coef > 0.85)
.OrderByDescending(i => i.coef)
.ToList()
?? [];
if (compatListMatches.Any(i => i.coef > 0.99))
compatListMatches = compatListMatches.Where(i => i.coef > 0.99).ToList();
else if (compatListMatches.Any(i => i.coef > 0.95))
compatListMatches = compatListMatches.Where(i => i.coef > 0.95).ToList();
else if (compatListMatches.Any(i => i.coef > 0.90))
compatListMatches = compatListMatches.Where(i => i.coef > 0.90).ToList();
foreach ((string productCode, TitleInfo titleInfo, double coef) in compatListMatches)
{
var dbItem = await db.Thumbnail.FirstOrDefaultAsync(i => i.ProductCode == productCode).ConfigureAwait(false);
if (dbItem is null)
dbItem = (await db.Thumbnail.AddAsync(new()
{
ProductCode = productCode,
Name = titleInfo.Title,
}).ConfigureAwait(false)).Entity;
dbItem.Name = titleInfo.Title;
if (Enum.TryParse(titleInfo.Status, out CompatStatus status))
dbItem.CompatibilityStatus = status;
if (DateTime.TryParseExact(titleInfo.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date))
dbItem.CompatibilityChangeDate = date.Ticks;
matches.Add((dbItem, coef));
}
await db.SaveChangesAsync().ConfigureAwait(false);
}
catch (Exception e)
{
Config.Log.Warn(e);
}
}
matches = matches.Where(i => !TrialNamePattern().IsMatch(i.thumb.Name ?? "")).ToList();
//var bestMatch = matches.FirstOrDefault();
//Config.Log.Trace($"Best title match for [{item.Title}] is [{bestMatch.thumb.Name}] with score {bestMatch.coef:0.0000}");
if (matches.Count > 0)
{
Config.Log.Trace($"Matched metacritic [{item.Title}] to compat titles: {string.Join(", ", matches.Select(m => $"[{m.thumb.Name}]"))}");
foreach (var (thumb, _) in matches)
thumb.Metacritic = item;
await db.SaveChangesAsync().ConfigureAwait(false);
}
else
{
Config.Log.Warn($"Failed to find a single match for metacritic [{item.Title}]");
}
}
}
}