diff --git a/source/Generic/GameEngineChecker/App.xaml b/source/Generic/GameEngineChecker/App.xaml
new file mode 100644
index 0000000000..0e3490bb12
--- /dev/null
+++ b/source/Generic/GameEngineChecker/App.xaml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/GameEngineChecker.cs b/source/Generic/GameEngineChecker/GameEngineChecker.cs
new file mode 100644
index 0000000000..a78f90491f
--- /dev/null
+++ b/source/Generic/GameEngineChecker/GameEngineChecker.cs
@@ -0,0 +1,157 @@
+using GameEngineChecker.Services;
+using GameEngineChecker.ViewModels;
+using GameEngineChecker.Views;
+using Playnite.SDK;
+using Playnite.SDK.Models;
+using Playnite.SDK.Plugins;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace GameEngineChecker
+{
+ public class GameEngineChecker : GenericPlugin
+ {
+ private const string ExtensionName = "Game Engine Checker";
+ private const int PcGamingWikiMaxRequestsPerWindow = 30;
+ private static readonly TimeSpan PcGamingWikiRateLimitWindow = TimeSpan.FromSeconds(60);
+ private static readonly ILogger Logger = LogManager.GetLogger();
+ private readonly Tagger _tagger;
+ private readonly RateLimiter _rateLimiter;
+
+ public override Guid Id { get; } = Guid.Parse("7a21243e-c7cc-4ca7-85bd-f6f96f22e9db");
+
+ public GameEngineChecker(IPlayniteAPI api) : base(api)
+ {
+ Properties = new GenericPluginProperties
+ {
+ HasSettings = false
+ };
+ _tagger = new Tagger(PlayniteApi);
+ _rateLimiter = new RateLimiter(PcGamingWikiRateLimitWindow, PcGamingWikiMaxRequestsPerWindow);
+ }
+
+ public override IEnumerable GetMainMenuItems(GetMainMenuItemsArgs args)
+ {
+ yield return new MainMenuItem()
+ {
+ MenuSection = $"@{ExtensionName}",
+ Description = ResourceProvider.GetString("LOCGame_Engine_Checker_MenuItemAddTagSelectedGamesDescription"),
+ Action = x =>
+ {
+ try
+ {
+ Task.Run(() => AddTagsToGames(PlayniteApi.MainView.SelectedGames.ToList()));
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, "Failure running add task. Should not happen.");
+ }
+ }
+ };
+ }
+
+ private async Task AddTagsToGames(IReadOnlyList games)
+ {
+ if (games.Count == 0)
+ {
+ return;
+ }
+
+ try
+ {
+ var gamesFilter = new GamesFilter(PlayniteApi);
+ var pcGamingWikiLinkProvider = new PcGamingWikiLinkProvider();
+ var pcGamingWikiClient = new PcGamingWikiClient(PlayniteApi);
+ var enginesParser = new EnginesParser();
+
+ var gameEngineCheckerService = new GameEngineCheckerService(
+ PlayniteApi,
+ gamesFilter,
+ _rateLimiter,
+ pcGamingWikiLinkProvider,
+ pcGamingWikiClient,
+ enginesParser,
+ _tagger);
+
+ using (var cancellationTokenSource = new CancellationTokenSource())
+ using (var progressViewModel = ShowProgressDialog(cancellationTokenSource))
+ {
+ void ReportProgressAction(float progress)
+ {
+ PlayniteApi.MainView.UIDispatcher.Invoke(() => progressViewModel.ProgressValue = progress);
+ }
+
+ var addedCount = await gameEngineCheckerService.AddGameEngineTags(games, ReportProgressAction, cancellationTokenSource.Token);
+
+ Logger.Info($"Successfully added game engine to {addedCount} out of {games.Count} games.");
+ PlayniteApi.Notifications.Add(
+ "game_engine_checker__added_count",
+ string.Format(ResourceProvider.GetString("LOCGame_Engine_Checker_ResultsMessage"), addedCount),
+ NotificationType.Info);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, "Failure while adding engines to games.");
+ PlayniteApi.Notifications.Add(
+ "game_engine_checker__add_error",
+ string.Format(ResourceProvider.GetString("LOCGame_Engine_Checker_ResultsErrorMessage"), ex.Message, ex.StackTrace),
+ NotificationType.Error);
+ }
+ }
+
+ private ProgressViewModel ShowProgressDialog(CancellationTokenSource cts)
+ {
+ var progressViewModel = new ProgressViewModel(PlayniteApi, cts);
+ PlayniteApi.MainView.UIDispatcher.Invoke(() =>
+ {
+ var window = ShowDialog(
+ new ProgressView(progressViewModel),
+ 100,
+ 250,
+ ResourceProvider.GetString("LOCGame_Engine_Checker_ProgressTitle"),
+ false,
+ false);
+
+ progressViewModel.SetWindow(window);
+ }
+ );
+
+ return progressViewModel;
+ }
+
+ private Window ShowDialog(UserControl view, double height, double width, string title, bool showMaximizeButton, bool waitToClose)
+ {
+ var window = PlayniteApi.Dialogs.CreateWindow(new WindowCreationOptions()
+ {
+ ShowCloseButton = true,
+ ShowMaximizeButton = showMaximizeButton,
+ ShowMinimizeButton = false,
+ });
+
+ window.Height = height;
+ window.Width = width;
+ window.Title = title;
+
+ window.Content = view;
+ window.Owner = PlayniteApi.Dialogs.GetCurrentAppWindow();
+ window.WindowStartupLocation = WindowStartupLocation.CenterOwner;
+
+ if (waitToClose)
+ {
+ window.ShowDialog();
+ }
+ else
+ {
+ window.Show();
+ }
+
+ return window;
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/GameEngineChecker.csproj b/source/Generic/GameEngineChecker/GameEngineChecker.csproj
new file mode 100644
index 0000000000..2fa1312441
--- /dev/null
+++ b/source/Generic/GameEngineChecker/GameEngineChecker.csproj
@@ -0,0 +1,105 @@
+
+
+
+
+ Debug
+ AnyCPU
+ {CA1D0577-BE3D-422D-808F-262D77A627DF}
+ Library
+ Properties
+ GameEngineChecker
+ GameEngineChecker
+ v4.6.2
+ 512
+ true
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+ packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll
+
+
+ packages\PlayniteSDK.6.15.0\lib\net462\Playnite.SDK.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ProgressView.xaml
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+ MSBuild:Compile
+ Designer
+ PreserveNewest
+
+
+ MSBuild:Compile
+ Designer
+
+
+ Designer
+ MSBuild:Compile
+
+
+
+
+ PreserveNewest
+
+
+
+
+
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/GameEngineChecker.psm1 b/source/Generic/GameEngineChecker/GameEngineChecker.psm1
deleted file mode 100644
index a57905bf18..0000000000
--- a/source/Generic/GameEngineChecker/GameEngineChecker.psm1
+++ /dev/null
@@ -1,205 +0,0 @@
-function GetMainMenuItems
-{
- param(
- $getMainMenuItemsArgs
- )
-
- $ExtensionName = "Game Engine Checker"
-
- $menuItem1 = New-Object Playnite.SDK.Plugins.ScriptMainMenuItem
- $menuItem1.Description = [Playnite.SDK.ResourceProvider]::GetString("LOCGame_Engine_Checker_MenuItemAddTagSelectedGamesDescription")
- $menuItem1.FunctionName = "Add-EngineTag"
- $menuItem1.MenuSection = "@$ExtensionName"
-
- return $menuItem1
-}
-
-function Add-EngineTag
-{
- param(
- $scriptMainMenuItemActionArgs
- )
-
- $ExtensionName = "Game Engine Checker"
- $pcgwApiTemplateSteam = "https://www.pcgamingwiki.com/w/api.php?action=cargoquery&tables=Infobox_game&fields=Engines%2C_pageName%3Dtitle&where=Steam_AppID%20HOLDS%20%22{0}%22&format=json"
- $pcgwApiTemplateGog = "https://www.pcgamingwiki.com/w/api.php?action=cargoquery&tables=Infobox_game&fields=Engines%2C_pageName%3Dtitle&where=GOGcom_ID%20HOLDS%20%22{0}%22&format=json"
- $CountertagAdded = 0
-
- $steamAppListPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'SteamAppList.json'
- if (Test-Path $steamAppListPath)
- {
- $AppListLastWrite = (Get-Item $steamAppListPath).LastWriteTime
- $TimeSpan = New-Timespan -days 1
- if (((Get-Date) - $AppListLastWrite) -gt $TimeSpan)
- {
- Get-SteamAppList $steamAppListPath
- }
- }
- else
- {
- Get-SteamAppList $steamAppListPath
- }
-
- [object]$steamAppList = [System.IO.File]::ReadAllLines($steamAppListPath) | ConvertFrom-Json
-
- $webClient = New-Object System.Net.WebClient
- $webClient.Encoding = [System.Text.Encoding]::UTF8
- foreach ($game in $PlayniteApi.MainView.SelectedGames) {
- if ($null -eq $game.Platforms)
- {
- continue
- }
- else
- {
- $isTargetSpecification = $false
- foreach ($platform in $game.Platforms) {
- if ($platform.Name -eq "PC (Windows)" -or $platform.Name -eq "PC")
- {
- $isTargetSpecification = $true
- break
- }
-
- if ($null -eq $platform.SpecificationId)
- {
- continue
- }
-
- if ($platform.SpecificationId -eq "pc_windows")
- {
- $isTargetSpecification = $true
- break
- }
- }
-
- if ($isTargetSpecification -eq $false)
- {
- $__logger.Info("$ExtensionName - Game `"$($game.name)`" is not a PC game")
- continue
- }
- }
-
- if ($null -ne $game.Tags)
- {
- $engineTagPresent = $false
- foreach ($tag in $game.Tags) {
- if ($tag.Name.StartsWith("[Engine]"))
- {
- $__logger.Info("$ExtensionName - Game `"$($game.name)`" already has engine tag $($tag.Name)")
- $engineTagPresent = $true
- break
- }
- }
-
- if ($engineTagPresent -eq $true)
- {
- continue
- }
- }
-
- $gameLibraryPlugin = [Playnite.SDK.BuiltinExtensions]::GetExtensionFromId($game.PluginId)
- if ($gameLibraryPlugin -eq 'SteamLibrary')
- {
- $uri = $pcgwApiTemplateSteam -f $game.GameId
- }
- elseif ($gameLibraryPlugin -eq 'GogLibrary')
- {
- $uri = $pcgwApiTemplateGog -f $game.GameId
- }
- else
- {
- $steamAppId = $null
- $gameName = $game.name.ToLower() -replace '[^\p{L}\p{Nd}]', ''
- foreach ($steamApp in $steamAppList) {
- if ($steamApp.name -eq $gameName)
- {
- $steamAppId = $steamApp.appid
- break
- }
- }
-
- if ($null -eq $steamAppId)
- {
- $__logger.Info("$ExtensionName - SteamId of `"$($game.name)`" not found.")
- continue
- }
-
- $uri = $pcgwApiTemplateSteam -f $steamAppId
- }
-
- try {
- $downloadedString = $webClient.DownloadString($uri)
- $gameInfo = $DownloadedString | ConvertFrom-Json
- } catch {
- $ErrorMessage = $_.Exception.Message
- $PlayniteApi.Dialogs.ShowErrorMessage(([Playnite.SDK.ResourceProvider]::GetString("LOCGame_Engine_Checker_DownloadErrorMessage") -f $game.name, $ErrorMessage), $ExtensionName)
- break
- }
-
- if ($gameInfo.cargoquery.Count -eq 0)
- {
- $__logger.Info("$ExtensionName - `"$($uri)`" did not produce any results")
- continue
- }
- elseif ($null -eq $gameInfo.cargoquery[0].title.Engines -or [string]::IsNullOrEmpty($gameInfo.cargoquery[0].title.Engines))
- {
- $__logger.Info("$ExtensionName - `"$($uri)`" does not have engine data")
- continue
- }
-
- $engines = $gameInfo.cargoquery[0].title.Engines.Replace("Engine:", "[Engine] ").Split(",")
- $engineName = $engines[0]
-
- $tag = $PlayniteApi.Database.Tags.Add($engineName)
- if ($game.tagIds -notcontains $tag.Id)
- {
- # Add tag Id to game
- if ($game.tagIds)
- {
- $game.tagIds += $tag.Id
- }
- else
- {
- # Fix in case game has null tagIds
- $game.tagIds = $tag.Id
- }
-
- # Update game in database and increase no media count
- $PlayniteApi.Database.Games.Update($game)
- $__logger.Info("$ExtensionName - Added `"$engineName`" engine tag to `"$($game.name)`".")
- $CountertagAdded++
- }
- }
-
- # Show finish dialogue with results
- $webClient.Dispose()
- $PlayniteApi.Dialogs.ShowMessage(([Playnite.SDK.ResourceProvider]::GetString("LOCGame_Engine_Checker_ResultsMessage") -f $CountertagAdded), $ExtensionName)
-}
-
-function Get-SteamAppList
-{
- param (
- [string]$steamAppListPath
- )
-
- $ExtensionName = "Game Engine Checker"
-
- try {
- $uri = 'https://api.steampowered.com/ISteamApps/GetAppList/v2/'
- $webClient = New-Object System.Net.WebClient
- $webClient.Encoding = [System.Text.Encoding]::UTF8
- $downloadedString = $webClient.DownloadString($uri)
- $webClient.Dispose()
- [array]$AppListContent = ($downloadedString | ConvertFrom-Json).applist.apps
- foreach ($steamApp in $AppListContent) {
- $steamApp.name = $steamApp.name.ToLower() -replace '[^\p{L}\p{Nd}]', ''
- }
-
- ConvertTo-Json $AppListContent -Depth 2 -Compress | Out-File -Encoding 'UTF8' -FilePath $steamAppListPath
- $__logger.Info("$ExtensionName - Downloaded AppList")
- } catch {
- $ErrorMessage = $_.Exception.Message
- $__logger.Error("$ExtensionName - Error downloading Steam AppList database. Error: $ErrorMessage")
- $PlayniteApi.Dialogs.ShowErrorMessage(([Playnite.SDK.ResourceProvider]::GetString("LOCGame_Engine_Checker_SteamAppListDownloadErrorMessage") -f $ErrorMessage), $ExtensionName)
- exit
- }
-}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/GameEngineChecker.sln b/source/Generic/GameEngineChecker/GameEngineChecker.sln
new file mode 100644
index 0000000000..1bf3f30d47
--- /dev/null
+++ b/source/Generic/GameEngineChecker/GameEngineChecker.sln
@@ -0,0 +1,31 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.37111.16
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameEngineChecker", "GameEngineChecker.csproj", "{CA1D0577-BE3D-422D-808F-262D77A627DF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameEngineChecker.Tests", "..\..\GenericTests\GameEngineChecker.Tests\GameEngineChecker.Tests.csproj", "{AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {CA1D0577-BE3D-422D-808F-262D77A627DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CA1D0577-BE3D-422D-808F-262D77A627DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CA1D0577-BE3D-422D-808F-262D77A627DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CA1D0577-BE3D-422D-808F-262D77A627DF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {620DAE38-0360-4A4F-A05E-7FE07CD0E592}
+ EndGlobalSection
+EndGlobal
diff --git a/source/Generic/GameEngineChecker/Interfaces/IEnginesParser.cs b/source/Generic/GameEngineChecker/Interfaces/IEnginesParser.cs
new file mode 100644
index 0000000000..7aed97ebdd
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Interfaces/IEnginesParser.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+
+namespace GameEngineChecker.Interfaces
+{
+ public interface IEnginesParser
+ {
+ IReadOnlyCollection Parse(string engines);
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Interfaces/IGamesFilter.cs b/source/Generic/GameEngineChecker/Interfaces/IGamesFilter.cs
new file mode 100644
index 0000000000..944ba6e951
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Interfaces/IGamesFilter.cs
@@ -0,0 +1,9 @@
+using Playnite.SDK.Models;
+
+namespace GameEngineChecker.Interfaces
+{
+ public interface IGamesFilter
+ {
+ bool ShouldTheGameBeProcessed(Game game);
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Interfaces/IPcGamingWikiClient.cs b/source/Generic/GameEngineChecker/Interfaces/IPcGamingWikiClient.cs
new file mode 100644
index 0000000000..8ce5dd7b62
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Interfaces/IPcGamingWikiClient.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Playnite.SDK.Models;
+
+namespace GameEngineChecker.Interfaces
+{
+ public interface IPcGamingWikiClient
+ {
+ Task GetEngines(Uri link, Game game, CancellationToken cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Interfaces/IPcGamingWikiLinkProvider.cs b/source/Generic/GameEngineChecker/Interfaces/IPcGamingWikiLinkProvider.cs
new file mode 100644
index 0000000000..b039319968
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Interfaces/IPcGamingWikiLinkProvider.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Playnite.SDK.Models;
+
+namespace GameEngineChecker.Interfaces
+{
+ public interface IPcGamingWikiLinkProvider
+ {
+ Task GetLink(Game game, CancellationToken cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Interfaces/IRateLimiter.cs b/source/Generic/GameEngineChecker/Interfaces/IRateLimiter.cs
new file mode 100644
index 0000000000..9079acdc13
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Interfaces/IRateLimiter.cs
@@ -0,0 +1,10 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GameEngineChecker.Interfaces
+{
+ public interface IRateLimiter
+ {
+ Task Limit(int batchSize, CancellationToken cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Interfaces/ITagger.cs b/source/Generic/GameEngineChecker/Interfaces/ITagger.cs
new file mode 100644
index 0000000000..8490544053
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Interfaces/ITagger.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using System.Threading;
+using Playnite.SDK.Models;
+
+namespace GameEngineChecker.Interfaces
+{
+ public interface ITagger
+ {
+ void AddEngineTags(Game game, IReadOnlyCollection engines, CancellationToken cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Localization/en_US.xaml b/source/Generic/GameEngineChecker/Localization/en_US.xaml
index dfe12ff872..9dd23f1f02 100644
--- a/source/Generic/GameEngineChecker/Localization/en_US.xaml
+++ b/source/Generic/GameEngineChecker/Localization/en_US.xaml
@@ -1,7 +1,12 @@
+ Cancel
+ Hide
+ Adding game engine tags...
Add game engine tag to selected game(s)
Couldn't download game information of "{0}" from PCGW. Error: {1}
Finished.
Added engine tag to {0} game(s).
+ Failure while adding engines to games: {0}
+{1}
Error downloading Steam AppList database. Error: {0}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Models/PcGamingWiki/CargoQueryItem.cs b/source/Generic/GameEngineChecker/Models/PcGamingWiki/CargoQueryItem.cs
new file mode 100644
index 0000000000..b9e34aa354
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Models/PcGamingWiki/CargoQueryItem.cs
@@ -0,0 +1,7 @@
+namespace GameEngineChecker.Models.PcGamingWiki
+{
+ public class CargoQueryItem
+ {
+ public Infobox Title { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Models/PcGamingWiki/Infobox.cs b/source/Generic/GameEngineChecker/Models/PcGamingWiki/Infobox.cs
new file mode 100644
index 0000000000..83f11c1b48
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Models/PcGamingWiki/Infobox.cs
@@ -0,0 +1,8 @@
+namespace GameEngineChecker.Models.PcGamingWiki
+{
+ public class Infobox
+ {
+ public string Title { get; set; }
+ public string Engines { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Models/PcGamingWiki/PcGamingWikiEngineResponse.cs b/source/Generic/GameEngineChecker/Models/PcGamingWiki/PcGamingWikiEngineResponse.cs
new file mode 100644
index 0000000000..7a99a4f6d8
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Models/PcGamingWiki/PcGamingWikiEngineResponse.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+
+namespace GameEngineChecker.Models.PcGamingWiki
+{
+ public class PcGamingWikiEngineResponse
+ {
+ public List CargoQuery { get; set; }
+ }
+}
diff --git a/source/Generic/GameEngineChecker/Properties/AssemblyInfo.cs b/source/Generic/GameEngineChecker/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..e3769be1e8
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Properties/AssemblyInfo.cs
@@ -0,0 +1,36 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+// General Information about an assembly is controlled through the following
+// set of attributes. Change these attribute values to modify the information
+// associated with an assembly.
+[assembly: AssemblyTitle("GameEngineChecker")]
+[assembly: AssemblyDescription("")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("")]
+[assembly: AssemblyProduct("GameEngineChecker")]
+[assembly: AssemblyCopyright("Copyright © 2019")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+// Setting ComVisible to false makes the types in this assembly not visible
+// to COM components. If you need to access a type in this assembly from
+// COM, set the ComVisible attribute to true on that type.
+[assembly: ComVisible(false)]
+
+// The following GUID is for the ID of the typelib if this project is exposed to COM
+[assembly: Guid("7a21243e-c7cc-4ca7-85bd-f6f96f22e9db")]
+
+// Version information for an assembly consists of the following four values:
+//
+// Major Version
+// Minor Version
+// Build Number
+// Revision
+//
+// You can specify all the values or you can default the Build and Revision Numbers
+// by using the '*' as shown below:
+// [assembly: AssemblyVersion("1.0.*")]
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Services/EnginesParser.cs b/source/Generic/GameEngineChecker/Services/EnginesParser.cs
new file mode 100644
index 0000000000..dff99b1acc
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Services/EnginesParser.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using GameEngineChecker.Interfaces;
+
+namespace GameEngineChecker.Services
+{
+ public class EnginesParser : IEnginesParser
+ {
+ public IReadOnlyCollection Parse(string engines)
+ {
+ return engines
+ .Split(new[] { "Engine:", ",Engine:" }, StringSplitOptions.RemoveEmptyEntries)
+ .Distinct()
+ .ToList();
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Services/GameEngineCheckerService.cs b/source/Generic/GameEngineChecker/Services/GameEngineCheckerService.cs
new file mode 100644
index 0000000000..20663c920b
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Services/GameEngineCheckerService.cs
@@ -0,0 +1,94 @@
+using GameEngineChecker.Interfaces;
+using Playnite.SDK;
+using Playnite.SDK.Models;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GameEngineChecker.Services
+{
+ public class GameEngineCheckerService
+ {
+ private readonly ILogger _logger = LogManager.GetLogger();
+ private readonly IPlayniteAPI _api;
+ private readonly IGamesFilter _filter;
+ private readonly IRateLimiter _rateLimiter;
+ private readonly IPcGamingWikiLinkProvider _linkProvider;
+ private readonly IPcGamingWikiClient _client;
+ private readonly IEnginesParser _enginesParser;
+ private readonly ITagger _tagger;
+
+ public GameEngineCheckerService(
+ IPlayniteAPI api,
+ IGamesFilter filter,
+ IRateLimiter rateLimiter,
+ IPcGamingWikiLinkProvider linkProvider,
+ IPcGamingWikiClient client,
+ IEnginesParser enginesParser,
+ ITagger tagger)
+ {
+ _api = api;
+ _filter = filter;
+ _rateLimiter = rateLimiter;
+ _linkProvider = linkProvider;
+ _client = client;
+ _enginesParser = enginesParser;
+ _tagger = tagger;
+ }
+
+ public async Task AddGameEngineTags(
+ IReadOnlyList games,
+ Action reportProgress,
+ CancellationToken cancellationToken)
+ {
+ var addedCount = 0;
+ try
+ {
+ using (var _ = _api.Database.BufferedUpdate())
+ {
+ for (var i = 0; i < games.Count; i++)
+ {
+ var game = games[i];
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return addedCount;
+ }
+
+ if (!_filter.ShouldTheGameBeProcessed(game))
+ {
+ continue;
+ }
+
+ var link = await _linkProvider.GetLink(game, cancellationToken);
+ if (link == null)
+ {
+ _logger.Info($"Could not create PC Gaming Wiki link for game {game.Id} - {game.Name}.");
+ continue;
+ }
+
+ await _rateLimiter.Limit(games.Count, cancellationToken);
+ var engines = await _client.GetEngines(link, game, cancellationToken);
+ if (engines == null)
+ {
+ _logger.Info($"No engines found for game {game.Id} - {game.Name}.");
+ continue;
+ }
+
+ var parsedEngines = _enginesParser.Parse(engines);
+ _tagger.AddEngineTags(game, parsedEngines, cancellationToken);
+
+ addedCount++;
+ reportProgress.Invoke(i * 100f / games.Count);
+ }
+ }
+
+ return addedCount;
+ }
+ catch (OperationCanceledException)
+ {
+ return addedCount;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Services/GamesFilter.cs b/source/Generic/GameEngineChecker/Services/GamesFilter.cs
new file mode 100644
index 0000000000..72e721c630
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Services/GamesFilter.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using GameEngineChecker.Interfaces;
+using Playnite.SDK;
+using Playnite.SDK.Models;
+
+namespace GameEngineChecker.Services
+{
+ public class GamesFilter : IGamesFilter
+ {
+ private readonly HashSet _engineTagIds;
+
+ public GamesFilter(IPlayniteAPI api)
+ {
+ _engineTagIds = api.Database.Tags.Where(x => x.Name.StartsWith("[Engine]")).Select(x => x.Id).ToHashSet();
+ }
+
+ public bool ShouldTheGameBeProcessed(Game game)
+ {
+ return game.TagIds?.All(x => !_engineTagIds.Contains(x)) ?? true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Services/PcGamingWikiClient.cs b/source/Generic/GameEngineChecker/Services/PcGamingWikiClient.cs
new file mode 100644
index 0000000000..c218701b78
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Services/PcGamingWikiClient.cs
@@ -0,0 +1,83 @@
+using GameEngineChecker.Interfaces;
+using GameEngineChecker.Models.PcGamingWiki;
+using Newtonsoft.Json;
+using Playnite.SDK;
+using Playnite.SDK.Models;
+using System;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GameEngineChecker.Services
+{
+ public class PcGamingWikiClient : IPcGamingWikiClient, IDisposable
+ {
+ private readonly IPlayniteAPI _api;
+ private readonly ILogger _logger = LogManager.GetLogger();
+ private readonly HttpClient _httpClient;
+
+ public PcGamingWikiClient(IPlayniteAPI api)
+ {
+ _api = api;
+ _httpClient = new HttpClient();
+ }
+
+ public async Task GetEngines(Uri link, Game game, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, link);
+ request.Headers.TryAddWithoutValidation("User-Agent", "Playnite.GameEngineChecker Extension 3.x (https://github.com/darklinkpower/PlayniteExtensionsCollection/)");
+
+ var response = await _httpClient.SendAsync(request, cancellationToken);
+ var responseString = await response.Content.ReadAsStringAsync();
+ _logger.Debug($"Response from PC Gaming Wiki: Status: {response.StatusCode}; Body {responseString}");
+
+ response.EnsureSuccessStatusCode();
+ var parsedResponse = ParseResponse(responseString);
+
+ if (parsedResponse?.CargoQuery?.Count > 1)
+ {
+ var foundEntries = string.Join(", ", parsedResponse.CargoQuery.Select(x => $"\"{x.Title?.Title}\""));
+ _logger.Info($"Multiple PC Gaming Wiki entries found for game {game.Id} - {game.Name}: {foundEntries}. Skipping.");
+ return null;
+ }
+
+ var engines = parsedResponse?.CargoQuery?.FirstOrDefault()?.Title?.Engines;
+ if (engines == null)
+ {
+ _logger.Debug($"No engines found in response: {responseString}");
+ }
+
+ return engines;
+ }
+ catch (Exception ex)
+ {
+ if (!(ex is OperationCanceledException))
+ {
+ _logger.Error(ex, $"Error while getting engines via {link}");
+ _api.Notifications.Add("game_engine_checker__pcgw_error_message",
+ string.Format(
+ ResourceProvider.GetString("LOCGame_Engine_Checker_PcgwDownloadErrorMessage"),
+ game.Name,
+ ex.Message),
+ NotificationType.Error);
+ }
+
+ return null;
+ }
+ }
+
+ public void Dispose()
+ {
+ _httpClient.Dispose();
+ }
+
+ private PcGamingWikiEngineResponse ParseResponse(string responseContent)
+ {
+ var importResponse = JsonConvert.DeserializeObject(responseContent);
+ return importResponse;
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Services/PcGamingWikiLinkProvider.cs b/source/Generic/GameEngineChecker/Services/PcGamingWikiLinkProvider.cs
new file mode 100644
index 0000000000..644b59ae77
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Services/PcGamingWikiLinkProvider.cs
@@ -0,0 +1,70 @@
+using GameEngineChecker.Interfaces;
+using Playnite.SDK.Models;
+using System;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GameEngineChecker.Services
+{
+ public class PcGamingWikiLinkProvider : IPcGamingWikiLinkProvider
+ {
+ private const string UrlBase = "https://www.pcgamingwiki.com/w/api.php?action=cargoquery&format=json&tables=Infobox_game&fields=Engines,_pageName=title&where=";
+ private readonly Regex _steamLinkRegex = new Regex(@"store\.steampowered\.com/app/(?\d+)", RegexOptions.Compiled);
+ private readonly Regex _wikipediaLinkRegex = new Regex(@"wikipedia\.org/wiki/(?[^/]+)", RegexOptions.Compiled);
+
+ public Task GetLink(Game game, CancellationToken cancellationToken)
+ {
+ if (game.PluginId == Guid.Parse("CB91DFC9-B977-43BF-8E70-55F46E410FAB")) // Steam
+ {
+ return Task.FromResult(GetSteamGameLink(game.GameId));
+ }
+
+ if (game.PluginId == Guid.Parse("AEBE8B7C-6DC3-4A66-AF31-E7375C6B5E9E") // GOG
+ || game.PluginId == Guid.Parse("03689811-3F33-4DFB-A121-2EE168FB9A5C")) // GOG OSS
+ {
+ return Task.FromResult(GetGogGameLink(game.GameId));
+ }
+
+ if (game.Links == null)
+ {
+ return Task.FromResult(null);
+ }
+
+ foreach (var link in game.Links)
+ {
+ var steamMatch = _steamLinkRegex.Match(link.Url);
+ if (steamMatch.Success)
+ {
+ var gameId = steamMatch.Groups["appId"].Value;
+ return Task.FromResult(GetSteamGameLink(gameId));
+ }
+
+ var wikipediaMatch = _wikipediaLinkRegex.Match(link.Url);
+ if (wikipediaMatch.Success)
+ {
+ var pageName = wikipediaMatch.Groups["pageName"].Value;
+ return Task.FromResult(GetWikipediaGameLink(pageName));
+ }
+ }
+
+ return Task.FromResult(null);
+ }
+
+ private static Uri GetSteamGameLink(string gameId)
+ {
+ return new Uri($@"{UrlBase}Steam_AppID HOLDS ""{gameId}""");
+ }
+
+ private static Uri GetGogGameLink(string gameId)
+ {
+ return new Uri($@"{UrlBase}GOGcom_ID HOLDS ""{gameId}""");
+ }
+
+ private static Uri GetWikipediaGameLink(string pageName)
+ {
+ var pcWikiPageName = pageName.Replace('_', ' ');
+ return new Uri($@"{UrlBase}Wikipedia=""{pcWikiPageName}""");
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Services/RateLimiter.cs b/source/Generic/GameEngineChecker/Services/RateLimiter.cs
new file mode 100644
index 0000000000..ad0206ecba
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Services/RateLimiter.cs
@@ -0,0 +1,77 @@
+using GameEngineChecker.Interfaces;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GameEngineChecker.Services
+{
+ public class RateLimiter : IRateLimiter
+ {
+ private readonly TimeSpan _rateLimitWindow;
+ private readonly int _maxRequestsPerWindow;
+ private readonly SemaphoreSlim _semaphore;
+ private DateTime _firstExecutionInWindow = DateTime.MinValue;
+ private int _totalExecutionInWindow;
+
+ public RateLimiter(TimeSpan rateLimitWindow, int maxRequestsPerWindow)
+ {
+ _rateLimitWindow = rateLimitWindow;
+ _maxRequestsPerWindow = maxRequestsPerWindow;
+ _semaphore = new SemaphoreSlim(1, 1);
+ }
+
+ public async Task Limit(int batchSize, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _semaphore.WaitAsync(cancellationToken);
+
+ await SpreadExecutionsIfBatchLargerThanMaxPerWindow(batchSize, cancellationToken);
+ ResetWindowIfWindowEnded();
+ await WaitForNewWindowIfMaxExecutionsReached(cancellationToken);
+
+ _totalExecutionInWindow++;
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+
+ private DateTime ExecutionWindowEnd => _firstExecutionInWindow + _rateLimitWindow;
+
+ private async Task SpreadExecutionsIfBatchLargerThanMaxPerWindow(int batchSize, CancellationToken cancellationToken)
+ {
+ if (batchSize > _maxRequestsPerWindow)
+ {
+ var delayMilliseconds = _rateLimitWindow.TotalMilliseconds / _maxRequestsPerWindow;
+ await Task.Delay(TimeSpan.FromMilliseconds(delayMilliseconds), cancellationToken);
+ }
+ }
+
+ private void ResetWindowIfWindowEnded()
+ {
+ if (DateTime.UtcNow > ExecutionWindowEnd)
+ {
+ ResetWindow();
+ }
+ }
+
+ private async Task WaitForNewWindowIfMaxExecutionsReached(CancellationToken cancellationToken)
+ {
+ if (_totalExecutionInWindow >= _maxRequestsPerWindow)
+ {
+ var timeLeftInWindow = ExecutionWindowEnd - DateTime.UtcNow;
+ await Task.Delay(timeLeftInWindow, cancellationToken);
+
+ ResetWindow();
+ }
+ }
+
+ private void ResetWindow()
+ {
+ _firstExecutionInWindow = DateTime.UtcNow;
+ _totalExecutionInWindow = 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Services/Tagger.cs b/source/Generic/GameEngineChecker/Services/Tagger.cs
new file mode 100644
index 0000000000..eb9c058435
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Services/Tagger.cs
@@ -0,0 +1,64 @@
+using GameEngineChecker.Interfaces;
+using Playnite.SDK;
+using Playnite.SDK.Models;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+
+namespace GameEngineChecker.Services
+{
+ public class Tagger : ITagger
+ {
+ private const string TagPrefix = "[Engine]";
+
+ private readonly ILogger _logger = LogManager.GetLogger();
+ private readonly IPlayniteAPI _api;
+ private readonly SemaphoreSlim _semaphore;
+
+ public Tagger(IPlayniteAPI api)
+ {
+ _api = api;
+ _semaphore = new SemaphoreSlim(1, 1);
+ }
+
+ public void AddEngineTags(Game game, IReadOnlyCollection engines, CancellationToken cancellationToken)
+ {
+ try
+ {
+ _semaphore.Wait(cancellationToken);
+ var tagNames = engines.Select(x => $"{TagPrefix} {x}").ToList();
+ var tags = _api.Database.Tags.Add(tagNames).ToList();
+
+ var newTagsIdsForGame = tags
+ .Select(x => x.Id)
+ .Except(game.TagIds ?? new List())
+ .ToList();
+
+ if (newTagsIdsForGame.Count == 0)
+ {
+ return;
+ }
+
+ if (game.TagIds == null)
+ {
+ game.TagIds = new List();
+ }
+
+ game.TagIds.AddRange(newTagsIdsForGame);
+ _api.Database.Games.Update(game);
+
+ var addedTagNames = newTagsIdsForGame
+ .Select(id => tags.FirstOrDefault(x => x.Id == id))
+ .Where(x => x != null)
+ .Select(x => x.Name);
+
+ _logger.Info($"Added game engine(s) {string.Join(", ", addedTagNames)} to {game.Name}");
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/ViewModels/ProgressViewModel.cs b/source/Generic/GameEngineChecker/ViewModels/ProgressViewModel.cs
new file mode 100644
index 0000000000..50fcdd9ab2
--- /dev/null
+++ b/source/Generic/GameEngineChecker/ViewModels/ProgressViewModel.cs
@@ -0,0 +1,49 @@
+using Playnite.SDK;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Windows;
+using System.Windows.Input;
+
+namespace GameEngineChecker.ViewModels
+{
+ public class ProgressViewModel : ObservableObject, IDisposable
+ {
+ private readonly IPlayniteAPI _api;
+ private readonly CancellationTokenSource _cts;
+ private float _progressValue;
+ private Window _window;
+
+ public ProgressViewModel(IPlayniteAPI api, CancellationTokenSource cts)
+ {
+ _api = api;
+ _cts = cts;
+ }
+
+ public void SetWindow(Window window)
+ {
+ _window = window;
+ }
+
+ public float ProgressValue
+ {
+ get => _progressValue;
+ set => SetValue(ref _progressValue, value);
+ }
+
+ public ICommand Hide => new RelayCommand(CloseWindow);
+
+ public ICommand Cancel => new RelayCommand(() => _cts.Cancel());
+
+ public void Dispose()
+ {
+ CloseWindow();
+ _cts.Dispose();
+ }
+
+ private void CloseWindow()
+ {
+ _api.MainView.UIDispatcher.Invoke(() => _window?.Close());
+ }
+ }
+}
diff --git a/source/Generic/GameEngineChecker/Views/ProgressView.xaml b/source/Generic/GameEngineChecker/Views/ProgressView.xaml
new file mode 100644
index 0000000000..0a39ac504d
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Views/ProgressView.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/Views/ProgressView.xaml.cs b/source/Generic/GameEngineChecker/Views/ProgressView.xaml.cs
new file mode 100644
index 0000000000..89928d8297
--- /dev/null
+++ b/source/Generic/GameEngineChecker/Views/ProgressView.xaml.cs
@@ -0,0 +1,17 @@
+using GameEngineChecker.ViewModels;
+using System.Windows.Controls;
+
+namespace GameEngineChecker.Views
+{
+ ///
+ /// Interaction logic for ProgressView.xaml
+ ///
+ public partial class ProgressView : UserControl
+ {
+ public ProgressView(ProgressViewModel progressViewModel)
+ {
+ InitializeComponent();
+ DataContext = progressViewModel;
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Generic/GameEngineChecker/extension.yaml b/source/Generic/GameEngineChecker/extension.yaml
index 8ae747bed3..12d8615d93 100644
--- a/source/Generic/GameEngineChecker/extension.yaml
+++ b/source/Generic/GameEngineChecker/extension.yaml
@@ -1,9 +1,9 @@
-Id: Game_Engine_Checker_7a21243e-c7cc-4ca7-85bd-f6f96f22e9db
+Id: Game_Engine_Checker_7a21243e-c7cc-4ca7-85bd-f6f96f22e9db
Name: Game Engine Checker
Author: darklinkpower
-Version: 2.13
-Module: GameEngineChecker.psm1
-Type: Script
+Version: 3.0
+Module: GameEngineChecker.dll
+Type: GenericPlugin
Icon: icon.jpg
Links:
- Name: GitHub
diff --git a/source/Generic/GameEngineChecker/packages.config b/source/Generic/GameEngineChecker/packages.config
new file mode 100644
index 0000000000..9bc0f3f063
--- /dev/null
+++ b/source/Generic/GameEngineChecker/packages.config
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/source/GenericTests/GameEngineChecker.Tests/GameEngineChecker.Tests.csproj b/source/GenericTests/GameEngineChecker.Tests/GameEngineChecker.Tests.csproj
new file mode 100644
index 0000000000..804ebca0f5
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/GameEngineChecker.Tests.csproj
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+ Debug
+ AnyCPU
+ {AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}
+ Library
+ Properties
+ GameEngineChecker.Tests
+ GameEngineChecker.Tests
+ v4.6.2
+ 512
+ {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}
+ 15.0
+ $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)
+ $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages
+ False
+ UnitTest
+
+
+
+
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+ ..\..\Generic\GameEngineChecker\packages\AutoFixture.4.18.1\lib\net452\AutoFixture.dll
+
+
+ ..\..\Generic\GameEngineChecker\packages\AutoFixture.AutoFakeItEasy.4.18.1\lib\net462\AutoFixture.AutoFakeItEasy.dll
+
+
+ ..\..\Generic\GameEngineChecker\packages\Castle.Core.5.1.1\lib\net462\Castle.Core.dll
+
+
+ ..\..\Generic\GameEngineChecker\packages\FakeItEasy.8.3.0\lib\net462\FakeItEasy.dll
+
+
+ ..\..\Generic\GameEngineChecker\packages\Fare.2.1.1\lib\net35\Fare.dll
+
+
+ ..\..\Generic\GameEngineChecker\packages\PlayniteSDK.6.15.0\lib\net462\Playnite.SDK.dll
+
+
+
+
+
+
+ ..\..\packages\xunit.abstractions.2.0.3\lib\net35\xunit.abstractions.dll
+
+
+ ..\..\packages\xunit.assert.2.4.1\lib\netstandard1.1\xunit.assert.dll
+
+
+ ..\..\packages\xunit.extensibility.core.2.4.1\lib\net452\xunit.core.dll
+
+
+ ..\..\packages\xunit.extensibility.execution.2.4.1\lib\net452\xunit.execution.desktop.dll
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {4FDF1E89-5BC3-4C72-8FDA-0D580E7A5D5F}
+ GameEngineChecker
+
+
+
+
+
+
+ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/source/GenericTests/GameEngineChecker.Tests/Properties/AssemblyInfo.cs b/source/GenericTests/GameEngineChecker.Tests/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..e41ea80614
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/Properties/AssemblyInfo.cs
@@ -0,0 +1,20 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+[assembly: AssemblyTitle("GameEngineChecker.Tests")]
+[assembly: AssemblyDescription("")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("")]
+[assembly: AssemblyProduct("GameEngineChecker.Tests")]
+[assembly: AssemblyCopyright("Copyright © 2026")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+[assembly: ComVisible(false)]
+
+[assembly: Guid("ac1fed92-44d4-4527-8de9-af4120db3ef1")]
+
+// [assembly: AssemblyVersion("1.0.*")]
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/source/GenericTests/GameEngineChecker.Tests/Services/EnginesParserTests.cs b/source/GenericTests/GameEngineChecker.Tests/Services/EnginesParserTests.cs
new file mode 100644
index 0000000000..e12be3680b
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/Services/EnginesParserTests.cs
@@ -0,0 +1,63 @@
+using AutoFixture;
+using AutoFixture.AutoFakeItEasy;
+using GameEngineChecker.Services;
+using Xunit;
+
+namespace GameEngineChecker.Tests.Services
+{
+ public class EnginesParserTests
+ {
+ private readonly EnginesParser _sut;
+
+ public EnginesParserTests()
+ {
+ var fixture = new Fixture();
+ fixture.Customize(new AutoFakeItEasyCustomization());
+
+ _sut = fixture.Create();
+ }
+
+ [Fact]
+ public void Parse_ReturnsSingleEngine_WhenSingleEngine()
+ {
+ // Arrange
+ var engines = "Engine:Unity";
+
+ // Act
+ var parsedEngines = _sut.Parse(engines);
+
+ // Assert
+ var engine = Assert.Single(parsedEngines);
+ Assert.Equal("Unity", engine);
+ }
+
+ [Fact]
+ public void Parse_ReturnsTwoEngines_WhenTwoDifferentEngines()
+ {
+ // Arrange
+ var engines = "Engine:Unreal Engine 5,Engine:Gamebryo (TES Engine)";
+
+ // Act
+ var parsedEngines = _sut.Parse(engines);
+
+ // Assert
+ Assert.Equal(2, parsedEngines.Count);
+ Assert.Contains("Unreal Engine 5", parsedEngines);
+ Assert.Contains("Gamebryo (TES Engine)", parsedEngines);
+ }
+
+ [Fact]
+ public void Parse_ReturnsSingleEngine_WhenSeveralIdenticalEngines()
+ {
+ // Arrange
+ var engines = "Engine:Unity,Engine:Unity,Engine:Unity";
+
+ // Act
+ var parsedEngines = _sut.Parse(engines);
+
+ // Assert
+ var engine = Assert.Single(parsedEngines);
+ Assert.Equal("Unity", engine);
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/GenericTests/GameEngineChecker.Tests/Services/GameEngineCheckerServiceTests.cs b/source/GenericTests/GameEngineChecker.Tests/Services/GameEngineCheckerServiceTests.cs
new file mode 100644
index 0000000000..5dd3571d66
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/Services/GameEngineCheckerServiceTests.cs
@@ -0,0 +1,92 @@
+using AutoFixture;
+using AutoFixture.AutoFakeItEasy;
+using FakeItEasy;
+using Playnite.SDK.Models;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GameEngineChecker.Interfaces;
+using GameEngineChecker.Services;
+using Xunit;
+
+namespace GameEngineChecker.Tests.Services
+{
+ public class GameEngineCheckerServiceTests
+ {
+ private readonly Fixture _fixture;
+ private readonly GameEngineCheckerService _sut;
+ private readonly IGamesFilter _gamesFilter;
+ private readonly IPcGamingWikiLinkProvider _pcGamingWikiLinkProvider;
+ private readonly IPcGamingWikiClient _pcGamingWikiClient;
+ private readonly IEnginesParser _enginesParser;
+ private readonly ITagger _tagger;
+
+ public GameEngineCheckerServiceTests()
+ {
+ _fixture = new Fixture();
+ _fixture.Customize(new AutoFakeItEasyCustomization());
+
+ _gamesFilter = _fixture.Freeze();
+ _pcGamingWikiLinkProvider = _fixture.Freeze();
+ _pcGamingWikiClient = _fixture.Freeze();
+ _enginesParser = _fixture.Freeze();
+ _tagger = _fixture.Freeze();
+ _sut = _fixture.Create();
+ }
+
+ [Fact]
+ public async Task AddGameEngineTags_AddsEngineTags_WhenTagsShouldBeAdded()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ var link = _fixture.Create();
+ var engines = "Engine:Unity";
+ var parsedEngines = new List { "Unity" };
+ SetupSuccessfulRun(game, link, engines, parsedEngines);
+
+ // Act
+ await _sut.AddGameEngineTags(new List { game }, x => { }, CancellationToken.None);
+
+ // Assert
+ A.CallTo(() => _tagger.AddEngineTags(game, parsedEngines, CancellationToken.None)).MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task AddGameEngineTags_DoesNotGenerateLink_WhenTagsShouldNotBeAdded()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ A.CallTo(() => _gamesFilter.ShouldTheGameBeProcessed(game)).Returns(false);
+
+ // Act
+ await _sut.AddGameEngineTags(new List { game }, x => { }, CancellationToken.None);
+
+ // Assert
+ A.CallTo(() => _pcGamingWikiLinkProvider.GetLink(A._, CancellationToken.None)).MustNotHaveHappened();
+ }
+
+ [Fact]
+ public async Task AddGameEngineTags_DoesNotCallPcGamingWiki_WhenLinkCouldNotBeGenerated()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ A.CallTo(() => _gamesFilter.ShouldTheGameBeProcessed(game)).Returns(true);
+ A.CallTo(() => _pcGamingWikiLinkProvider.GetLink(game, A._)).Returns(null);
+
+ // Act
+ await _sut.AddGameEngineTags(new List { game }, x => { }, CancellationToken.None);
+
+ // Assert
+ A.CallTo(() => _pcGamingWikiClient.GetEngines(A._, A._, A._)).MustNotHaveHappened();
+ }
+
+ private void SetupSuccessfulRun(Game game, Uri link, string engines, List parsedEngines)
+ {
+ A.CallTo(() => _gamesFilter.ShouldTheGameBeProcessed(game)).Returns(true);
+ A.CallTo(() => _pcGamingWikiLinkProvider.GetLink(game, A._)).Returns(link);
+ A.CallTo(() => _pcGamingWikiClient.GetEngines(link, A._, A._)).Returns(engines);
+ A.CallTo(() => _enginesParser.Parse(engines)).Returns(parsedEngines);
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/GenericTests/GameEngineChecker.Tests/Services/GamesFilterTests.cs b/source/GenericTests/GameEngineChecker.Tests/Services/GamesFilterTests.cs
new file mode 100644
index 0000000000..88c227a5c5
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/Services/GamesFilterTests.cs
@@ -0,0 +1,77 @@
+using AutoFixture;
+using AutoFixture.AutoFakeItEasy;
+using FakeItEasy;
+using Playnite.SDK;
+using Playnite.SDK.Models;
+using System.Collections.Generic;
+using GameEngineChecker.Services;
+using Xunit;
+
+namespace GameEngineChecker.Tests.Services
+{
+ public class GamesFilterTests
+ {
+ private readonly Fixture _fixture;
+ private readonly GamesFilter _sut;
+ private readonly Tag _engineTag;
+
+ public GamesFilterTests()
+ {
+ _fixture = new Fixture();
+ _fixture.Customize(new AutoFakeItEasyCustomization());
+
+ var api = _fixture.Freeze();
+
+ var tags = new TestableItemCollection(new List());
+
+ _engineTag = _fixture.Create();
+ _engineTag.Name = "[Engine] Unity";
+ tags.Add(_engineTag);
+
+ A.CallTo(() => api.Database.Tags).Returns(tags);
+
+ _sut = _fixture.Create();
+ }
+
+ [Fact]
+ public void ShouldTheGameBeProcessed_ReturnsTrue_WhenGameHasNoEngineTags()
+ {
+ // Arrange
+ var game = _fixture.Create();
+
+ // Act
+ var result = _sut.ShouldTheGameBeProcessed(game);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void ShouldTheGameBeProcessed_ReturnsTrue_WhenGameHasNoTags()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ game.TagIds = null;
+
+ // Act
+ var result = _sut.ShouldTheGameBeProcessed(game);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void ShouldTheGameBeProcessed_ReturnsFalse_WhenGameHasAnyEngineTag()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ game.TagIds.Add(_engineTag.Id);
+
+ // Act
+ var result = _sut.ShouldTheGameBeProcessed(game);
+
+ // Assert
+ Assert.False(result);
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/GenericTests/GameEngineChecker.Tests/Services/PcGamingWikiLinkProviderTests.cs b/source/GenericTests/GameEngineChecker.Tests/Services/PcGamingWikiLinkProviderTests.cs
new file mode 100644
index 0000000000..ad9f1a150e
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/Services/PcGamingWikiLinkProviderTests.cs
@@ -0,0 +1,114 @@
+using AutoFixture;
+using AutoFixture.AutoFakeItEasy;
+using Playnite.SDK.Models;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using GameEngineChecker.Services;
+using Xunit;
+
+namespace GameEngineChecker.Tests.Services
+{
+ public class PcGamingWikiLinkProviderTests
+ {
+ private readonly Fixture _fixture;
+ private readonly PcGamingWikiLinkProvider _sut;
+
+ public PcGamingWikiLinkProviderTests()
+ {
+ _fixture = new Fixture();
+ _fixture.Customize(new AutoFakeItEasyCustomization());
+
+ _sut = _fixture.Create();
+ }
+
+ [Fact]
+ public async Task GetLink_UseGameId_WhenGameIsSteamGame()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ game.PluginId = Guid.Parse("CB91DFC9-B977-43BF-8E70-55F46E410FAB");
+
+ // Act
+ var result = await _sut.GetLink(game, CancellationToken.None);
+
+ // Assert
+ Assert.Equal(new Uri($@"https://www.pcgamingwiki.com/w/api.php?action=cargoquery&format=json&tables=Infobox_game&fields=Engines,_pageName=title&where=Steam_AppID HOLDS ""{game.GameId}"""), result);
+ }
+
+ [Theory]
+ [InlineData("AEBE8B7C-6DC3-4A66-AF31-E7375C6B5E9E")] // GOG
+ [InlineData("03689811-3F33-4DFB-A121-2EE168FB9A5C")] // GOG OSS
+ public async Task GetLink_UseGameId_WhenGameIsGogGame(string pluginId)
+ {
+ // Arrange
+ var game = _fixture.Create();
+ game.PluginId = Guid.Parse(pluginId);
+
+ // Act
+ var result = await _sut.GetLink(game, CancellationToken.None);
+
+ // Assert
+ Assert.Equal(new Uri($@"https://www.pcgamingwiki.com/w/api.php?action=cargoquery&format=json&tables=Infobox_game&fields=Engines,_pageName=title&where=GOGcom_ID HOLDS ""{game.GameId}"""), result);
+ }
+
+ [Theory]
+ [InlineData("Steam", "https://store.steampowered.com/app/3634520/Samson")]
+ [InlineData("LINK!", "store.steampowered.com/app/3634520")]
+ public async Task GetLink_UseSteamLink_WhenGameIsNonSteamNonGogGameAndHasSteamLink(string name, string url)
+ {
+ // Arrange
+ var game = _fixture.Create();
+ game.Links.Add(new Link(name, url));
+
+ // Act
+ var result = await _sut.GetLink(game, CancellationToken.None);
+
+ // Assert
+ Assert.Equal(new Uri($@"https://www.pcgamingwiki.com/w/api.php?action=cargoquery&format=json&tables=Infobox_game&fields=Engines,_pageName=title&where=Steam_AppID HOLDS ""3634520"""), result);
+ }
+
+ [Theory]
+ [InlineData("Wikipedia", "https://en.wikipedia.org/wiki/Need_for_Speed_III:_Hot_Pursuit")]
+ [InlineData("LINK!", "wikipedia.org/wiki/Need_for_Speed_III:_Hot_Pursuit")]
+ public async Task GetLink_UseWikipediaLink_WhenGameIsNonSteamNonGogGameAndHasWikipediaLink(string name, string url)
+ {
+ // Arrange
+ var game = _fixture.Create();
+ game.Links.Add(new Link(name, url));
+
+ // Act
+ var result = await _sut.GetLink(game, CancellationToken.None);
+
+ // Assert
+ Assert.Equal(new Uri($@"https://www.pcgamingwiki.com/w/api.php?action=cargoquery&format=json&tables=Infobox_game&fields=Engines,_pageName=title&where=Wikipedia=""Need for Speed III: Hot Pursuit"""), result);
+ }
+
+ [Fact]
+ public async Task GetLink_ReturnNull_WhenGameIsNonSteamNonGogAndHasNoLinks()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ game.Links = null;
+
+ // Act
+ var result = await _sut.GetLink(game, CancellationToken.None);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task GetLink_ReturnNull_WhenGameLinkNotGenerated()
+ {
+ // Arrange
+ var game = _fixture.Create();
+
+ // Act
+ var result = await _sut.GetLink(game, CancellationToken.None);
+
+ // Assert
+ Assert.Null(result);
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/GenericTests/GameEngineChecker.Tests/Services/RateLimiterTests.cs b/source/GenericTests/GameEngineChecker.Tests/Services/RateLimiterTests.cs
new file mode 100644
index 0000000000..9dde560e12
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/Services/RateLimiterTests.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using GameEngineChecker.Services;
+using Xunit;
+
+namespace GameEngineChecker.Tests.Services
+{
+ public class RateLimiterTests
+ {
+ [Fact]
+ public async Task Limit_Bursts_WhenBatchIsSmallerThanLimitPerWindow()
+ {
+ // Arrange
+ var expected = 19;
+ var actual = 0;
+ var cts = new CancellationTokenSource(200);
+
+ // Act
+ _ = await Record.ExceptionAsync(() => Task.Run(async () =>
+ {
+ var sut = new RateLimiter(TimeSpan.FromMilliseconds(250), expected);
+ while (true)
+ {
+ await sut.Limit(expected, cts.Token);
+ actual++;
+ }
+ }, cts.Token));
+
+ // Assert
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Fact]
+ public async Task Limit_Steady_WhenBatchIsHigherThanLimitPerWindow()
+ {
+ // Arrange
+ var batchSize = 4;
+ var maxRequestsPerWindow = 2;
+ var windowMilliseconds = 50;
+
+ var cts = new CancellationTokenSource(500);
+
+ // Act
+ var stopwatch = new Stopwatch();
+ stopwatch.Start();
+ await Task.Run(async () =>
+ {
+ var sut = new RateLimiter(TimeSpan.FromMilliseconds(windowMilliseconds), maxRequestsPerWindow);
+ for (var i = 0; i < batchSize; i++)
+ {
+ await sut.Limit(batchSize, cts.Token);
+ }
+ }, cts.Token);
+ stopwatch.Stop();
+
+ // Assert
+ Assert.True(stopwatch.ElapsedMilliseconds > windowMilliseconds * batchSize / maxRequestsPerWindow);
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/GenericTests/GameEngineChecker.Tests/Services/TaggerTests.cs b/source/GenericTests/GameEngineChecker.Tests/Services/TaggerTests.cs
new file mode 100644
index 0000000000..71370e0ebd
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/Services/TaggerTests.cs
@@ -0,0 +1,100 @@
+using AutoFixture;
+using AutoFixture.AutoFakeItEasy;
+using FakeItEasy;
+using Playnite.SDK;
+using Playnite.SDK.Models;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GameEngineChecker.Services;
+using Xunit;
+
+namespace GameEngineChecker.Tests.Services
+{
+ public class TaggerTests
+ {
+ private readonly Fixture _fixture;
+ private readonly IPlayniteAPI _api;
+ private readonly Tagger _sut;
+ private readonly TestableItemCollection _tags;
+
+ public TaggerTests()
+ {
+ _fixture = new Fixture();
+ _fixture.Customize(new AutoFakeItEasyCustomization());
+
+ _api = _fixture.Freeze();
+ _sut = _fixture.Create();
+
+ _tags = new TestableItemCollection(new List());
+
+ A.CallTo(() => _api.Database.Tags).Returns(_tags);
+ }
+
+ [Fact]
+ public void AddEngineTags_AddsNewTagAndUpdatesTheGame_WhenTagDoesNotExist()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ var engines = new List { "Unity" };
+
+ // Act
+ _sut.AddEngineTags(game, engines, CancellationToken.None);
+
+ // Assert
+ var tag = Assert.Single(_api.Database.Tags);
+ Assert.Equal("[Engine] Unity", tag.Name);
+ Assert.Contains(tag.Id, game.TagIds);
+ }
+
+ [Fact]
+ public void AddEngineTags_AddsNewTagAndUpdatesTheGame_WhenGameHasNoTags()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ var engines = new List { "Unity" };
+ game.TagIds = null;
+
+ // Act
+ _sut.AddEngineTags(game, engines, CancellationToken.None);
+
+ // Assert
+ var tag = Assert.Single(_api.Database.Tags);
+ Assert.Equal("[Engine] Unity", tag.Name);
+ Assert.Contains(tag.Id, game.TagIds);
+ }
+
+ [Fact]
+ public void AddEngineTags_DoesNotAddTheTagToTheGame_WhenGameHasTheTag()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ var engines = new List { "Unity" };
+ var tagOnGame = _tags.Add("[Engine] Unity");
+ game.TagIds.Add(tagOnGame.Id);
+
+ // Act
+ _sut.AddEngineTags(game, engines, CancellationToken.None);
+
+ // Assert
+ Assert.Single(game.TagIds.Where(x => x == tagOnGame.Id));
+ }
+
+ [Fact]
+ public void AddEngineTags_PreventsRaceCondition_WhenProcessingInParallel()
+ {
+ // Arrange
+ var game = _fixture.Create();
+ var engines = new List { "Unity" };
+
+ // Act
+ Parallel.For(0, 2, x => _sut.AddEngineTags(game, engines, CancellationToken.None));
+
+ // Assert
+ var tag = Assert.Single(_api.Database.Tags);
+ Assert.Equal("[Engine] Unity", tag.Name);
+ Assert.Contains(tag.Id, game.TagIds);
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/GenericTests/GameEngineChecker.Tests/TestableItemCollection.cs b/source/GenericTests/GameEngineChecker.Tests/TestableItemCollection.cs
new file mode 100644
index 0000000000..cf81f573c8
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/TestableItemCollection.cs
@@ -0,0 +1,190 @@
+using Playnite.SDK;
+using Playnite.SDK.Models;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GameEngineChecker.Tests
+{
+ internal class TestableItemCollection : IItemCollection where T : DatabaseObject, new()
+ {
+ private List _items;
+
+ public TestableItemCollection(List items)
+ {
+ _items = items;
+ }
+
+ public int UpdateCount { get; private set; }
+
+ public void Dispose()
+ {
+ throw new NotImplementedException();
+ }
+
+ public bool ContainsItem(Guid id)
+ {
+ throw new NotImplementedException();
+ }
+
+ public GameDatabaseCollection CollectionType { get; }
+
+ public IEnumerator GetEnumerator()
+ {
+ return _items.GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ return GetEnumerator();
+ }
+
+ public void Add(T item)
+ {
+ _items.Add(item);
+ }
+
+ public void Clear()
+ {
+ throw new NotImplementedException();
+ }
+
+ public bool Contains(T item)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void CopyTo(T[] array, int arrayIndex)
+ {
+ throw new NotImplementedException();
+ }
+
+ public bool Remove(T item)
+ {
+ throw new NotImplementedException();
+ }
+
+ public int Count { get; }
+ public bool IsReadOnly { get; }
+
+ public T Get(Guid id)
+ {
+ throw new NotImplementedException();
+ }
+
+ public List Get(IList ids)
+ {
+ throw new NotImplementedException();
+ }
+
+ public T Add(string itemName)
+ {
+ var alreadyExists = _items.FirstOrDefault(x => x.Name == itemName);
+ if (alreadyExists != null)
+ {
+ return alreadyExists;
+ }
+
+ var toAdd = new T()
+ {
+ Id = Guid.NewGuid(),
+ Name = itemName,
+ };
+
+ _items.Add(toAdd);
+ return toAdd;
+ }
+
+ public T Add(string itemName, Func existingComparer)
+ {
+ throw new NotImplementedException();
+ }
+
+ public IEnumerable Add(List items)
+ {
+ var alreadyExists = _items.Where(x => items.Contains(x.Name)).ToList();
+
+ var toAdd = items
+ .Except(alreadyExists.Select(x => x.Name))
+ .Select(x => new T()
+ {
+ Id = Guid.NewGuid(),
+ Name = x,
+ }).ToList();
+
+ _items.AddRange(toAdd);
+ return toAdd.Concat(alreadyExists);
+ }
+
+ public T Add(MetadataProperty property)
+ {
+ throw new NotImplementedException();
+ }
+
+ public IEnumerable Add(IEnumerable properties)
+ {
+ throw new NotImplementedException();
+ }
+
+ public IEnumerable Add(List items, Func existingComparer)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void Add(IEnumerable items)
+ {
+ throw new NotImplementedException();
+ }
+
+ public bool Remove(Guid id)
+ {
+ throw new NotImplementedException();
+ }
+
+ public bool Remove(IEnumerable items)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void Update(T item)
+ {
+ UpdateCount++;
+ }
+
+ public void Update(IEnumerable items)
+ {
+ throw new NotImplementedException();
+ }
+
+ public IDisposable BufferedUpdate()
+ {
+ throw new NotImplementedException();
+ }
+
+ public void BeginBufferUpdate()
+ {
+ throw new NotImplementedException();
+ }
+
+ public void EndBufferUpdate()
+ {
+ throw new NotImplementedException();
+ }
+
+ public IEnumerable GetClone()
+ {
+ throw new NotImplementedException();
+ }
+
+ public T this[Guid id]
+ {
+ get => throw new NotImplementedException();
+ set => throw new NotImplementedException();
+ }
+
+ public event EventHandler> ItemCollectionChanged;
+
+ public event EventHandler> ItemUpdated;
+ }
+}
\ No newline at end of file
diff --git a/source/GenericTests/GameEngineChecker.Tests/app.config b/source/GenericTests/GameEngineChecker.Tests/app.config
new file mode 100644
index 0000000000..900564731f
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/app.config
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/source/GenericTests/GameEngineChecker.Tests/packages.config b/source/GenericTests/GameEngineChecker.Tests/packages.config
new file mode 100644
index 0000000000..6767c87718
--- /dev/null
+++ b/source/GenericTests/GameEngineChecker.Tests/packages.config
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/source/PlayniteExtensions.sln b/source/PlayniteExtensions.sln
index a475eaa22c..a9fbcecc82 100644
--- a/source/PlayniteExtensions.sln
+++ b/source/PlayniteExtensions.sln
@@ -127,6 +127,9 @@ Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "ThrottlerSharp", "Common\Th
EndProject
Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "WebViewCore", "Common\WebViewCore\WebViewCore.shproj", "{5FBF6DC4-DFDB-4A07-8E73-0EF038F19E16}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameEngineChecker.Tests", "GenericTests\GameEngineChecker.Tests\GameEngineChecker.Tests.csproj", "{AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameEngineChecker", "Generic\GameEngineChecker\GameEngineChecker.csproj", "{CA1D0577-BE3D-422D-808F-262D77A627DF}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DisplayHelper.Tests", "DisplayHelper.Tests\DisplayHelper.Tests.csproj", "{76E46611-35FE-4309-B60C-3FD897061BC7}"
EndProject
Global
@@ -307,6 +310,14 @@ Global
{27F33F55-C75D-4FCD-90C3-13E7DAA5B67E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{27F33F55-C75D-4FCD-90C3-13E7DAA5B67E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{27F33F55-C75D-4FCD-90C3-13E7DAA5B67E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AC1FED92-44D4-4527-8DE9-AF4120DB3EF1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CA1D0577-BE3D-422D-808F-262D77A627DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CA1D0577-BE3D-422D-808F-262D77A627DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CA1D0577-BE3D-422D-808F-262D77A627DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CA1D0577-BE3D-422D-808F-262D77A627DF}.Release|Any CPU.Build.0 = Release|Any CPU
{76E46611-35FE-4309-B60C-3FD897061BC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{76E46611-35FE-4309-B60C-3FD897061BC7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{76E46611-35FE-4309-B60C-3FD897061BC7}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -372,6 +383,8 @@ Global
{A280169A-8BA9-473D-AEED-30502E510BD6} = {82DA706F-BCA9-4D05-912F-E23A589A66C8}
{B3A1FEFC-8C7F-4AA7-9993-B1D4F51222A9} = {82DA706F-BCA9-4D05-912F-E23A589A66C8}
{5FBF6DC4-DFDB-4A07-8E73-0EF038F19E16} = {82DA706F-BCA9-4D05-912F-E23A589A66C8}
+ {AC1FED92-44D4-4527-8DE9-AF4120DB3EF1} = {A94A7FEF-3AA0-42E2-BA9F-A32BD1623476}
+ {CA1D0577-BE3D-422D-808F-262D77A627DF} = {0B392A79-5131-438C-BDB2-AEF170C06F60}
{76E46611-35FE-4309-B60C-3FD897061BC7} = {A94A7FEF-3AA0-42E2-BA9F-A32BD1623476}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution