├── ci ├── release.bat ├── screenshots │ ├── 01.jpg │ ├── 02.jpg │ ├── 03.jpg │ ├── 04.jpg │ ├── 05.jpg │ ├── 01_thumb.jpg │ ├── 02_thumb.jpg │ ├── 03_thumb.jpg │ ├── 04_thumb.jpg │ └── 05_thumb.jpg ├── Release.md ├── Changelog.txt └── installer_manifest.yaml ├── GGDeals ├── icon.png ├── Settings │ ├── IVersionedSettings.cs │ ├── ISettingsMigrator.cs │ ├── VersionedSettings.cs │ ├── IMigratableSettings.cs │ ├── Old │ │ ├── versions.txt │ │ └── SettingsV0.cs │ ├── IPluginSettingsPersistence.cs │ ├── MVVM │ │ ├── GGDealsSettingsView.xaml.cs │ │ ├── LibraryItem.cs │ │ └── GGDealsSettingsViewModel.cs │ ├── PluginSettingsPersistence.cs │ ├── StartupSettingsValidator.cs │ ├── SettingsMigrator.cs │ └── GGDealsSettings.cs ├── Menu │ ├── Failures │ │ ├── File │ │ │ ├── IVersionedFailuresFile.cs │ │ │ ├── VersionedFailuresFile.cs │ │ │ ├── IAddFailuresFileService.cs │ │ │ ├── FailuresFile.cs │ │ │ └── AddFailuresFileService.cs │ │ ├── IAddFailuresManager.cs │ │ ├── MVVM │ │ │ ├── ShowAddFailuresView.xaml.cs │ │ │ ├── FailureItem.cs │ │ │ └── ShowAddFailuresViewModel.cs │ │ └── AddFailuresManager.cs │ └── AddGames │ │ └── MVVM │ │ ├── IViewModelForWindow.cs │ │ ├── AddGamesView.xaml.cs │ │ ├── AddGamesViewModel.cs │ │ └── AddGamesView.xaml ├── Services │ ├── IAddLinkService.cs │ ├── IGameToAddFilter.cs │ ├── IAddResultProcessor.cs │ ├── IGameStatusService.cs │ ├── IAddGamesService.cs │ ├── AddLinkService.cs │ ├── GameToAddFilter.cs │ ├── AddResultProcessor.cs │ ├── GameStatusService.cs │ └── AddGamesService.cs ├── Api │ ├── Models │ │ ├── ImportResponse.cs │ │ ├── ImportResultStatus.cs │ │ ├── ResponseData.cs │ │ ├── ApiException.cs │ │ ├── FailedImportResponse.cs │ │ ├── FailedImportData.cs │ │ ├── ImportResult.cs │ │ ├── ImportRequest.cs │ │ ├── GameWithLauncher.cs │ │ └── GGLauncher.cs │ └── Services │ │ ├── ILibraryToGGLauncherMap.cs │ │ ├── IGameToGameWithLauncherConverter.cs │ │ ├── IRequestDataBatcher.cs │ │ ├── IGGDealsApiClient.cs │ │ ├── GameToGameWithLauncherConverter.cs │ │ ├── RequestDataBatcher.cs │ │ ├── LibraryToGGLauncherMap.cs │ │ └── GGDealsApiClient.cs ├── Queue │ ├── QueueFile.cs │ ├── IQueuePersistence.cs │ ├── QueuePersistence.cs │ └── PersistentProcessingQueue.cs ├── packages.config ├── Models │ ├── AddResult.cs │ ├── AddToCollectionResult.cs │ └── SyncRunSettings.cs ├── extension.yaml ├── Infrastructure │ ├── Converters │ │ ├── BaseConverter.cs │ │ ├── BooleanToCollapsedVisibilityConverter.cs │ │ └── AddResultToLocalizedStringConverter.cs │ └── Helpers │ │ └── EnumHelpers.cs ├── App.xaml ├── Progress │ └── MVVM │ │ ├── ProgressView.xaml.cs │ │ ├── ProgressViewModel.cs │ │ └── ProgressView.xaml ├── Properties │ └── AssemblyInfo.cs └── Localization │ ├── en_US.xaml │ └── ar_SA.xaml ├── ReleaseTools.IntegrationTests ├── Changelog │ ├── TestData │ │ └── expected_release_changelog.md │ ├── ChangelogReaderTests.cs │ └── ReleaseChangelogWriterTests.cs ├── ExtensionYaml │ ├── TestData │ │ ├── extension_after.yaml │ │ └── extension_before.yaml │ └── ExtensionYamlUpdaterTests.cs ├── InstallerManifestYaml │ ├── TestData │ │ ├── before_installer_manifest.yaml │ │ └── after_installer_manifest.yaml │ ├── PlayniteSdkVersionParserTests.cs │ └── InstallerManifestUpdaterTests.cs ├── app.config ├── Properties │ └── AssemblyInfo.cs └── packages.config ├── ReleaseTools ├── IDateTimeProvider.cs ├── Package │ ├── IExtensionPackageNameGuesser.cs │ └── ExtensionPackageNameGuesser.cs ├── DateTimeProvider.cs ├── InstallerManifestYaml │ ├── IPlayniteSdkVersionParser.cs │ ├── InstallerManifestUpdater.cs │ ├── PlayniteSdkVersionParser.cs │ └── InstallerManifestEntryGenerator.cs ├── App.config ├── GitHubTools │ └── AuthStatusParser.cs ├── Changelog │ ├── ChangelogEntry.cs │ ├── ReleaseChangelogWriter.cs │ ├── ChangelogReader.cs │ └── ChangelogParser.cs ├── ExtensionYaml │ └── ExtensionYamlUpdater.cs ├── Properties │ └── AssemblyInfo.cs └── ReleaseTools.csproj ├── TestTools.Shared ├── InlineAutoMoqDataAttribute.cs ├── AutoMoqDataAttribute.cs ├── app.config ├── packages.config ├── Properties │ └── AssemblyInfo.cs ├── MemberAutoMoqDataAttribute.cs └── TestTools.Shared.csproj ├── GGDeals.sln.DotSettings ├── crowdin.yml ├── GGDeals.UnitTests ├── app.config ├── Properties │ └── AssemblyInfo.cs ├── Settings │ ├── Old │ │ └── SettingsV0Tests.cs │ ├── SettingsMigratorTests.cs │ └── StartupSettingsValidatorTests.cs ├── packages.config ├── Api │ └── Services │ │ ├── LibraryToGGLauncherMapTests.cs │ │ ├── GameToGameWithLauncherConverterTests.cs │ │ └── RequestDataBatcherTests.cs ├── Services │ ├── AddLinkServiceTests.cs │ ├── GameToAddFilterTests.cs │ └── PersistentProcessingQueueTests.cs └── TestableItemCollection.cs ├── ReleaseTools.UnitTests ├── app.config ├── Properties │ └── AssemblyInfo.cs ├── Package │ └── ExtensionPackageNameGuesserTests.cs ├── GitHubTools │ └── AuthStatusParserTests.cs ├── packages.config ├── InstallerManifestYaml │ └── InstallerManifestEntryGeneratorTests.cs └── Changelog │ └── ChangelogParserTests.cs ├── GGDeals.IntegrationTests ├── app.config ├── Properties │ └── AssemblyInfo.cs ├── packages.config ├── Queue │ └── QueuePersistenceTests.cs └── Menu │ └── Failures │ └── File │ └── AddFailuresFileServiceTests.cs ├── LICENSE ├── .github └── workflows │ ├── tests.yml │ └── crowdin.yml ├── README.md └── GGDeals.sln /ci/release.bat: -------------------------------------------------------------------------------- 1 | "..\ReleaseTools\bin\Debug\ReleaseTools.exe" -------------------------------------------------------------------------------- /GGDeals/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/GGDeals/icon.png -------------------------------------------------------------------------------- /ci/screenshots/01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/ci/screenshots/01.jpg -------------------------------------------------------------------------------- /ci/screenshots/02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/ci/screenshots/02.jpg -------------------------------------------------------------------------------- /ci/screenshots/03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/ci/screenshots/03.jpg -------------------------------------------------------------------------------- /ci/screenshots/04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/ci/screenshots/04.jpg -------------------------------------------------------------------------------- /ci/screenshots/05.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/ci/screenshots/05.jpg -------------------------------------------------------------------------------- /ReleaseTools.IntegrationTests/Changelog/TestData/expected_release_changelog.md: -------------------------------------------------------------------------------- 1 | - Change 1 2 | - Change 2 3 | - Fix 1 -------------------------------------------------------------------------------- /ci/screenshots/01_thumb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/ci/screenshots/01_thumb.jpg -------------------------------------------------------------------------------- /ci/screenshots/02_thumb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/ci/screenshots/02_thumb.jpg -------------------------------------------------------------------------------- /ci/screenshots/03_thumb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/ci/screenshots/03_thumb.jpg -------------------------------------------------------------------------------- /ci/screenshots/04_thumb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/ci/screenshots/04_thumb.jpg -------------------------------------------------------------------------------- /ci/screenshots/05_thumb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SparrowBrain/Playnite.GGDeals/HEAD/ci/screenshots/05_thumb.jpg -------------------------------------------------------------------------------- /GGDeals/Settings/IVersionedSettings.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Settings 2 | { 3 | public interface IVersionedSettings 4 | { 5 | int Version { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /ReleaseTools/IDateTimeProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace ReleaseTools 4 | { 5 | public interface IDateTimeProvider 6 | { 7 | DateTime Now { get; } 8 | } 9 | } -------------------------------------------------------------------------------- /GGDeals/Menu/Failures/File/IVersionedFailuresFile.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Menu.Failures.File 2 | { 3 | public interface IVersionedFailuresFile 4 | { 5 | int Version { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /GGDeals/Settings/ISettingsMigrator.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Settings 2 | { 3 | public interface ISettingsMigrator 4 | { 5 | GGDealsSettings LoadAndMigrateToNewest(int version); 6 | } 7 | } -------------------------------------------------------------------------------- /GGDeals/Settings/VersionedSettings.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Settings 2 | { 3 | public class VersionedSettings : IVersionedSettings 4 | { 5 | public int Version { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /GGDeals/Services/IAddLinkService.cs: -------------------------------------------------------------------------------- 1 | using Playnite.SDK.Models; 2 | 3 | namespace GGDeals.Services 4 | { 5 | public interface IAddLinkService 6 | { 7 | void AddLink(Game game, string url); 8 | } 9 | } -------------------------------------------------------------------------------- /GGDeals/Settings/IMigratableSettings.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Settings 2 | { 3 | public interface IMigratableSettings : IVersionedSettings 4 | { 5 | IVersionedSettings Migrate(); 6 | } 7 | } -------------------------------------------------------------------------------- /GGDeals/Api/Models/ImportResponse.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Api.Models 2 | { 3 | public class ImportResponse 4 | { 5 | public bool Success { get; set; } 6 | 7 | public ResponseData Data { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /GGDeals/Api/Models/ImportResultStatus.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Api.Models 2 | { 3 | public enum ImportResultStatus 4 | { 5 | Error, 6 | Added, 7 | Skipped, 8 | Miss, 9 | Ignored, 10 | } 11 | } -------------------------------------------------------------------------------- /ReleaseTools/Package/IExtensionPackageNameGuesser.cs: -------------------------------------------------------------------------------- 1 | namespace ReleaseTools.Package 2 | { 3 | public interface IExtensionPackageNameGuesser 4 | { 5 | string GetName(string version); 6 | } 7 | } -------------------------------------------------------------------------------- /GGDeals/Api/Models/ResponseData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace GGDeals.Api.Models 4 | { 5 | public class ResponseData 6 | { 7 | public List Result { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /GGDeals/Menu/Failures/File/VersionedFailuresFile.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Menu.Failures.File 2 | { 3 | public class VersionedFailuresFile : IVersionedFailuresFile 4 | { 5 | public int Version { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /ReleaseTools/DateTimeProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace ReleaseTools 4 | { 5 | internal class DateTimeProvider : IDateTimeProvider 6 | { 7 | public DateTime Now => DateTime.Now; 8 | } 9 | } -------------------------------------------------------------------------------- /ReleaseTools/InstallerManifestYaml/IPlayniteSdkVersionParser.cs: -------------------------------------------------------------------------------- 1 | namespace ReleaseTools.InstallerManifestYaml 2 | { 3 | public interface IPlayniteSdkVersionParser 4 | { 5 | string GetVersion(); 6 | } 7 | } -------------------------------------------------------------------------------- /ReleaseTools/App.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /GGDeals/Queue/QueueFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace GGDeals.Queue 5 | { 6 | public class QueueFile 7 | { 8 | public IReadOnlyCollection GameIds { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /GGDeals/Api/Models/ApiException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace GGDeals.Api.Models 4 | { 5 | public class ApiException : Exception 6 | { 7 | public ApiException(string message) : base(message) 8 | { 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /GGDeals/Menu/AddGames/MVVM/IViewModelForWindow.cs: -------------------------------------------------------------------------------- 1 | using System.Windows; 2 | 3 | namespace GGDeals.Menu.AddGames.MVVM 4 | { 5 | public interface IViewModelForWindow 6 | { 7 | void AssociateWindow(Window window); 8 | } 9 | } -------------------------------------------------------------------------------- /GGDeals/Api/Models/FailedImportResponse.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Api.Models 2 | { 3 | public class FailedImportResponse 4 | { 5 | public bool Success { get; set; } 6 | public FailedImportData Data { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /GGDeals/packages.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /GGDeals/Api/Services/ILibraryToGGLauncherMap.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using GGDeals.Api.Models; 3 | 4 | namespace GGDeals.Api.Services 5 | { 6 | public interface ILibraryToGGLauncherMap 7 | { 8 | GGLauncher GetGGLauncher(Guid pluginId); 9 | } 10 | } -------------------------------------------------------------------------------- /GGDeals/Models/AddResult.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Models 2 | { 3 | public class AddResult 4 | { 5 | public AddToCollectionResult Result { get; set; } 6 | 7 | public string Message { get; set; } 8 | 9 | public string Url { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /GGDeals/Services/IGameToAddFilter.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Models; 2 | using Playnite.SDK.Models; 3 | 4 | namespace GGDeals.Services 5 | { 6 | public interface IGameToAddFilter 7 | { 8 | bool ShouldTryAddGame(Game game, out AddResult status); 9 | } 10 | } -------------------------------------------------------------------------------- /GGDeals/Settings/Old/versions.txt: -------------------------------------------------------------------------------- 1 | v1 2 | AddTagsToGames 3 | SyncNewlyAddedGames 4 | ShowProgressBar 5 | LibraryMapOverride 6 | 7 | v0 8 | AuthenticationToken 9 | LibrariesToSkip 10 | SyncPlayniteLibrary 11 | DevCollectionImportEndpoint 12 | AddLinksToGames -------------------------------------------------------------------------------- /GGDeals/Settings/IPluginSettingsPersistence.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Settings 2 | { 3 | public interface IPluginSettingsPersistence 4 | { 5 | T LoadPluginSettings() where T : class; 6 | 7 | void SavePluginSettings(T settings) where T : class; 8 | } 9 | } -------------------------------------------------------------------------------- /GGDeals/Api/Services/IGameToGameWithLauncherConverter.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using Playnite.SDK.Models; 3 | 4 | namespace GGDeals.Api.Services 5 | { 6 | public interface IGameToGameWithLauncherConverter 7 | { 8 | GameWithLauncher GetGameWithLauncher(Game game); 9 | } 10 | } -------------------------------------------------------------------------------- /GGDeals/Models/AddToCollectionResult.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Models 2 | { 3 | public enum AddToCollectionResult 4 | { 5 | Added, 6 | NotFound, 7 | Synced, 8 | SkippedDueToLibrary, 9 | New, 10 | Error, 11 | Ignored 12 | } 13 | } -------------------------------------------------------------------------------- /ReleaseTools/GitHubTools/AuthStatusParser.cs: -------------------------------------------------------------------------------- 1 | namespace ReleaseTools.GitHubTools 2 | { 3 | public class AuthStatusParser 4 | { 5 | public bool IsUserLoggedIn(string output) 6 | { 7 | return output.Contains("Logged in to github.com"); 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /GGDeals/Api/Services/IRequestDataBatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using GGDeals.Api.Models; 3 | 4 | namespace GGDeals.Api.Services 5 | { 6 | public interface IRequestDataBatcher 7 | { 8 | IEnumerable CreateDataJsons(IReadOnlyCollection games); 9 | } 10 | } -------------------------------------------------------------------------------- /GGDeals/Api/Models/FailedImportData.cs: -------------------------------------------------------------------------------- 1 | namespace GGDeals.Api.Models 2 | { 3 | public class FailedImportData 4 | { 5 | public string Name { get; set; } 6 | public string Message { get; set; } 7 | public int Code { get; set; } 8 | public int Status { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /GGDeals/Queue/IQueuePersistence.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | namespace GGDeals.Queue 6 | { 7 | public interface IQueuePersistence 8 | { 9 | Task Save(IReadOnlyCollection gameIds); 10 | 11 | Task> Load(); 12 | } 13 | } -------------------------------------------------------------------------------- /GGDeals/Api/Models/ImportResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace GGDeals.Api.Models 4 | { 5 | public class ImportResult 6 | { 7 | public Guid Id { get; set; } 8 | 9 | public ImportResultStatus Status { get; set; } 10 | 11 | public string Message { get; set; } 12 | 13 | public string Url { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /GGDeals/Api/Models/ImportRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Text; 3 | using System.Threading.Tasks; 4 | 5 | namespace GGDeals.Api.Models 6 | { 7 | public class ImportRequest 8 | { 9 | public string Version => "v1"; 10 | 11 | public string Token { get; set; } 12 | 13 | public string Data { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /GGDeals/Services/IAddResultProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using GGDeals.Models; 4 | using Playnite.SDK.Models; 5 | 6 | namespace GGDeals.Services 7 | { 8 | public interface IAddResultProcessor 9 | { 10 | void Process(IReadOnlyCollection games, IDictionary results); 11 | } 12 | } -------------------------------------------------------------------------------- /TestTools.Shared/InlineAutoMoqDataAttribute.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture.Xunit2; 2 | 3 | namespace TestTools.Shared 4 | { 5 | public class InlineAutoMoqDataAttribute : InlineAutoDataAttribute 6 | { 7 | public InlineAutoMoqDataAttribute(params object[] values) : base(new AutoMoqDataAttribute(), values) 8 | { 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /GGDeals/Api/Services/IGGDealsApiClient.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using System; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace GGDeals.Api.Services 7 | { 8 | public interface IGGDealsApiClient : IDisposable 9 | { 10 | Task ImportGames(ImportRequest request, CancellationToken ct); 11 | } 12 | } -------------------------------------------------------------------------------- /GGDeals/Settings/MVVM/GGDealsSettingsView.xaml.cs: -------------------------------------------------------------------------------- 1 | using System.Windows.Controls; 2 | using Playnite.SDK; 3 | 4 | namespace GGDeals.Settings.MVVM 5 | { 6 | public partial class GGDealsSettingsView : UserControl 7 | { 8 | public GGDealsSettingsView(IPlayniteAPI api) 9 | { 10 | InitializeComponent(); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /GGDeals/extension.yaml: -------------------------------------------------------------------------------- 1 | Id: SparrowBrain_GGDeals 2 | Name: GG.deals 3 | Author: SparrowBrain 4 | Version: 2.3.0 5 | Module: GGDeals.dll 6 | Type: GenericPlugin 7 | Icon: icon.png 8 | Links: 9 | - Name: GitHub 10 | Url: https://github.com/SparrowBrain/Playnite.GGDeals 11 | - Name: Translate 12 | Url: https://crowdin.com/project/sparrowbrain-playnite-ggdeals -------------------------------------------------------------------------------- /GGDeals/Infrastructure/Converters/BaseConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Windows.Markup; 3 | 4 | namespace GGDeals.Infrastructure.Converters 5 | { 6 | public abstract class BaseConverter : MarkupExtension 7 | { 8 | public override object ProvideValue(IServiceProvider serviceProvider) 9 | { 10 | return this; 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /ReleaseTools/Changelog/ChangelogEntry.cs: -------------------------------------------------------------------------------- 1 | namespace ReleaseTools.Changelog 2 | { 3 | public class ChangelogEntry 4 | { 5 | public ChangelogEntry(string version, string[] changes) 6 | { 7 | Version = version; 8 | Changes = changes; 9 | } 10 | 11 | public string Version { get; } 12 | public string[] Changes { get; } 13 | } 14 | } -------------------------------------------------------------------------------- /GGDeals/Services/IGameStatusService.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Models; 2 | using Playnite.SDK.Models; 3 | using System; 4 | 5 | namespace GGDeals.Services 6 | { 7 | public interface IGameStatusService 8 | { 9 | AddToCollectionResult GetStatus(Game game); 10 | 11 | void UpdateStatus(Game game, AddToCollectionResult status); 12 | 13 | IDisposable BufferedUpdate(); 14 | } 15 | } -------------------------------------------------------------------------------- /GGDeals/Menu/Failures/File/IAddFailuresFileService.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Services; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Threading.Tasks; 5 | using GGDeals.Models; 6 | 7 | namespace GGDeals.Menu.Failures.File 8 | { 9 | public interface IAddFailuresFileService 10 | { 11 | Task> Load(); 12 | 13 | Task Save(Dictionary failures); 14 | } 15 | } -------------------------------------------------------------------------------- /GGDeals.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | GG -------------------------------------------------------------------------------- /ReleaseTools.IntegrationTests/ExtensionYaml/TestData/extension_after.yaml: -------------------------------------------------------------------------------- 1 | Id: SparrowBrain_GGDeals 2 | Name: Play Next 3 | Author: SparrowBrain 4 | Version: 1.2.3 5 | Module: GGDeals.dll 6 | Type: GenericPlugin 7 | Icon: icon.png 8 | Links: 9 | - Name: GitHub 10 | Url: https://github.com/SparrowBrain/Playnite.GGDeals 11 | - Name: Translate 12 | Url: https://crowdin.com/project/sparrowbrain-playnite-ggdeals -------------------------------------------------------------------------------- /ReleaseTools.IntegrationTests/ExtensionYaml/TestData/extension_before.yaml: -------------------------------------------------------------------------------- 1 | Id: SparrowBrain_GGDeals 2 | Name: Play Next 3 | Author: SparrowBrain 4 | Version: 1.0.1 5 | Module: GGDeals.dll 6 | Type: GenericPlugin 7 | Icon: icon.png 8 | Links: 9 | - Name: GitHub 10 | Url: https://github.com/SparrowBrain/Playnite.GGDeals 11 | - Name: Translate 12 | Url: https://crowdin.com/project/sparrowbrain-playnite-ggdeals -------------------------------------------------------------------------------- /TestTools.Shared/AutoMoqDataAttribute.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture; 2 | using AutoFixture.AutoMoq; 3 | using AutoFixture.Xunit2; 4 | 5 | namespace TestTools.Shared 6 | { 7 | public class AutoMoqDataAttribute : AutoDataAttribute 8 | { 9 | public AutoMoqDataAttribute() 10 | : base(() => new Fixture() 11 | .Customize(new AutoMoqCustomization())) 12 | { 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | project_id_env: CROWDIN_PROJECT_ID 2 | api_token_env: CROWDIN_PERSONAL_TOKEN 3 | preserve_hierarchy: true 4 | 5 | 6 | 7 | files: [ 8 | { 9 | "source": "/GGDeals/Localization/en_US.xaml", 10 | "translation": "/GGDeals/Localization/%locale_with_underscore%.xaml", 11 | "escape_quotes": 3, 12 | "escape_special_characters": 0, 13 | "update_option": "update_as_unapproved" 14 | } 15 | ] 16 | -------------------------------------------------------------------------------- /ReleaseTools/Package/ExtensionPackageNameGuesser.cs: -------------------------------------------------------------------------------- 1 | namespace ReleaseTools.Package 2 | { 3 | public class ExtensionPackageNameGuesser : IExtensionPackageNameGuesser 4 | { 5 | public string GetName(string version) 6 | { 7 | var versionNumbers = version.Split('.'); 8 | return $"SparrowBrain_GGDeals_{versionNumbers[0]}_{versionNumbers[1]}_{versionNumbers[2]}.pext"; 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /GGDeals/Menu/AddGames/MVVM/AddGamesView.xaml.cs: -------------------------------------------------------------------------------- 1 | using System.Windows.Controls; 2 | 3 | namespace GGDeals.Menu.AddGames.MVVM 4 | { 5 | /// 6 | /// Interaction logic for AddGames.xaml 7 | /// 8 | public partial class AddGamesView : UserControl 9 | { 10 | public AddGamesView(AddGamesViewModel addGamesViewModel) 11 | { 12 | InitializeComponent(); 13 | DataContext = addGamesViewModel; 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /GGDeals/Menu/Failures/IAddFailuresManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using GGDeals.Models; 5 | using GGDeals.Services; 6 | 7 | namespace GGDeals.Menu.Failures 8 | { 9 | public interface IAddFailuresManager 10 | { 11 | Task AddFailures(IDictionary failures); 12 | Task RemoveFailures(IReadOnlyCollection gameIds); 13 | Task> GetFailures(); 14 | } 15 | } -------------------------------------------------------------------------------- /GGDeals/Services/IAddGamesService.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Models; 2 | using Playnite.SDK.Models; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using GGDeals.Progress.MVVM; 8 | 9 | namespace GGDeals.Services 10 | { 11 | public interface IAddGamesService 12 | { 13 | Task> TryAddToCollection(IReadOnlyCollection games, 14 | Action reportProgress, CancellationToken ct); 15 | } 16 | } -------------------------------------------------------------------------------- /GGDeals/Infrastructure/Helpers/EnumHelpers.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using System.ComponentModel; 3 | using System.Reflection; 4 | 5 | namespace GGDeals.Infrastructure.Helpers 6 | { 7 | internal class EnumHelpers 8 | { 9 | public static string GetEnumDescription(GGLauncher value) 10 | { 11 | var field = typeof(GGLauncher).GetField(value.ToString()); 12 | var attr = field.GetCustomAttribute(); 13 | return attr != null ? attr.Description : value.ToString(); 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /GGDeals/Menu/Failures/File/FailuresFile.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Services; 2 | using System; 3 | using System.Collections.Generic; 4 | using GGDeals.Models; 5 | 6 | namespace GGDeals.Menu.Failures.File 7 | { 8 | public class FailuresFile : IVersionedFailuresFile 9 | { 10 | public const int CurrentVersion = 1; 11 | 12 | public FailuresFile() 13 | { 14 | Version = CurrentVersion; 15 | } 16 | 17 | public int Version { get; set; } 18 | 19 | public Dictionary Failures { get; set; } = new Dictionary(); 20 | } 21 | } -------------------------------------------------------------------------------- /GGDeals/Menu/Failures/MVVM/ShowAddFailuresView.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Windows; 3 | using System.Windows.Controls; 4 | 5 | namespace GGDeals.Menu.Failures.MVVM 6 | { 7 | /// 8 | /// Interaction logic for ShowAddFailuresView.xaml 9 | /// 10 | public partial class ShowAddFailuresView : UserControl 11 | { 12 | public ShowAddFailuresView(ShowAddFailuresViewModel viewModel) 13 | { 14 | InitializeComponent(); 15 | DataContext = viewModel; 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /GGDeals/App.xaml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /ReleaseTools.IntegrationTests/Changelog/ReleaseChangelogWriterTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using AutoFixture.Xunit2; 4 | using ReleaseTools.Changelog; 5 | using Xunit; 6 | 7 | namespace ReleaseTools.IntegrationTests.Changelog 8 | { 9 | public class ReleaseChangelogWriterTests : IDisposable 10 | { 11 | private readonly string _file; 12 | 13 | public ReleaseChangelogWriterTests() 14 | { 15 | _file = Path.GetTempFileName(); 16 | } 17 | 18 | [Theory, AutoData] 19 | public void Write_CreatesChangelogFile( 20 | string version, 21 | ReleaseChangelogWriter sut) 22 | { 23 | // Arrange 24 | var changes = new[] 25 | { 26 | "- Change 1", 27 | "- Change 2", 28 | "- Fix 1", 29 | }; 30 | var changelogEntry = new ChangelogEntry(version, changes); 31 | var expectedText = File.ReadAllText(@"Changelog\TestData\expected_release_changelog.md"); 32 | 33 | // Act 34 | sut.Write(_file, changelogEntry); 35 | 36 | // Assert 37 | var actualText = File.ReadAllText(_file); 38 | Assert.Equal(expectedText, actualText); 39 | } 40 | 41 | public void Dispose() 42 | { 43 | if (File.Exists(_file)) 44 | { 45 | File.Delete(_file); 46 | } 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /ReleaseTools.IntegrationTests/ExtensionYaml/ExtensionYamlUpdaterTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using AutoFixture.Xunit2; 4 | using ReleaseTools.ExtensionYaml; 5 | using Xunit; 6 | 7 | namespace ReleaseTools.IntegrationTests.ExtensionYaml 8 | { 9 | public class ExtensionYamlUpdaterTests : IDisposable 10 | { 11 | private const string ExpectedExtensionYaml = "ExtensionYaml\\TestData\\extension_after.yaml"; 12 | private const string ExtensionYamlBefore = "ExtensionYaml\\TestData\\extension_before.yaml"; 13 | private readonly string _installerManifest; 14 | 15 | public ExtensionYamlUpdaterTests() 16 | { 17 | _installerManifest = Path.GetTempFileName(); 18 | File.Delete(_installerManifest); 19 | File.Copy(ExtensionYamlBefore, _installerManifest); 20 | } 21 | 22 | [Theory, AutoData] 23 | public void Update_ReplacesTheVersionWithTheGivenOne( 24 | ExtensionYamlUpdater sut) 25 | { 26 | // Arrange 27 | var expectedYaml = File.ReadAllText(ExpectedExtensionYaml); 28 | 29 | // Act 30 | sut.Update(_installerManifest, "1.2.3"); 31 | 32 | // Assert 33 | var actual = File.ReadAllText(_installerManifest); 34 | Assert.Equal(expectedYaml, actual); 35 | } 36 | 37 | public void Dispose() 38 | { 39 | File.Delete(_installerManifest); 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /ReleaseTools.UnitTests/GitHubTools/AuthStatusParserTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture.Xunit2; 2 | using ReleaseTools.GitHubTools; 3 | using Xunit; 4 | 5 | namespace ReleaseTools.UnitTests.GitHubTools 6 | { 7 | public class AuthStatusParserTests 8 | { 9 | [Theory, AutoData] 10 | public void IsUserLoggedIn_ReturnsTrue_When_UserIsLoggedIn(AuthStatusParser sut) 11 | { 12 | // Arrange 13 | var output = "\u001b[0;1;39mgithub.com\u001b[0m\r\n \u001b[0;32mŌ£ō\u001b[0m Logged in to github.com account \u001b[0;1;39mSparrowBrain\u001b[0m (keyring)\r\n \u001b[0;32mŌ£ō\u001b[0m Git operations for github.com configured to use \u001b[0;1;39mhttps\u001b[0m protocol.\r\n \u001b[0;32mŌ£ō\u001b[0m Token: gho_************************************\r\n \u001b[0;32mŌ£ō\u001b[0m Token scopes: gist, read:org, repo\r\n\r\n"; 14 | 15 | // Act 16 | var result = sut.IsUserLoggedIn(output); 17 | 18 | // Assert 19 | Assert.True(result); 20 | } 21 | 22 | [Theory, AutoData] 23 | public void IsUserLoggedIn_ReturnsFalse_When_UserIsLoggedOut(AuthStatusParser sut) 24 | { 25 | // Arrange 26 | var output = "You are not logged into any GitHub hosts. Run \u001b[0;1;39mgh auth login\u001b[0m to authenticate.\r\n\r\n"; 27 | 28 | // Act 29 | var result = sut.IsUserLoggedIn(output); 30 | 31 | // Assert 32 | Assert.False(result); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /.github/workflows/crowdin.yml: -------------------------------------------------------------------------------- 1 | name: Crowdin Action 2 | concurrency: crowdin 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: [ main ] 8 | 9 | jobs: 10 | synchronize-with-crowdin: 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | contents: write 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4.1.2 19 | 20 | - name: Generate token 21 | id: generate_token 22 | uses: tibdex/github-app-token@v1 23 | with: 24 | app_id: "${{ secrets.LITTLE_BUDDY_APP_ID }}" 25 | private_key: "${{ secrets.LITTLE_BUDDY_PRIVATE_KEY }}" 26 | 27 | - name: crowdin action 28 | uses: crowdin/github-action@v1 29 | with: 30 | config: 'crowdin.yml' 31 | dryrun_action: false 32 | upload_sources: true 33 | upload_translations: false 34 | download_translations: true 35 | localization_branch_name: l10n_crowdin_translations 36 | create_pull_request: true 37 | pull_request_title: 'New Crowdin Translations' 38 | pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)' 39 | pull_request_base_branch_name: 'main' 40 | pull_request_labels: 'enhancement' 41 | env: 42 | GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} 43 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} 44 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} -------------------------------------------------------------------------------- /GGDeals/Api/Models/GGLauncher.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Runtime.Serialization; 3 | 4 | namespace GGDeals.Api.Models 5 | { 6 | public enum GGLauncher 7 | { 8 | [EnumMember(Value = "other")] 9 | [Description("Other")] 10 | Other = 0, 11 | 12 | [EnumMember(Value = "steam")] 13 | [Description("Steam")] 14 | Steam = 1, 15 | 16 | [EnumMember(Value = "ea")] 17 | [Description("EA App")] 18 | EA = 2, 19 | 20 | [EnumMember(Value = "ubisoft")] 21 | [Description("Ubisoft Connect")] 22 | Ubisoft = 3, 23 | 24 | [EnumMember(Value = "gog")] 25 | [Description("GOG")] 26 | GOG = 4, 27 | 28 | [EnumMember(Value = "epic")] 29 | [Description("Epic Games Launcher")] 30 | Epic = 5, 31 | 32 | [EnumMember(Value = "microsoft")] 33 | [Description("Microsoft Store")] 34 | Microsoft = 6, 35 | 36 | [EnumMember(Value = "battle-net")] 37 | [Description("Battle.net")] 38 | BattleNet = 7, 39 | 40 | [EnumMember(Value = "rockstar")] 41 | [Description("Rockstar Games Launcher")] 42 | Rockstar = 8, 43 | 44 | [EnumMember(Value = "prime-gaming")] 45 | [Description("Prime Gaming")] 46 | PrimeGaming = 9, 47 | 48 | [EnumMember(Value = "playstation")] 49 | [Description("PlayStation")] 50 | Playstation = 10, 51 | 52 | [EnumMember(Value = "nintendo")] 53 | [Description("Nintendo")] 54 | Nintendo = 11, 55 | 56 | [EnumMember(Value = "itch")] 57 | [Description("Itch.io")] 58 | Itch = 12, 59 | 60 | [EnumMember(Value = "drm-free")] 61 | [Description("DRM free")] 62 | DrmFree = 13, 63 | } 64 | } -------------------------------------------------------------------------------- /ReleaseTools/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("ReleaseTools")] 9 | [assembly: AssemblyDescription("")] 10 | [assembly: AssemblyConfiguration("")] 11 | [assembly: AssemblyCompany("")] 12 | [assembly: AssemblyProduct("ReleaseTools")] 13 | [assembly: AssemblyCopyright("Copyright © 2023")] 14 | [assembly: AssemblyTrademark("")] 15 | [assembly: AssemblyCulture("")] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible(false)] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid("5c1ff6fe-5a69-4f8d-b161-75f58db89666")] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion("1.0.0.0")] 36 | [assembly: AssemblyFileVersion("1.0.0.0")] 37 | -------------------------------------------------------------------------------- /TestTools.Shared/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("TestTools.Shared")] 9 | [assembly: AssemblyDescription("")] 10 | [assembly: AssemblyConfiguration("")] 11 | [assembly: AssemblyCompany("")] 12 | [assembly: AssemblyProduct("TestTools.Shared")] 13 | [assembly: AssemblyCopyright("Copyright © 2024")] 14 | [assembly: AssemblyTrademark("")] 15 | [assembly: AssemblyCulture("")] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible(false)] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid("53b7484b-be62-481c-90d3-1e75e18250e3")] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion("1.0.0.0")] 36 | [assembly: AssemblyFileVersion("1.0.0.0")] 37 | -------------------------------------------------------------------------------- /GGDeals/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | using System.Windows.Markup; 5 | 6 | // General Information about an assembly is controlled through the following 7 | // set of attributes. Change these attribute values to modify the information 8 | // associated with an assembly. 9 | [assembly: AssemblyTitle("GGDeals")] 10 | [assembly: AssemblyDescription("")] 11 | [assembly: AssemblyConfiguration("")] 12 | [assembly: AssemblyCompany("")] 13 | [assembly: AssemblyProduct("GGDeals")] 14 | [assembly: AssemblyCopyright("Copyright © 2019")] 15 | [assembly: AssemblyTrademark("")] 16 | [assembly: AssemblyCulture("")] 17 | 18 | // Setting ComVisible to false makes the types in this assembly not visible 19 | // to COM components. If you need to access a type in this assembly from 20 | // COM, set the ComVisible attribute to true on that type. 21 | [assembly: ComVisible(false)] 22 | 23 | // The following GUID is for the ID of the typelib if this project is exposed to COM 24 | [assembly: Guid("2af05ded-085c-426b-a10e-8e03185092bf")] 25 | 26 | // Version information for an assembly consists of the following four values: 27 | // 28 | // Major Version 29 | // Minor Version 30 | // Build Number 31 | // Revision 32 | // 33 | // You can specify all the values or you can default the Build and Revision Numbers 34 | // by using the '*' as shown below: 35 | // [assembly: AssemblyVersion("1.0.*")] 36 | [assembly: AssemblyVersion("1.0.0.0")] 37 | [assembly: AssemblyFileVersion("1.0.0.0")] 38 | 39 | #if DEBUG 40 | [assembly: XmlnsDefinition("debug-mode", "GGDeals.Debug")] 41 | #endif 42 | -------------------------------------------------------------------------------- /GGDeals/Settings/Old/SettingsV0.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace GGDeals.Settings.Old 5 | { 6 | public class SettingsV0 : IMigratableSettings 7 | { 8 | public SettingsV0() 9 | { 10 | Version = 0; 11 | } 12 | 13 | public static SettingsV0 Default => 14 | new SettingsV0 15 | { 16 | LibrariesToSkip = new List() 17 | { 18 | Guid.Parse("CB91DFC9-B977-43BF-8E70-55F46E410FAB"), // Steam 19 | Guid.Parse("AEBE8B7C-6DC3-4A66-AF31-E7375C6B5E9E"), // GOG 20 | }, 21 | SyncPlayniteLibrary = false, 22 | AddLinksToGames = false, 23 | }; 24 | 25 | public int Version { get; set; } 26 | 27 | public virtual IVersionedSettings Migrate() 28 | { 29 | var newSettings = GGDealsSettings.Default; 30 | newSettings.AddLinksToGames = AddLinksToGames; 31 | newSettings.AuthenticationToken = AuthenticationToken; 32 | newSettings.DevCollectionImportEndpoint = DevCollectionImportEndpoint; 33 | newSettings.LibrariesToSkip = LibrariesToSkip; 34 | newSettings.SyncPlayniteLibrary = SyncPlayniteLibrary; 35 | 36 | return newSettings; 37 | } 38 | 39 | public string AuthenticationToken { get; set; } 40 | 41 | public List LibrariesToSkip { get; set; } 42 | 43 | public bool SyncPlayniteLibrary { get; set; } 44 | 45 | public string DevCollectionImportEndpoint { get; set; } 46 | 47 | public bool AddLinksToGames { get; set; } 48 | } 49 | } -------------------------------------------------------------------------------- /ReleaseTools.IntegrationTests/packages.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /GGDeals/Menu/Failures/File/AddFailuresFileService.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Services; 2 | using Newtonsoft.Json; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | using GGDeals.Models; 9 | 10 | namespace GGDeals.Menu.Failures.File 11 | { 12 | public class AddFailuresFileService : IAddFailuresFileService 13 | { 14 | private readonly string _failuresFilePath; 15 | 16 | public AddFailuresFileService(string failuresFilePath) 17 | { 18 | _failuresFilePath = failuresFilePath; 19 | } 20 | 21 | public async Task> Load() 22 | { 23 | if (!System.IO.File.Exists(_failuresFilePath)) 24 | { 25 | return new Dictionary(); 26 | } 27 | 28 | using (var streamReader = new StreamReader(_failuresFilePath)) 29 | { 30 | var contents = await streamReader.ReadToEndAsync(); 31 | 32 | var file = JsonConvert.DeserializeObject(contents); 33 | if (file.Failures.Count == 0) 34 | { 35 | var versionedFile = JsonConvert.DeserializeObject(contents); 36 | if (versionedFile.Version == 0) 37 | { 38 | var fileV0 = JsonConvert.DeserializeObject>(contents); 39 | return fileV0.ToDictionary(x => x.Key, x => new AddResult() { Result = x.Value }); 40 | } 41 | } 42 | 43 | return file.Failures; 44 | } 45 | } 46 | 47 | public async Task Save(Dictionary failures) 48 | { 49 | var file = new FailuresFile() { Failures = failures }; 50 | using (var streamWriter = new StreamWriter(_failuresFilePath, false)) 51 | { 52 | await streamWriter.WriteAsync(JsonConvert.SerializeObject(file)); 53 | } 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /ReleaseTools.UnitTests/packages.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /ci/Changelog.txt: -------------------------------------------------------------------------------- 1 | v2.3.0 2 | - Added an ability to assign GG.deals library to a Playnite plugin 3 | - Added hawkeye's library plugins for GOG, Legendary (Epic) and Nile (Prime Gaming) to defaults 4 | - Added Portuguese, Brazilian translations (thanks, TheNutellas!) 5 | - Clicking on 'games added' notification now opens the website 6 | - API failures are now propagated to the user 7 | 8 | v2.2.0 9 | - Added buffered update (thanks Jeshibu!) 10 | - Improved success notification readability 11 | 12 | v2.1.1 13 | - Sync of newly added games is now off by default 14 | - Clearer tooltips in add games window 15 | 16 | v2.1.0 17 | - Tags are now optional 18 | - Sync of newly added games is now optional 19 | - Added optional progress bar (on by default) 20 | - Added a scrollbar to the settings window 21 | - Added tooltips to add games window selections 22 | - Improved failures layout 23 | - Fixed a bug where user could issue multiple calls to the API in parallel 24 | 25 | v2.0.0 26 | - Calling official GG.deals API instead of scraping the website 27 | - Users now must provide authentication token generated on GG.deals website instead of logging in through the extension 28 | - Processing games only after metadata fetch 29 | - Using custom tags to keep track of sync status 30 | - Added text messages to failures window 31 | - Added sync success notification 32 | - Authentication error notification now opens extension settings when clicked 33 | - Add failure notification now opens add failure window when clicked 34 | - Adding GG.deals link to processed games 35 | - Improved handling for games hidden by Duplicate Hider 36 | 37 | v1.1.0 38 | - Added Lithuanian language 39 | - Added skipping of Playnite library (skipped by default) 40 | 41 | v1.0.1 42 | - Added missing links 43 | 44 | v1.0.0 45 | - Initial release 46 | -------------------------------------------------------------------------------- /GGDeals/Services/GameToAddFilter.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Models; 2 | using GGDeals.Settings; 3 | using Playnite.SDK; 4 | using Playnite.SDK.Models; 5 | using System; 6 | 7 | namespace GGDeals.Services 8 | { 9 | public class GameToAddFilter : IGameToAddFilter 10 | { 11 | private static readonly ILogger Logger = LogManager.GetLogger(); 12 | private readonly GGDealsSettings _settings; 13 | private readonly IGameStatusService _gameStatusService; 14 | private readonly SyncRunSettings _syncRunSettings; 15 | 16 | public GameToAddFilter(GGDealsSettings settings, IGameStatusService gameStatusService, SyncRunSettings syncRunSettings) 17 | { 18 | _settings = settings; 19 | _gameStatusService = gameStatusService; 20 | _syncRunSettings = syncRunSettings; 21 | } 22 | 23 | public bool ShouldTryAddGame(Game game, out AddResult status) 24 | { 25 | if (game.PluginId == Guid.Empty && !_settings.SyncPlayniteLibrary) 26 | { 27 | Logger.Debug($"Skipped due to Playnite library: {{ Id: {game.Id}, Name: {game.Name} }}."); 28 | status = new AddResult() { Result = AddToCollectionResult.SkippedDueToLibrary }; 29 | return false; 30 | } 31 | 32 | if (_settings.LibrariesToSkip.Contains(game.PluginId)) 33 | { 34 | Logger.Debug($"Skipped due to library: {{ Id: {game.Id}, Name: {game.Name} }}."); 35 | status = new AddResult() { Result = AddToCollectionResult.SkippedDueToLibrary }; 36 | return false; 37 | } 38 | 39 | var gameStatus = _gameStatusService.GetStatus(game); 40 | if (!_syncRunSettings.StatusesToSync.Contains(gameStatus)) 41 | { 42 | Logger.Debug($"Skipped due to status: {{ Id: {game.Id}, Name: {game.Name}, Status: {gameStatus} }}."); 43 | status = new AddResult() { Result = gameStatus }; 44 | return false; 45 | } 46 | 47 | status = null; 48 | return true; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /GGDeals/Api/Services/RequestDataBatcher.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Serialization; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text; 8 | 9 | namespace GGDeals.Api.Services 10 | { 11 | public class RequestDataBatcher : IRequestDataBatcher 12 | { 13 | private readonly JsonSerializerSettings _jsonSerializerSettings; 14 | private const int BatchGameCount = 1000; 15 | private const int MaxJsonLength = 10_000_000; 16 | 17 | public RequestDataBatcher(JsonSerializerSettings jsonSerializerSettings) 18 | { 19 | _jsonSerializerSettings = new JsonSerializerSettings 20 | { 21 | ContractResolver = new DefaultContractResolver 22 | { 23 | NamingStrategy = new DefaultNamingStrategy() 24 | }, 25 | }; 26 | } 27 | 28 | public IEnumerable CreateDataJsons(IReadOnlyCollection games) 29 | { 30 | if (games.Count == 0) 31 | { 32 | throw new ArgumentException("Cannot batch an empty games list", nameof(games)); 33 | } 34 | 35 | var remainingGames = new List(games); 36 | while (true) 37 | { 38 | var batch = remainingGames.Take(BatchGameCount).ToList(); 39 | var gamesTaken = batch.Count; 40 | var json = JsonConvert.SerializeObject(batch, _jsonSerializerSettings); 41 | for (var i = 1; Encoding.UTF8.GetBytes(json).Length > MaxJsonLength; i++) 42 | { 43 | batch = remainingGames.Take(gamesTaken - i).ToList(); 44 | gamesTaken = batch.Count; 45 | json = JsonConvert.SerializeObject(batch, _jsonSerializerSettings); 46 | } 47 | 48 | yield return json; 49 | 50 | remainingGames = remainingGames.Skip(gamesTaken).ToList(); 51 | if (!remainingGames.Any()) 52 | { 53 | yield break; 54 | } 55 | } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /GGDeals/Api/Services/LibraryToGGLauncherMap.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using System; 3 | using System.Collections.Generic; 4 | 5 | namespace GGDeals.Api.Services 6 | { 7 | public class LibraryToGGLauncherMap : ILibraryToGGLauncherMap 8 | { 9 | private readonly Dictionary _libraryIdMapping = new Dictionary() 10 | { 11 | { Guid.Parse("CB91DFC9-B977-43BF-8E70-55F46E410FAB"), GGLauncher.Steam }, 12 | { Guid.Parse("85DD7072-2F20-4E76-A007-41035E390724"), GGLauncher.EA }, 13 | { Guid.Parse("C2F038E5-8B92-4877-91F1-DA9094155FC5"), GGLauncher.Ubisoft }, 14 | { Guid.Parse("AEBE8B7C-6DC3-4A66-AF31-E7375C6B5E9E"), GGLauncher.GOG }, 15 | { Guid.Parse("03689811-3F33-4DFB-A121-2EE168FB9A5C"), GGLauncher.GOG }, // GOG OSS 16 | { Guid.Parse("00000002-DBD1-46C6-B5D0-B1BA559D10E4"), GGLauncher.Epic }, 17 | { Guid.Parse("EAD65C3B-2F8F-4E37-B4E6-B3DE6BE540C6"), GGLauncher.Epic }, // Legendary (Epic) 18 | { Guid.Parse("7e4fbb5e-2ae3-48d4-8ba0-6b30e7a4e287"), GGLauncher.Microsoft }, 19 | { Guid.Parse("E3C26A3D-D695-4CB7-A769-5FF7612C7EDD"), GGLauncher.BattleNet }, 20 | { Guid.Parse("88409022-088a-4de8-805a-fdbac291f00a"), GGLauncher.Rockstar }, 21 | { Guid.Parse("402674cd-4af6-4886-b6ec-0e695bfa0688"), GGLauncher.PrimeGaming }, 22 | { Guid.Parse("5901B4B4-774D-411A-9CCE-807C5CA49D88"), GGLauncher.PrimeGaming }, // Nile (Amazon) 23 | { Guid.Parse("e4ac81cb-1b1a-4ec9-8639-9a9633989a71"), GGLauncher.Playstation }, 24 | { Guid.Parse("e4ac81cb-1b1a-4ec9-8639-9a9633989a72"), GGLauncher.Nintendo }, 25 | { Guid.Parse("00000001-EBB2-4EEC-ABCB-7C89937A42BB"), GGLauncher.Itch }, 26 | }; 27 | 28 | public GGLauncher GetGGLauncher(Guid pluginId) 29 | { 30 | if (_libraryIdMapping.TryGetValue(pluginId, out var ggLauncher)) 31 | { 32 | return ggLauncher; 33 | } 34 | 35 | return GGLauncher.Other; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /ReleaseTools.UnitTests/InstallerManifestYaml/InstallerManifestEntryGeneratorTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AutoFixture.Xunit2; 3 | using Moq; 4 | using ReleaseTools.Changelog; 5 | using ReleaseTools.InstallerManifestYaml; 6 | using ReleaseTools.Package; 7 | using TestTools.Shared; 8 | using Xunit; 9 | 10 | namespace ReleaseTools.UnitTests.InstallerManifestYaml 11 | { 12 | public class InstallerManifestEntryGeneratorTests 13 | { 14 | [Theory, AutoMoqData] 15 | public void Generate_CreatesEntry( 16 | [Frozen] Mock dateTimeProviderMock, 17 | [Frozen] Mock playniteSdkVersionParserMock, 18 | [Frozen] Mock extensionPackageNameGuesserMock, 19 | InstallerManifestEntryGenerator sut) 20 | { 21 | // Arrange 22 | dateTimeProviderMock.Setup(x => x.Now).Returns(DateTime.Parse("2020-03-24")); 23 | playniteSdkVersionParserMock.Setup(x => x.GetVersion()).Returns("9.8.7"); 24 | extensionPackageNameGuesserMock 25 | .Setup(x => x.GetName(It.Is(v => v == "2.3.4"))) 26 | .Returns("SparrowBrain_GGDeals_2_3_4.pext"); 27 | var changeEntry = new ChangelogEntry("2.3.4", new[] { "- Change 1", "- Change 22", "- Fix important!" }); 28 | var expected = @" - Version: 2.3.4 29 | RequiredApiVersion: 9.8.7 30 | ReleaseDate: 2020-03-24 31 | PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v2.3.4/SparrowBrain_GGDeals_2_3_4.pext 32 | Changelog: 33 | - Change 1 34 | - Change 22 35 | - Fix important! 36 | "; 37 | 38 | // Act 39 | var result = sut.Generate(changeEntry); 40 | 41 | // Assert 42 | Assert.Equal(expected, result); 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /GGDeals.IntegrationTests/packages.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /GGDeals/Settings/MVVM/LibraryItem.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using GGDeals.Infrastructure.Helpers; 6 | 7 | namespace GGDeals.Settings.MVVM 8 | { 9 | public class LibraryItem : ObservableObject 10 | { 11 | private readonly Action _isCheckedSettingsUpdateAction; 12 | private readonly Action _ggLauncherSettingsUpdateAction; 13 | private bool _isChecked; 14 | private GGLauncher _ggLauncher; 15 | 16 | public LibraryItem(Guid id, 17 | string name, 18 | bool isOffByDefault, 19 | bool isChecked, 20 | GGLauncher ggLauncher, 21 | Action isCheckedSettingsUpdateAction, 22 | Action ggLauncherSettingsUpdateAction) 23 | { 24 | Id = id; 25 | Name = name; 26 | IsOffByDefault = isOffByDefault; 27 | _isChecked = isChecked; 28 | GGLauncher = ggLauncher; 29 | _isCheckedSettingsUpdateAction = isCheckedSettingsUpdateAction; 30 | _ggLauncherSettingsUpdateAction = ggLauncherSettingsUpdateAction; 31 | } 32 | 33 | public Guid Id { get; } 34 | 35 | public string Name { get; } 36 | 37 | public bool IsOffByDefault { get; } 38 | 39 | // ReSharper disable once UnusedMember.Global 40 | public bool IsChecked 41 | { 42 | get => _isChecked; 43 | set 44 | { 45 | SetValue(ref _isChecked, value); 46 | _isCheckedSettingsUpdateAction(Id, value); 47 | } 48 | } 49 | 50 | public GGLauncher GGLauncher 51 | { 52 | get => _ggLauncher; 53 | set 54 | { 55 | SetValue(ref _ggLauncher, value); 56 | _ggLauncherSettingsUpdateAction?.Invoke(Id, value); 57 | } 58 | } 59 | 60 | public Dictionary GGLauncherOptions { get; } = 61 | Enum.GetValues(typeof(GGLauncher)) 62 | .Cast() 63 | .ToDictionary( 64 | x => x, 65 | EnumHelpers.GetEnumDescription 66 | ); 67 | } 68 | } -------------------------------------------------------------------------------- /ReleaseTools/InstallerManifestYaml/InstallerManifestEntryGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using ReleaseTools.Changelog; 3 | using ReleaseTools.Package; 4 | 5 | namespace ReleaseTools.InstallerManifestYaml 6 | { 7 | public class InstallerManifestEntryGenerator 8 | { 9 | private readonly IPlayniteSdkVersionParser _playniteSdkVersionParser; 10 | private readonly IDateTimeProvider _dateTimeProvider; 11 | private readonly IExtensionPackageNameGuesser _extensionPackageNameGuesser; 12 | 13 | public InstallerManifestEntryGenerator(IPlayniteSdkVersionParser playniteSdkVersionParser, IDateTimeProvider dateTimeProvider, IExtensionPackageNameGuesser extensionPackageNameGuesser) 14 | { 15 | _playniteSdkVersionParser = playniteSdkVersionParser; 16 | _dateTimeProvider = dateTimeProvider; 17 | _extensionPackageNameGuesser = extensionPackageNameGuesser; 18 | } 19 | 20 | public string Generate(ChangelogEntry changelogEntry) 21 | { 22 | var apiVersion = _playniteSdkVersionParser.GetVersion(); 23 | var packageName = _extensionPackageNameGuesser.GetName(changelogEntry.Version); 24 | 25 | var builder = new StringBuilder(); 26 | builder.AppendLine($" - Version: {changelogEntry.Version}"); 27 | builder.AppendLine($" RequiredApiVersion: {apiVersion}"); 28 | builder.AppendLine($" ReleaseDate: {_dateTimeProvider.Now:yyyy-MM-dd}"); 29 | builder.AppendLine($" PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v{changelogEntry.Version}/{packageName}"); 30 | builder.AppendLine($" Changelog:"); 31 | foreach (var change in changelogEntry.Changes) 32 | { 33 | builder.AppendLine($" {change}"); 34 | } 35 | 36 | return builder.ToString(); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /ReleaseTools.IntegrationTests/InstallerManifestYaml/InstallerManifestUpdaterTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using AutoFixture.Xunit2; 4 | using ReleaseTools.InstallerManifestYaml; 5 | using Xunit; 6 | 7 | namespace ReleaseTools.IntegrationTests.InstallerManifestYaml 8 | { 9 | public class InstallerManifestUpdaterTests : IDisposable 10 | { 11 | private const string BeforeManifest = @"InstallerManifestYaml\TestData\before_installer_manifest.yaml"; 12 | private const string AfterManifest = @"InstallerManifestYaml\TestData\after_installer_manifest.yaml"; 13 | private readonly string _installerManifest; 14 | 15 | public InstallerManifestUpdaterTests() 16 | { 17 | _installerManifest = Path.GetTempFileName(); 18 | File.Delete(_installerManifest); 19 | File.Copy(BeforeManifest, _installerManifest); 20 | } 21 | 22 | [Theory, AutoData] 23 | public void Update_AddsExpectedEntry( 24 | InstallerManifestUpdater sut) 25 | { 26 | // Arrange 27 | var expected = File.ReadAllText(AfterManifest); 28 | var manifestEntry = @" - Version: 2.3.4 29 | RequiredApiVersion: 9.8.7 30 | ReleaseDate: 2020-03-24 31 | PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v2.3.4/SparrowBrain_GGDeals_2_3_4.pext 32 | Changelog: 33 | - Change 1 34 | - Change 2 35 | - Fix 36 | "; 37 | 38 | // Act 39 | sut.Update(_installerManifest, manifestEntry); 40 | 41 | // Assert 42 | var actual = File.ReadAllText(_installerManifest); 43 | Assert.Equal(expected, actual); 44 | } 45 | 46 | public void Dispose() 47 | { 48 | if (File.Exists(_installerManifest)) 49 | { 50 | File.Delete(_installerManifest); 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /GGDeals.UnitTests/packages.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /GGDeals.UnitTests/Api/Services/LibraryToGGLauncherMapTests.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using GGDeals.Api.Services; 3 | using Playnite.SDK.Models; 4 | using System; 5 | using TestTools.Shared; 6 | using Xunit; 7 | 8 | namespace GGDeals.UnitTests.Api.Services 9 | { 10 | public class LibraryToGGLauncherMapTests 11 | { 12 | [Theory] 13 | [MemberAutoMoqData(nameof(LibraryIdTestData))] 14 | public void GetLibraryName_ReturnsGGLibraryNameFromLibraryId_WhenPluginIdMatchesTheMap( 15 | string playniteLibraryId, 16 | GGLauncher ggLauncher, 17 | LibraryToGGLauncherMap sut) 18 | { 19 | // Arrange 20 | var pluginId = Guid.Parse(playniteLibraryId); 21 | 22 | // Act 23 | var actual = sut.GetGGLauncher(pluginId); 24 | 25 | // Assert 26 | Assert.Equal(ggLauncher, actual); 27 | } 28 | 29 | public static TheoryData LibraryIdTestData => new TheoryData 30 | { 31 | { "CB91DFC9-B977-43BF-8E70-55F46E410FAB", GGLauncher.Steam }, 32 | { "85DD7072-2F20-4E76-A007-41035E390724", GGLauncher.EA }, 33 | { "C2F038E5-8B92-4877-91F1-DA9094155FC5", GGLauncher.Ubisoft }, 34 | { "AEBE8B7C-6DC3-4A66-AF31-E7375C6B5E9E", GGLauncher.GOG }, 35 | { "03689811-3F33-4DFB-A121-2EE168FB9A5C", GGLauncher.GOG }, // GOG OSS 36 | { "00000002-DBD1-46C6-B5D0-B1BA559D10E4", GGLauncher.Epic }, 37 | { "EAD65C3B-2F8F-4E37-B4E6-B3DE6BE540C6", GGLauncher.Epic }, // Legendary (Epic) 38 | { "7e4fbb5e-2ae3-48d4-8ba0-6b30e7a4e287", GGLauncher.Microsoft }, 39 | { "E3C26A3D-D695-4CB7-A769-5FF7612C7EDD", GGLauncher.BattleNet }, 40 | { "88409022-088a-4de8-805a-fdbac291f00a", GGLauncher.Rockstar }, 41 | { "402674cd-4af6-4886-b6ec-0e695bfa0688", GGLauncher.PrimeGaming }, 42 | { "5901B4B4-774D-411A-9CCE-807C5CA49D88", GGLauncher.PrimeGaming }, // Nile (Amazon) 43 | { "e4ac81cb-1b1a-4ec9-8639-9a9633989a71", GGLauncher.Playstation }, 44 | { "e4ac81cb-1b1a-4ec9-8639-9a9633989a72", GGLauncher.Nintendo }, 45 | { "00000001-EBB2-4EEC-ABCB-7C89937A42BB", GGLauncher.Itch }, 46 | { "00000000-0000-0000-0000-000000000000", GGLauncher.Other }, 47 | { "415373e6-afb4-4be0-8f31-82d9a0b54086", GGLauncher.Other } 48 | }; 49 | } 50 | } -------------------------------------------------------------------------------- /GGDeals/Services/AddResultProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using GGDeals.Models; 5 | using GGDeals.Settings; 6 | using Playnite.SDK.Models; 7 | 8 | namespace GGDeals.Services 9 | { 10 | public class AddResultProcessor : IAddResultProcessor 11 | { 12 | private readonly GGDealsSettings _settings; 13 | private readonly IGameStatusService _gameStatusService; 14 | private readonly IAddLinkService _addLinkService; 15 | 16 | public AddResultProcessor( 17 | GGDealsSettings settings, 18 | IGameStatusService gameStatusService, 19 | IAddLinkService addLinkService) 20 | { 21 | _settings = settings; 22 | _gameStatusService = gameStatusService; 23 | _addLinkService = addLinkService; 24 | } 25 | 26 | public void Process(IReadOnlyCollection games, IDictionary results) 27 | { 28 | using (_gameStatusService.BufferedUpdate()) 29 | { 30 | foreach (var addResult in results) 31 | { 32 | var game = games.Single(g => g.Id == addResult.Key); 33 | 34 | UpdateStatus(game, addResult.Value.Result); 35 | AddLink(game, addResult.Value.Url); 36 | } 37 | } 38 | } 39 | 40 | private void UpdateStatus(Game game, AddToCollectionResult addToCollectionResult) 41 | { 42 | if (!_settings.AddTagsToGames) 43 | { 44 | return; 45 | } 46 | 47 | switch (addToCollectionResult) 48 | { 49 | case AddToCollectionResult.Error: 50 | case AddToCollectionResult.SkippedDueToLibrary: 51 | return; 52 | 53 | case AddToCollectionResult.Added: 54 | case AddToCollectionResult.Synced: 55 | case AddToCollectionResult.NotFound: 56 | case AddToCollectionResult.Ignored: 57 | _gameStatusService.UpdateStatus(game, addToCollectionResult); 58 | break; 59 | 60 | case AddToCollectionResult.New: 61 | default: 62 | throw new Exception($"Not configured AddToCollectionResult {addToCollectionResult} while processing status."); 63 | } 64 | } 65 | 66 | private void AddLink(Game game, string url) 67 | { 68 | if (_settings.AddLinksToGames && !string.IsNullOrEmpty(url)) 69 | { 70 | _addLinkService.AddLink(game, url); 71 | } 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /GGDeals/Queue/PersistentProcessingQueue.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Playnite.SDK; 7 | 8 | namespace GGDeals.Queue 9 | { 10 | public class PersistentProcessingQueue 11 | { 12 | private readonly ILogger _logger = LogManager.GetLogger(); 13 | private readonly IQueuePersistence _queuePersistence; 14 | private readonly Func, Task> _action; 15 | 16 | private readonly SemaphoreSlim _initSemaphore = new SemaphoreSlim(1, 1); 17 | private readonly SemaphoreSlim _processingSemaphore = new SemaphoreSlim(1, 1); 18 | private ConcurrentQueue _gameIds; 19 | 20 | public PersistentProcessingQueue(IQueuePersistence queuePersistence, Func, Task> action) 21 | { 22 | _queuePersistence = queuePersistence; 23 | _action = action; 24 | } 25 | 26 | public async Task Enqueue(IReadOnlyCollection gameIds) 27 | { 28 | await EnsureInitialized(); 29 | foreach (var gameId in gameIds) 30 | { 31 | _gameIds.Enqueue(gameId); 32 | } 33 | 34 | await _queuePersistence.Save(_gameIds.ToArray()); 35 | } 36 | 37 | public void ProcessInBackground() 38 | { 39 | Task.Run(async () => 40 | { 41 | await EnsureInitialized(); 42 | await _processingSemaphore.WaitAsync(); 43 | 44 | var gameIds = new List(); 45 | try 46 | { 47 | while (_gameIds.TryDequeue(out var gameId)) 48 | { 49 | gameIds.Add(gameId); 50 | } 51 | 52 | await _action(gameIds); 53 | await _queuePersistence.Save(_gameIds); 54 | } 55 | catch (Exception e) 56 | { 57 | _logger.Error(e, "Failed to process queue."); 58 | await Enqueue(gameIds); 59 | } 60 | finally 61 | { 62 | _processingSemaphore.Release(); 63 | } 64 | }); 65 | } 66 | 67 | private async Task EnsureInitialized() 68 | { 69 | if (_gameIds != null) 70 | { 71 | return; 72 | } 73 | 74 | await _initSemaphore.WaitAsync(); 75 | if (_gameIds != null) 76 | { 77 | return; 78 | } 79 | 80 | var gameIds = await _queuePersistence.Load(); 81 | _gameIds = new ConcurrentQueue(gameIds); 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /GGDeals/Settings/SettingsMigrator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using GGDeals.Settings.Old; 3 | 4 | namespace GGDeals.Settings 5 | { 6 | public class SettingsMigrator : ISettingsMigrator 7 | { 8 | private readonly IPluginSettingsPersistence _pluginSettingsPersistence; 9 | 10 | public SettingsMigrator(IPluginSettingsPersistence pluginSettingsPersistence) 11 | { 12 | _pluginSettingsPersistence = pluginSettingsPersistence; 13 | } 14 | 15 | public GGDealsSettings LoadAndMigrateToNewest(int version) 16 | { 17 | IVersionedSettings versionedSettings; 18 | switch (version) 19 | { 20 | case 0: 21 | versionedSettings = _pluginSettingsPersistence.LoadPluginSettings(); 22 | break; 23 | //case 1: 24 | // versionedSettings = _pluginSettingsPersistence.LoadPluginSettings(); 25 | // break; 26 | //case 2: 27 | // versionedSettings = _pluginSettingsPersistence.LoadPluginSettings(); 28 | // break; 29 | 30 | default: 31 | throw new ArgumentException($"Version v{version} not configured in the migrator"); 32 | } 33 | 34 | while (true) 35 | { 36 | if (versionedSettings is GGDealsSettings newestSettings) 37 | { 38 | return newestSettings; 39 | } 40 | 41 | var oldSettings = versionedSettings as IMigratableSettings; 42 | if (oldSettings == null) 43 | { 44 | throw new Exception($"Somehow v{oldSettings.Version} settings are not migratable. This should have never happened. What have you done?"); 45 | } 46 | 47 | var newSettings = oldSettings.Migrate(); 48 | if (newSettings.Version != oldSettings.Version + 1) 49 | { 50 | throw new Exception($"Invalid migration in v{oldSettings.Version} - version changed to v{newSettings.Version}, but only allowed to increment by one."); 51 | } 52 | 53 | versionedSettings = newSettings; 54 | } 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /GGDeals/Settings/GGDealsSettings.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using System; 3 | using System.Collections.Generic; 4 | 5 | namespace GGDeals.Settings 6 | { 7 | public class GGDealsSettings : ObservableObject, IVersionedSettings 8 | { 9 | public const int CurrentVersion = 1; 10 | 11 | private string _authenticationToken; 12 | private string _devCollectionImportEndpoint; 13 | private bool _addLinksToGames; 14 | private bool _addTagsToGames; 15 | private bool _syncNewlyAddedGames; 16 | private bool _showProgressBar; 17 | 18 | public GGDealsSettings() 19 | { 20 | Version = CurrentVersion; 21 | } 22 | 23 | public static GGDealsSettings Default => 24 | new GGDealsSettings 25 | { 26 | LibrariesToSkip = new List() 27 | { 28 | Guid.Parse("CB91DFC9-B977-43BF-8E70-55F46E410FAB"), // Steam 29 | Guid.Parse("AEBE8B7C-6DC3-4A66-AF31-E7375C6B5E9E"), // GOG 30 | Guid.Parse("03689811-3F33-4DFB-A121-2EE168FB9A5C"), // GOG OSS 31 | }, 32 | SyncPlayniteLibrary = false, 33 | AddLinksToGames = false, 34 | AddTagsToGames = true, 35 | SyncNewlyAddedGames = false, 36 | ShowProgressBar = true, 37 | }; 38 | 39 | public int Version { get; set; } 40 | 41 | public string AuthenticationToken 42 | { 43 | get => _authenticationToken; 44 | set => SetValue(ref _authenticationToken, value); 45 | } 46 | 47 | public List LibrariesToSkip { get; set; } 48 | 49 | public Dictionary LibraryMapOverride { get; set; } = new Dictionary(); 50 | 51 | public bool SyncPlayniteLibrary { get; set; } 52 | 53 | public string DevCollectionImportEndpoint 54 | { 55 | get => _devCollectionImportEndpoint; 56 | set => SetValue(ref _devCollectionImportEndpoint, value); 57 | } 58 | 59 | public bool AddLinksToGames 60 | { 61 | get => _addLinksToGames; 62 | set => SetValue(ref _addLinksToGames, value); 63 | } 64 | 65 | public bool AddTagsToGames 66 | { 67 | get => _addTagsToGames; 68 | set => SetValue(ref _addTagsToGames, value); 69 | } 70 | 71 | public bool SyncNewlyAddedGames 72 | { 73 | get => _syncNewlyAddedGames; 74 | set => SetValue(ref _syncNewlyAddedGames, value); 75 | } 76 | 77 | public bool ShowProgressBar 78 | { 79 | get => _showProgressBar; 80 | set => SetValue(ref _showProgressBar, value); 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /ReleaseTools.UnitTests/Changelog/ChangelogParserTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using AutoFixture.Xunit2; 4 | using ReleaseTools.Changelog; 5 | using Xunit; 6 | 7 | namespace ReleaseTools.UnitTests.Changelog 8 | { 9 | public class ChangelogParserTests 10 | { 11 | [Theory] 12 | [InlineAutoData("")] 13 | [InlineAutoData((string)null)] 14 | public void Parse_ThrowsException_When_EmptyInput( 15 | string input, 16 | ChangelogParser sut) 17 | { 18 | // Act 19 | var act = new Action(() => sut.Parse(input)); 20 | 21 | // Assert 22 | Assert.Throws(act); 23 | } 24 | 25 | [Theory] 26 | [InlineAutoData("Good Day!")] 27 | [InlineAutoData("- Changed things for better")] 28 | [InlineAutoData("vA.B.C")] 29 | [InlineAutoData("v1.2.3.4")] 30 | [InlineAutoData("v1.2.3 and something more!")] 31 | [InlineAutoData("This is v1.2.3")] 32 | public void Parse_ThrowsException_When_FirstLineIsNotAValidVersion( 33 | string input, 34 | ChangelogParser sut) 35 | { 36 | // Act 37 | var act = new Action(() => sut.Parse(input)); 38 | 39 | // Assert 40 | Assert.Throws(act); 41 | } 42 | 43 | [Theory, AutoData] 44 | public void Parse_ReturnsVersion_When_InputHasAVersion( 45 | ChangelogParser sut) 46 | { 47 | // Arrange 48 | var input = @"v1.2.3 49 | - Change 1 50 | "; 51 | 52 | // Act 53 | var result = sut.Parse(input); 54 | 55 | // Assert 56 | Assert.Equal("1.2.3", result.Version); 57 | } 58 | 59 | [Theory, AutoData] 60 | public void Parse_ReturnsChanges_When_InputHasChangeItems( 61 | ChangelogParser sut) 62 | { 63 | // Arrange 64 | var input = @"v1.2.3 65 | - Change 1 66 | - Change 2 67 | "; 68 | 69 | // Act 70 | var result = sut.Parse(input); 71 | 72 | // Assert 73 | Assert.Equal(2, result.Changes.Length); 74 | Assert.Equal("- Change 1", result.Changes.First()); 75 | Assert.Equal("- Change 2", result.Changes.Last()); 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /GGDeals.IntegrationTests/Queue/QueuePersistenceTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture.Xunit2; 2 | using GGDeals.Queue; 3 | using Newtonsoft.Json; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.Threading.Tasks; 8 | using Xunit; 9 | 10 | namespace GGDeals.IntegrationTests.Queue 11 | { 12 | public class QueuePersistenceTests 13 | { 14 | private const string FailuresFilePath = "queue.json"; 15 | 16 | [Fact] 17 | public async Task Load_ReturnsEmptyDictionary_WhenFileDoesNotExist() 18 | { 19 | // Arrange 20 | EnsureFileDoesNotExist(); 21 | var sut = CreateSut(); 22 | 23 | // Act 24 | var result = await sut.Load(); 25 | 26 | // Assert 27 | Assert.Empty(result); 28 | } 29 | 30 | [Theory] 31 | [AutoData] 32 | public async Task Load_ReturnsContentsOfFile_WhenFileExists(QueueFile file) 33 | { 34 | // Arrange 35 | File.WriteAllText(FailuresFilePath, JsonConvert.SerializeObject(file)); 36 | var sut = CreateSut(); 37 | 38 | // Act 39 | var result = await sut.Load(); 40 | 41 | // Assert 42 | Assert.Equal(file.GameIds, result); 43 | } 44 | 45 | [Theory] 46 | [AutoData] 47 | public async Task Save_CreatesNewFile_WhenFileDoesNotExist( 48 | List newContents) 49 | { 50 | // Arrange 51 | EnsureFileDoesNotExist(); 52 | var sut = CreateSut(); 53 | 54 | // Act 55 | await sut.Save(newContents); 56 | 57 | // Assert 58 | var result = ReadFile(); 59 | Assert.Equal(newContents, result); 60 | } 61 | 62 | [Theory] 63 | [AutoData] 64 | public async Task Save_OverwritesFile_WhenFileExists( 65 | QueueFile originalContents, 66 | List newContents) 67 | { 68 | // Arrange 69 | File.WriteAllText(FailuresFilePath, JsonConvert.SerializeObject(originalContents)); 70 | var sut = CreateSut(); 71 | 72 | // Act 73 | await sut.Save(newContents); 74 | 75 | // Assert 76 | var result = ReadFile(); 77 | Assert.Equal(newContents, result); 78 | } 79 | 80 | private QueuePersistence CreateSut() 81 | { 82 | return new QueuePersistence(FailuresFilePath); 83 | } 84 | 85 | private void EnsureFileDoesNotExist() 86 | { 87 | if (File.Exists(FailuresFilePath)) 88 | { 89 | File.Delete(FailuresFilePath); 90 | } 91 | } 92 | 93 | private static IReadOnlyCollection ReadFile() 94 | { 95 | var file = JsonConvert.DeserializeObject(File.ReadAllText(FailuresFilePath)); 96 | return file.GameIds; 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /GGDeals/Menu/AddGames/MVVM/AddGamesViewModel.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Models; 2 | using Playnite.SDK; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Windows; 7 | using System.Windows.Input; 8 | using Playnite.SDK.Models; 9 | 10 | namespace GGDeals.Menu.AddGames.MVVM 11 | { 12 | public class AddGamesViewModel : ObservableObject, IViewModelForWindow 13 | { 14 | private readonly GGDeals _plugin; 15 | private readonly List _games; 16 | private readonly Guid? _duplicateHiderTagId; 17 | private Window _window; 18 | private bool _addNew; 19 | private bool _addSynced; 20 | private bool _addNotFound; 21 | private bool _addIgnored; 22 | 23 | public AddGamesViewModel(GGDeals plugin, List games) 24 | { 25 | _plugin = plugin; 26 | _games = games; 27 | _duplicateHiderTagId = plugin.PlayniteApi.Database.Tags?.FirstOrDefault(t => t.Name == "[DH] Hidden")?.Id; 28 | 29 | AddNew = true; 30 | } 31 | 32 | public void AssociateWindow(Window window) 33 | { 34 | _window = window; 35 | } 36 | 37 | public bool AddNew 38 | { 39 | get => _addNew; 40 | set => SetValue(ref _addNew, value); 41 | } 42 | 43 | public bool AddSynced 44 | { 45 | get => _addSynced; 46 | set => SetValue(ref _addSynced, value); 47 | } 48 | 49 | public bool AddNotFound 50 | { 51 | get => _addNotFound; 52 | set => SetValue(ref _addNotFound, value); 53 | } 54 | 55 | public bool AddIgnored 56 | { 57 | get => _addIgnored; 58 | set => SetValue(ref _addIgnored, value); 59 | } 60 | 61 | // ReSharper disable once UnusedMember.Global 62 | public ICommand AddAllGames => new RelayCommand(() => 63 | { 64 | var games = _games.Where(game => 65 | !game.Hidden || game.Hidden && _duplicateHiderTagId != null && 66 | (game.TagIds?.Contains(_duplicateHiderTagId.Value) ?? false)).ToList(); 67 | 68 | var syncRunSettings = new SyncRunSettings 69 | { 70 | StatusesToSync = new List() 71 | }; 72 | 73 | if (AddNew) 74 | { 75 | syncRunSettings.StatusesToSync.Add(AddToCollectionResult.New); 76 | } 77 | 78 | if (AddSynced) 79 | { 80 | syncRunSettings.StatusesToSync.Add(AddToCollectionResult.Synced); 81 | } 82 | 83 | if (AddNotFound) 84 | { 85 | syncRunSettings.StatusesToSync.Add(AddToCollectionResult.NotFound); 86 | } 87 | 88 | if (AddIgnored) 89 | { 90 | syncRunSettings.StatusesToSync.Add(AddToCollectionResult.Ignored); 91 | } 92 | 93 | _plugin.AddGamesToGGCollection(games, syncRunSettings); 94 | OnAddingGamesInitiated(); 95 | }); 96 | 97 | protected virtual void OnAddingGamesInitiated() 98 | { 99 | _window.Close(); 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /GGDeals/Api/Services/GGDealsApiClient.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using GGDeals.Settings; 3 | using Newtonsoft.Json; 4 | using Playnite.SDK; 5 | using System; 6 | using System.Net; 7 | using System.Net.Http; 8 | using System.Security.Authentication; 9 | using System.Text; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | 13 | namespace GGDeals.Api.Services 14 | { 15 | public class GGDealsApiClient : IGGDealsApiClient 16 | { 17 | private static readonly ILogger Logger = LogManager.GetLogger(); 18 | private static string _endpoint = "https://api.gg.deals/playnite/collection/import/"; 19 | private readonly JsonSerializerSettings _jsonSerializerSettings; 20 | private readonly HttpClient _httpClient; 21 | 22 | public GGDealsApiClient(GGDealsSettings settings, JsonSerializerSettings jsonSerializerSettings) 23 | { 24 | _jsonSerializerSettings = jsonSerializerSettings; 25 | _httpClient = new HttpClient(); 26 | _httpClient.Timeout = TimeSpan.FromMinutes(5); 27 | 28 | #if DEBUG 29 | if (!string.IsNullOrWhiteSpace(settings.DevCollectionImportEndpoint)) 30 | { 31 | _endpoint = settings.DevCollectionImportEndpoint; 32 | } 33 | #endif 34 | } 35 | 36 | public async Task ImportGames(ImportRequest request, CancellationToken ct) 37 | { 38 | var content = PrepareContent(request); 39 | 40 | var response = await _httpClient.PostAsync(_endpoint, content, ct); 41 | var responseString = await response.Content.ReadAsStringAsync(); 42 | Logger.Info($"Response from GG.Deals: Status: {response.StatusCode}; Body {responseString}"); 43 | if (response.StatusCode == HttpStatusCode.Unauthorized) 44 | { 45 | throw new AuthenticationException(responseString); 46 | } 47 | 48 | return ParseResponse(responseString); 49 | } 50 | 51 | private StringContent PrepareContent(ImportRequest request) 52 | { 53 | var requestJson = JsonConvert.SerializeObject(request, _jsonSerializerSettings); 54 | LogRequest(requestJson); 55 | 56 | var content = new StringContent(requestJson, Encoding.UTF8, "application/json"); 57 | return content; 58 | } 59 | 60 | private ImportResponse ParseResponse(string responseContent) 61 | { 62 | var importResponse = JsonConvert.DeserializeObject(responseContent, _jsonSerializerSettings); 63 | if (!importResponse.Success) 64 | { 65 | var failedResponse = JsonConvert.DeserializeObject(responseContent, _jsonSerializerSettings); 66 | throw new ApiException(failedResponse.Data.Message); 67 | } 68 | 69 | return importResponse; 70 | } 71 | 72 | private static void LogRequest(string requestJson) 73 | { 74 | var requestCopy = JsonConvert.DeserializeObject(requestJson); 75 | requestCopy.Token = "***REDACTED***"; 76 | var jsonForLogging = JsonConvert.SerializeObject(requestCopy); 77 | Logger.Debug($"Calling GG.Deals with body: {jsonForLogging}"); 78 | } 79 | 80 | public void Dispose() 81 | { 82 | _httpClient.Dispose(); 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /GGDeals.UnitTests/Services/AddLinkServiceTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture.Xunit2; 2 | using Moq; 3 | using Playnite.SDK; 4 | using Playnite.SDK.Models; 5 | using System; 6 | using System.Net.Mime; 7 | using System.Windows.Threading; 8 | using GGDeals.Services; 9 | using TestTools.Shared; 10 | using Xunit; 11 | 12 | namespace GGDeals.UnitTests.Services 13 | { 14 | public class AddLinkServiceTests 15 | { 16 | 17 | [Theory] 18 | [InlineAutoMoqData(null)] 19 | [InlineAutoMoqData("")] 20 | public void AddLink_ThrowsException_WhenUrlIsEmpty( 21 | string url, 22 | Game game, 23 | AddLinkService sut) 24 | { 25 | // Act 26 | var exception = Record.Exception(() => sut.AddLink(game, url)); 27 | 28 | // Assert 29 | Assert.NotNull(exception); 30 | Assert.IsType(exception); 31 | } 32 | 33 | [Theory] 34 | [AutoMoqData] 35 | public void AddLink_AddsLink_ToExistingList( 36 | [Frozen] Mock playniteApiMock, 37 | [Frozen] Mock mainViewApiMock, 38 | string url, 39 | Game game, 40 | AddLinkService sut) 41 | { 42 | // Arrange 43 | playniteApiMock.SetupGet(x => x.MainView).Returns(mainViewApiMock.Object); 44 | mainViewApiMock.SetupGet(x => x.UIDispatcher).Returns(Dispatcher.CurrentDispatcher); 45 | 46 | // Act 47 | sut.AddLink(game, url); 48 | 49 | // Assert 50 | var link = Assert.Single(game.Links, l => l.Url == url); 51 | Assert.Equal(url, link.Url); 52 | Assert.Equal("GG.deals", link.Name); 53 | playniteApiMock.Verify(a => a.Database.Games.Update(It.Is(g => g == game)), Times.Once); 54 | } 55 | 56 | [Theory] 57 | [AutoMoqData] 58 | public void AddLink_AddsLink_ToEmptyList( 59 | [Frozen] Mock playniteApiMock, 60 | [Frozen] Mock mainViewApiMock, 61 | string url, 62 | Game game, 63 | AddLinkService sut) 64 | { 65 | // Arrange 66 | game.Links = null; 67 | playniteApiMock.SetupGet(x => x.MainView).Returns(mainViewApiMock.Object); 68 | mainViewApiMock.SetupGet(x => x.UIDispatcher).Returns(Dispatcher.CurrentDispatcher); 69 | 70 | // Act 71 | sut.AddLink(game, url); 72 | 73 | // Assert 74 | var link = Assert.Single(game.Links, l => l.Url == url); 75 | Assert.Equal(url, link.Url); 76 | Assert.Equal("GG.deals", link.Name); 77 | playniteApiMock.Verify(a => a.Database.Games.Update(It.Is(g => g == game)), Times.Once); 78 | } 79 | 80 | [Theory] 81 | [AutoMoqData] 82 | public void AddLink_DoesNotAddLink_WhenLinkIsAlreadyInList( 83 | [Frozen] Mock playniteApiMock, 84 | string url, 85 | Game game, 86 | AddLinkService sut) 87 | { 88 | // Arrange 89 | game.Links.Add(new Link() { Url = url }); 90 | 91 | // Act 92 | sut.AddLink(game, url); 93 | 94 | // Assert 95 | var link = Assert.Single(game.Links, l => l.Url == url); 96 | Assert.Equal(url, link.Url); 97 | playniteApiMock.Verify(a => a.Database.Games.Update(It.IsAny()), Times.Never); 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /GGDeals.UnitTests/Services/GameToAddFilterTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture.Xunit2; 2 | using GGDeals.Models; 3 | using GGDeals.Services; 4 | using GGDeals.Settings; 5 | using Moq; 6 | using Playnite.SDK.Models; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.Linq; 10 | using TestTools.Shared; 11 | using Xunit; 12 | 13 | namespace GGDeals.UnitTests.Services 14 | { 15 | public class GameToAddFilterTests 16 | { 17 | [Theory] 18 | [AutoMoqData] 19 | public void ShouldTryAddGame_ReturnsFalse_WhenLibraryIsSkippedInSettings( 20 | [Frozen] GGDealsSettings settings, 21 | Game game, 22 | GameToAddFilter sut) 23 | { 24 | // Arrange 25 | game.PluginId = settings.LibrariesToSkip.Last(); 26 | 27 | // Act 28 | var result = sut.ShouldTryAddGame(game, out var status); 29 | 30 | // Assert 31 | Assert.False(result); 32 | Assert.Equal(AddToCollectionResult.SkippedDueToLibrary, status.Result); 33 | } 34 | 35 | [Theory] 36 | [AutoMoqData] 37 | public void ShouldTryAddGame_ReturnsFalse_WhenLibraryIsPlayniteAndSkippedInSettings( 38 | [Frozen] GGDealsSettings settings, 39 | Game game, 40 | GameToAddFilter sut) 41 | { 42 | // Arrange 43 | game.PluginId = Guid.Empty; 44 | settings.SyncPlayniteLibrary = false; 45 | 46 | // Act 47 | var result = sut.ShouldTryAddGame(game, out var status); 48 | 49 | // Assert 50 | Assert.False(result); 51 | Assert.Equal(AddToCollectionResult.SkippedDueToLibrary, status.Result); 52 | } 53 | 54 | [Theory] 55 | [InlineAutoMoqData(AddToCollectionResult.Added, AddToCollectionResult.Ignored)] 56 | public void ShouldTryAddGame_False_WhenStatusIsNotInSyncRunSettings( 57 | AddToCollectionResult allowedStatus, 58 | AddToCollectionResult actualStatus, 59 | [Frozen] Mock gameStatusServiceMock, 60 | GGDealsSettings settings, 61 | Game game) 62 | { 63 | // Arrange 64 | var syncRunSettings = new SyncRunSettings { StatusesToSync = new List { allowedStatus } }; 65 | var sut = new GameToAddFilter(settings, gameStatusServiceMock.Object, syncRunSettings); 66 | gameStatusServiceMock.Setup(s => s.GetStatus(game)).Returns(actualStatus); 67 | 68 | // Act 69 | var result = sut.ShouldTryAddGame(game, out var status); 70 | 71 | // Assert 72 | Assert.False(result); 73 | Assert.Equal(actualStatus, status.Result); 74 | } 75 | 76 | [Theory] 77 | [AutoMoqData] 78 | public void ShouldTryAddGame_True_WhenStatusIsInSyncRunSettings( 79 | [Frozen] Mock gameStatusServiceMock, 80 | AddToCollectionResult allowedStatus, 81 | GGDealsSettings settings, 82 | Game game) 83 | { 84 | // Arrange 85 | var syncRunSettings = new SyncRunSettings { StatusesToSync = new List { allowedStatus } }; 86 | var sut = new GameToAddFilter(settings, gameStatusServiceMock.Object, syncRunSettings); 87 | gameStatusServiceMock.Setup(s => s.GetStatus(game)).Returns(allowedStatus); 88 | 89 | // Act 90 | var result = sut.ShouldTryAddGame(game, out var status); 91 | 92 | // Assert 93 | Assert.True(result); 94 | Assert.Null(status); 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /GGDeals/Menu/Failures/AddFailuresManager.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Menu.Failures.File; 2 | using GGDeals.Services; 3 | using Playnite.SDK; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using GGDeals.Models; 10 | 11 | namespace GGDeals.Menu.Failures 12 | { 13 | public class AddFailuresManager : IAddFailuresManager, IDisposable 14 | { 15 | private const double SemaphoreTimeoutSeconds = 10; 16 | private readonly ILogger _logger = LogManager.GetLogger(); 17 | private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); 18 | private readonly IAddFailuresFileService _addFailuresFileService; 19 | 20 | private Dictionary _failures; 21 | 22 | public AddFailuresManager(IAddFailuresFileService addFailuresFileService) 23 | { 24 | _addFailuresFileService = addFailuresFileService; 25 | } 26 | 27 | public async Task AddFailures(IDictionary failures) 28 | { 29 | try 30 | { 31 | await _semaphore.WaitAsync(TimeSpan.FromSeconds(SemaphoreTimeoutSeconds)); 32 | await EnsureFailuresAreLoaded(); 33 | 34 | if (failures.All(f => _failures.ContainsKey(f.Key) && _failures[f.Key] == f.Value)) 35 | { 36 | return; 37 | } 38 | 39 | foreach (var failure in failures) 40 | { 41 | _failures[failure.Key] = failure.Value; 42 | } 43 | 44 | await _addFailuresFileService.Save(_failures); 45 | } 46 | catch (Exception ex) 47 | { 48 | _logger.Error(ex, "Failure while adding failure."); 49 | throw; 50 | } 51 | finally 52 | { 53 | _semaphore.Release(); 54 | } 55 | } 56 | 57 | public async Task RemoveFailures(IReadOnlyCollection gameIds) 58 | { 59 | try 60 | { 61 | await _semaphore.WaitAsync(TimeSpan.FromSeconds(SemaphoreTimeoutSeconds)); 62 | await EnsureFailuresAreLoaded(); 63 | if (gameIds.All(g => !_failures.ContainsKey(g))) 64 | { 65 | return; 66 | } 67 | 68 | foreach (var gameId in gameIds) 69 | { 70 | _failures.Remove(gameId); 71 | } 72 | 73 | await _addFailuresFileService.Save(_failures); 74 | } 75 | catch (Exception ex) 76 | { 77 | _logger.Error(ex, "Failure while removing failure."); 78 | throw; 79 | } 80 | finally 81 | { 82 | _semaphore.Release(); 83 | } 84 | } 85 | 86 | public async Task> GetFailures() 87 | { 88 | try 89 | { 90 | await _semaphore.WaitAsync(TimeSpan.FromSeconds(SemaphoreTimeoutSeconds)); 91 | await EnsureFailuresAreLoaded(); 92 | 93 | return new Dictionary(_failures); 94 | } 95 | catch (Exception ex) 96 | { 97 | _logger.Error(ex, "Failure while getting failures."); 98 | throw; 99 | } 100 | finally 101 | { 102 | _semaphore.Release(); 103 | } 104 | } 105 | 106 | public void Dispose() 107 | { 108 | _semaphore.Dispose(); 109 | } 110 | 111 | private async Task EnsureFailuresAreLoaded() 112 | { 113 | if (_failures == null) 114 | { 115 | _failures = await _addFailuresFileService.Load(); 116 | } 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /GGDeals.UnitTests/Api/Services/GameToGameWithLauncherConverterTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture.Xunit2; 2 | using GGDeals.Api.Models; 3 | using GGDeals.Api.Services; 4 | using GGDeals.Settings; 5 | using Moq; 6 | using Playnite.SDK.Models; 7 | using TestTools.Shared; 8 | using Xunit; 9 | 10 | namespace GGDeals.UnitTests.Api.Services 11 | { 12 | public class GameToGameWithLauncherConverterTests 13 | { 14 | [Theory] 15 | [AutoMoqData] 16 | public void GetGameWithLauncher( 17 | [Frozen] Mock libraryToGGLauncherMapMock, 18 | Game game, 19 | GGLauncher ggLauncher, 20 | GameToGameWithLauncherConverter sut) 21 | { 22 | // Arrange 23 | libraryToGGLauncherMapMock.Setup(x => x.GetGGLauncher(game.PluginId)).Returns(ggLauncher); 24 | 25 | // Act 26 | var result = sut.GetGameWithLauncher(game); 27 | 28 | // Assert 29 | Assert.Equal(ggLauncher, result.GGLauncher); 30 | Assert.Equal(game.Id, result.Id); 31 | Assert.Equal(game.GameId, result.GameId); 32 | Assert.Equal(game.Links, result.Links); 33 | Assert.Equal(game.Source, result.Source); 34 | Assert.Equal(game.ReleaseDate, result.ReleaseDate); 35 | Assert.Equal(game.ReleaseYear, result.ReleaseYear); 36 | Assert.Equal(game.Name, result.Name); 37 | } 38 | 39 | [Theory] 40 | [AutoMoqData] 41 | public void GetGameWithLauncher_WhenLauncherOverridenInSettings( 42 | [Frozen] Mock libraryToGGLauncherMapMock, 43 | [Frozen] GGDealsSettings settings, 44 | Game game, 45 | GGLauncher ggLauncherFromMap, 46 | GGLauncher ggLauncherFromSettings, 47 | GameToGameWithLauncherConverter sut) 48 | { 49 | // Arrange 50 | libraryToGGLauncherMapMock.Setup(x => x.GetGGLauncher(game.PluginId)).Returns(ggLauncherFromMap); 51 | settings.LibraryMapOverride[game.PluginId] = ggLauncherFromSettings; 52 | 53 | // Act 54 | var result = sut.GetGameWithLauncher(game); 55 | 56 | // Assert 57 | Assert.Equal(ggLauncherFromSettings, result.GGLauncher); 58 | Assert.Equal(game.Id, result.Id); 59 | Assert.Equal(game.GameId, result.GameId); 60 | Assert.Equal(game.Links, result.Links); 61 | Assert.Equal(game.Source, result.Source); 62 | Assert.Equal(game.ReleaseDate, result.ReleaseDate); 63 | Assert.Equal(game.ReleaseYear, result.ReleaseYear); 64 | Assert.Equal(game.Name, result.Name); 65 | } 66 | 67 | [Theory] 68 | [AutoMoqData] 69 | public void GetGameWithLauncher_WhenLinksAreNull( 70 | [Frozen] Mock libraryToGGLauncherMapMock, 71 | Game game, 72 | GGLauncher ggLauncher, 73 | GameToGameWithLauncherConverter sut) 74 | { 75 | // Arrange 76 | game.Links = null; 77 | libraryToGGLauncherMapMock.Setup(x => x.GetGGLauncher(game.PluginId)).Returns(ggLauncher); 78 | 79 | // Act 80 | var result = sut.GetGameWithLauncher(game); 81 | 82 | // Assert 83 | Assert.Equal(ggLauncher, result.GGLauncher); 84 | Assert.Equal(game.Id, result.Id); 85 | Assert.Equal(game.GameId, result.GameId); 86 | Assert.Equal(game.Links, result.Links); 87 | Assert.Equal(game.Source, result.Source); 88 | Assert.Equal(game.ReleaseDate, result.ReleaseDate); 89 | Assert.Equal(game.ReleaseYear, result.ReleaseYear); 90 | Assert.Equal(game.Name, result.Name); 91 | } 92 | } 93 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Playnite.GGDeals 2 | ![DownloadCountTotal](https://img.shields.io/github/downloads/sparrowbrain/playnite.ggdeals/total?label=total%20downloads&style=for-the-badge) 3 | ![LatestVersion](https://img.shields.io/github/v/release/SparrowBrain/Playnite.GGDeals?label=Latest%20version&style=for-the-badge) 4 | ![DownloadCountLatest](https://img.shields.io/github/downloads/SparrowBrain/Playnite.GGDeals/latest/total?style=for-the-badge) 5 | 6 | ## What Is It? 7 | Playnite extension to sync library with GG.deals website. 8 | 9 | You can add games via: 10 | * Game menu; 11 | * Extensions menu; 12 | * Games are added to GG.deals automatically when you add them to your library, after the metadata is fetched. 13 | 14 | ![Main NextPlay view screenshot](/ci/screenshots/01.jpg) 15 | ![Main NextPlay view screenshot](/ci/screenshots/02.jpg) 16 | ![Main NextPlay view screenshot](/ci/screenshots/03.jpg) 17 | 18 | Currently there is no way to remove games from GG.deals via this extension, and it's not planned. 19 | 20 | ## How to use it? 21 | In order to use this extension, you will need to generate an authentication token on [GG.deals website](https://gg.deals/): 22 | 1. Login 23 | 2. Go to [Settings](https://gg.deals/settings/) (link here, or click on your avatar top right) 24 | 3. Connect to Playnite 25 | 4. Copy the generated token 26 | 27 | And then in Playnite: 28 | 1. Playnite menu 29 | 2. Add-ons... 30 | 3. Extensions settings 31 | 4. Generic 32 | 5. GG.deals 33 | 6. Paste the token into "Authentication Token" field 34 | 35 | ![Main NextPlay view screenshot](/ci/screenshots/05.jpg) 36 | 37 | You can also allow the extension to add GG.deals link to processed game's page. 38 | 39 | ## How does it work? 40 | It uses GG.deals API to import the games. The extension sends these game fields: 41 | * Id 42 | * GameId 43 | * Links 44 | * Source 45 | * ReleaseDate 46 | * ReleaseYear 47 | * Name 48 | * ggLauncher (custom fields that depends on game's library plugin ID) 49 | 50 | GG.deals then uses this data to match the game with one on their database. API can return these outcomes: 51 | * Added - game was added to your collection 52 | * Skipped - game was skipped, because it was already in your collection 53 | * Ignored - the item was ignored, because it's not applicable for GG.deals (demo, beta, or something similar) 54 | * Miss - game was not found in GG.deals database 55 | * Error - there was an error while adding the game 56 | 57 | In case of errors and misses, you can check the failures list for more information. 58 | 59 | ![Main NextPlay view screenshot](/ci/screenshots/04.jpg) 60 | 61 | ## Disclaimers 62 | * While this extension uses GG.deals API and received support from the devs, it is not an official extension. 63 | * The extension sends the game data listed above to the API for matching. Don't store any personal data there. 64 | * API matching accuracy will depend on the quality of your games' metadata. As of writing, links are very important for accurate matching. 65 | * The extension adds tags with GG.deals status to your games (for example `[GGDeals] Synced`). This allows the extension to track which games were synced, etc. 66 | 67 | ## Installation 68 | You can install it either from Playnite's addon browser, or from [the web addon browser](https://playnite.link/addons.html#SparrowBrain_GGDeals). 69 | 70 | ## Translation 71 | You can help with translation by visiting [the project on Crowdin](https://crowdin.com/project/sparrowbrain-playnite-ggdeals). 72 | -------------------------------------------------------------------------------- /ReleaseTools/ReleaseTools.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {5C1FF6FE-5A69-4F8D-B161-75F58DB89666} 8 | Exe 9 | ReleaseTools 10 | ReleaseTools 11 | v4.6.2 12 | 512 13 | true 14 | true 15 | 16 | 17 | AnyCPU 18 | true 19 | full 20 | false 21 | bin\Debug\ 22 | DEBUG;TRACE 23 | prompt 24 | 4 25 | 26 | 27 | AnyCPU 28 | pdbonly 29 | true 30 | bin\Release\ 31 | TRACE 32 | prompt 33 | 4 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /GGDeals.IntegrationTests/Menu/Failures/File/AddFailuresFileServiceTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture.Xunit2; 2 | using GGDeals.Menu.Failures.File; 3 | using GGDeals.Services; 4 | using Newtonsoft.Json; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Threading.Tasks; 9 | using GGDeals.Models; 10 | using Xunit; 11 | 12 | namespace GGDeals.IntegrationTests.Menu.Failures.File 13 | { 14 | public class AddFailuresFileServiceTests 15 | { 16 | private const string FailuresFilePath = "failures.json"; 17 | 18 | [Fact] 19 | public async Task Load_ReturnsEmptyDictionary_WhenFileDoesNotExist() 20 | { 21 | // Arrange 22 | EnsureFileDoesNotExist(); 23 | var sut = CreateSut(); 24 | 25 | // Act 26 | var result = await sut.Load(); 27 | 28 | // Assert 29 | Assert.Empty(result); 30 | } 31 | 32 | [Theory] 33 | [AutoData] 34 | public async Task Load_ReturnsContentsOfFile_WhenFileExists( 35 | Dictionary contents) 36 | { 37 | // Arrange 38 | EnsureFileExists(contents); 39 | var sut = CreateSut(); 40 | 41 | // Act 42 | var result = await sut.Load(); 43 | 44 | // Assert 45 | Assert.Equivalent(contents, result); 46 | } 47 | 48 | [Theory] 49 | [AutoData] 50 | public async Task Load_ReturnsContentsOfFile_WhenFileIsV0( 51 | Dictionary contents) 52 | { 53 | // Arrange 54 | EnsureV0FileExists(contents); 55 | var sut = CreateSut(); 56 | 57 | // Act 58 | var result = await sut.Load(); 59 | 60 | // Assert 61 | Assert.Equivalent(contents.ToDictionary(x => x.Key, x => new AddResult() { Result = x.Value }), result); 62 | } 63 | 64 | [Theory] 65 | [AutoData] 66 | public async Task Save_CreatesNewFile_WhenFileDoesNotExist( 67 | Dictionary newContents) 68 | { 69 | // Arrange 70 | EnsureFileDoesNotExist(); 71 | var sut = CreateSut(); 72 | 73 | // Act 74 | await sut.Save(newContents); 75 | 76 | // Assert 77 | var result = ReadFile(); 78 | Assert.Equivalent(newContents, result); 79 | } 80 | 81 | [Theory] 82 | [AutoData] 83 | public async Task Save_OverwritesFile_WhenFileExists( 84 | Dictionary originalContents, 85 | Dictionary newContents) 86 | { 87 | // Arrange 88 | EnsureFileExists(originalContents); 89 | var sut = CreateSut(); 90 | 91 | // Act 92 | await sut.Save(newContents); 93 | 94 | // Assert 95 | var result = ReadFile(); 96 | Assert.Equivalent(newContents, result); 97 | } 98 | 99 | private AddFailuresFileService CreateSut() 100 | { 101 | return new AddFailuresFileService(FailuresFilePath); 102 | } 103 | 104 | private void EnsureFileDoesNotExist() 105 | { 106 | if (System.IO.File.Exists(FailuresFilePath)) 107 | { 108 | System.IO.File.Delete(FailuresFilePath); 109 | } 110 | } 111 | 112 | private static void EnsureV0FileExists(Dictionary contents) 113 | { 114 | System.IO.File.WriteAllText(FailuresFilePath, JsonConvert.SerializeObject(contents)); 115 | } 116 | 117 | private static void EnsureFileExists(Dictionary contents) 118 | { 119 | var file = new FailuresFile() { Failures = contents }; 120 | System.IO.File.WriteAllText(FailuresFilePath, JsonConvert.SerializeObject(file)); 121 | } 122 | 123 | private static Dictionary ReadFile() 124 | { 125 | var file = JsonConvert.DeserializeObject(System.IO.File.ReadAllText(FailuresFilePath)); 126 | return file.Failures; 127 | } 128 | } 129 | } -------------------------------------------------------------------------------- /GGDeals.UnitTests/Settings/SettingsMigratorTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using AutoFixture.Xunit2; 5 | using GGDeals.Settings; 6 | using GGDeals.Settings.Old; 7 | using Moq; 8 | using TestTools.Shared; 9 | using Xunit; 10 | 11 | namespace GGDeals.UnitTests.Settings 12 | { 13 | public class SettingsMigratorTests 14 | { 15 | [Theory, AutoMoqData] 16 | public void LoadAndMigrateToNewest_ThrowsException_WhenCalledWithNonConfiguredVersion( 17 | SettingsMigrator sut) 18 | { 19 | // Act 20 | var act = new Action(() => sut.LoadAndMigrateToNewest(int.MaxValue)); 21 | 22 | // Assert 23 | Assert.ThrowsAny(act); 24 | } 25 | 26 | [Theory, MemberAutoMoqData(nameof(GetAllOldSettingsVersions))] 27 | public void LoadAndMigrateToNewest_MigratesAllExistingNonCurrentSettingsVersionsToNewest( 28 | int version, 29 | SettingsMigrator sut) 30 | { 31 | // Act 32 | var result = sut.LoadAndMigrateToNewest(version); 33 | 34 | // Assert 35 | Assert.Equal(GGDealsSettings.CurrentVersion, result.Version); 36 | } 37 | 38 | [Theory] 39 | [InlineAutoMoqData(0)] 40 | [InlineAutoMoqData(+2)] 41 | [InlineAutoMoqData(-1)] 42 | public void LoadAndMigrateToNewest_ThrowsException_WhenSettingsMigratesToNonIncrementedVersion( 43 | int versionIncrement, 44 | [Frozen] Mock pluginSettingsPersistenceMock, 45 | SettingsV0Fake settingsV0Fake, 46 | SettingsMigrator sut) 47 | { 48 | // Arrange 49 | pluginSettingsPersistenceMock.Setup(x => x.LoadPluginSettings()).Returns(settingsV0Fake); 50 | settingsV0Fake.SetupVersionItMigratesTo(settingsV0Fake.Version + versionIncrement); 51 | 52 | // Act 53 | var act = new Action(() => sut.LoadAndMigrateToNewest(settingsV0Fake.Version)); 54 | 55 | // Assert 56 | Assert.ThrowsAny(act); 57 | } 58 | 59 | public static IEnumerable GetAllOldSettingsVersions() 60 | { 61 | var type = typeof(IVersionedSettings); 62 | var types = AppDomain.CurrentDomain.GetAssemblies() 63 | .Where(x => x.FullName.StartsWith("GGDeals")) 64 | .SelectMany(s => s.GetTypes()) 65 | .Where(x => x.IsClass) 66 | .Where(p => type.IsAssignableFrom(p)) 67 | .Where(x => x != typeof(VersionedSettings)) 68 | .Where(x => x != typeof(SettingsV0Fake)); 69 | 70 | var allOldSettingsVersions = types.Select(x => 71 | { 72 | var ctor = x.GetConstructor(new Type[] { }); 73 | object instance = ctor.Invoke(new object[] { }); 74 | return new object[] { (instance as IVersionedSettings).Version }; 75 | }).Where(x => (int)x[0] != GGDealsSettings.CurrentVersion); 76 | 77 | return allOldSettingsVersions; 78 | } 79 | 80 | public class SettingsV0Fake : SettingsV0 81 | { 82 | private int _newVersion; 83 | 84 | public void SetupVersionItMigratesTo(int newVersion) 85 | { 86 | _newVersion = newVersion; 87 | } 88 | 89 | public override IVersionedSettings Migrate() 90 | { 91 | return new SettingsV0() 92 | { 93 | Version = _newVersion 94 | }; 95 | } 96 | } 97 | } 98 | } -------------------------------------------------------------------------------- /ci/installer_manifest.yaml: -------------------------------------------------------------------------------- 1 | AddonId: SparrowBrain_GGDeals 2 | Packages: 3 | - Version: 2.3.0 4 | RequiredApiVersion: 6.11.0 5 | ReleaseDate: 2025-09-01 6 | PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v2.3.0/SparrowBrain_GGDeals_2_3_0.pext 7 | Changelog: 8 | - Added an ability to assign GG.deals library to a Playnite plugin 9 | - Added hawkeye's library plugins for GOG, Legendary (Epic) and Nile (Prime Gaming) to defaults 10 | - Added Portuguese, Brazilian translations (thanks, TheNutellas!) 11 | - Clicking on 'games added' notification now opens the website 12 | - API failures are now propagated to the user 13 | - Version: 2.2.0 14 | RequiredApiVersion: 6.11.0 15 | ReleaseDate: 2024-12-14 16 | PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v2.2.0/SparrowBrain_GGDeals_2_2_0.pext 17 | Changelog: 18 | - Added buffered update (thanks Jeshibu!) 19 | - Improved success notification readability 20 | - Version: 2.1.1 21 | RequiredApiVersion: 6.11.0 22 | ReleaseDate: 2024-09-07 23 | PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v2.1.1/SparrowBrain_GGDeals_2_1_1.pext 24 | Changelog: 25 | - Sync of newly added games is now off by default 26 | - Clearer tooltips in add games window 27 | - Version: 2.1.0 28 | RequiredApiVersion: 6.11.0 29 | ReleaseDate: 2024-09-01 30 | PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v2.1.0/SparrowBrain_GGDeals_2_1_0.pext 31 | Changelog: 32 | - Tags are now optional 33 | - Sync of newly added games is now optional 34 | - Added optional progress bar (on by default) 35 | - Added a scrollbar to the settings window 36 | - Added tooltips to add games window selections 37 | - Improved failures layout 38 | - Fixed a bug where user could issue multiple calls to the API in parallel 39 | - Version: 2.0.0 40 | RequiredApiVersion: 6.11.0 41 | ReleaseDate: 2024-08-21 42 | PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v2.0.0/SparrowBrain_GGDeals_2_0_0.pext 43 | Changelog: 44 | - Calling official GG.deals API instead of scraping the website 45 | - Users now must provide authentication token generated on GG.deals website instead of logging in through the extension 46 | - Processing games only after metadata fetch 47 | - Using custom tags to keep track of sync status 48 | - Added text messages to failures window 49 | - Added sync success notification 50 | - Authentication error notification now opens extension settings when clicked 51 | - Add failure notification now opens add failure window when clicked 52 | - Adding GG.deals link to processed games 53 | - Improved handling for games hidden by Duplicate Hider 54 | - Version: 1.1.0 55 | RequiredApiVersion: 6.11.0 56 | ReleaseDate: 2024-06-24 57 | PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v1.1.0/SparrowBrain_GGDeals_1_1_0.pext 58 | Changelog: 59 | - Added Lithuanian language 60 | - Added skipping of Playnite library (skipped by default) 61 | - Version: 1.0.1 62 | RequiredApiVersion: 6.11.0 63 | ReleaseDate: 2024-05-12 64 | PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v1.0.1/SparrowBrain_GGDeals_1_0_1.pext 65 | Changelog: 66 | - Added missing links 67 | - Version: 1.0.0 68 | RequiredApiVersion: 6.11.0 69 | ReleaseDate: 2024-05-12 70 | PackageUrl: https://github.com/SparrowBrain/Playnite.GGDeals/releases/download/v1.0.0/SparrowBrain_GGDeals_1_0_0.pext 71 | Changelog: 72 | - Initial release 73 | -------------------------------------------------------------------------------- /GGDeals.UnitTests/Settings/StartupSettingsValidatorTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using AutoFixture.Xunit2; 3 | using GGDeals.Settings; 4 | using Moq; 5 | using TestTools.Shared; 6 | using Xunit; 7 | 8 | namespace GGDeals.UnitTests.Settings 9 | { 10 | public class StartupSettingsValidatorTests 11 | { 12 | [Theory, AutoMoqData] 13 | public void EnsureCorrectVersionSettingsExist_CreatesAndSavesNewestVersion_WhenSettingsDontExist( 14 | [Frozen] Mock pluginSettingsPersistenceMock, 15 | StartupSettingsValidator sut) 16 | { 17 | // Arrange 18 | pluginSettingsPersistenceMock.Setup(x => x.LoadPluginSettings()) 19 | .Returns((VersionedSettings)null); 20 | 21 | // Act 22 | sut.EnsureCorrectVersionSettingsExist(); 23 | 24 | // Assert 25 | pluginSettingsPersistenceMock.Verify(x => x.SavePluginSettings(It.IsAny()), 26 | Times.Once()); 27 | } 28 | 29 | [Theory, AutoMoqData] 30 | public void EnsureCorrectVersionSettingsExist_DoesNotSaveNewVersion_WhenSettingsInNewestVersionAlreadyExist( 31 | [Frozen] Mock pluginSettingsPersistenceMock, 32 | VersionedSettings settings, 33 | StartupSettingsValidator sut) 34 | { 35 | // Arrange 36 | settings.Version = GGDealsSettings.CurrentVersion; 37 | pluginSettingsPersistenceMock.Setup(x => x.LoadPluginSettings()).Returns(settings); 38 | 39 | // Act 40 | sut.EnsureCorrectVersionSettingsExist(); 41 | 42 | // Assert 43 | pluginSettingsPersistenceMock.Verify(x => x.SavePluginSettings(It.IsAny()), 44 | Times.Never()); 45 | } 46 | 47 | [Theory, AutoMoqData] 48 | public void 49 | EnsureCorrectVersionSettingsExist_DoesNotMigrateSettings_WhenSettingsInNewestVersionAlreadyExist( 50 | [Frozen] Mock pluginSettingsPersistenceMock, 51 | [Frozen] Mock settingsMigratorMock, 52 | VersionedSettings settings, 53 | StartupSettingsValidator sut) 54 | { 55 | // Arrange 56 | settings.Version = GGDealsSettings.CurrentVersion; 57 | pluginSettingsPersistenceMock.Setup(x => x.LoadPluginSettings()).Returns(settings); 58 | 59 | // Act 60 | sut.EnsureCorrectVersionSettingsExist(); 61 | 62 | // Assert 63 | settingsMigratorMock.Verify(x => x.LoadAndMigrateToNewest(It.IsAny()), Times.Never()); 64 | } 65 | 66 | [Theory, AutoMoqData] 67 | public void EnsureCorrectVersionSettingsExist_MigratesAndSavesSettings_WhenSettingsInOldVersionExist( 68 | [Frozen] Mock pluginSettingsPersistenceMock, 69 | [Frozen] Mock settingsMigratorMock, 70 | VersionedSettings oldSettings, 71 | GGDealsSettings newSettings, 72 | StartupSettingsValidator sut) 73 | { 74 | // Arrange 75 | oldSettings.Version = GGDealsSettings.CurrentVersion - 1; 76 | pluginSettingsPersistenceMock.Setup(x => x.LoadPluginSettings()) 77 | .Returns(oldSettings); 78 | settingsMigratorMock.Setup(x => x.LoadAndMigrateToNewest(It.IsAny())).Returns(newSettings); 79 | 80 | // Act 81 | sut.EnsureCorrectVersionSettingsExist(); 82 | 83 | // Assert 84 | pluginSettingsPersistenceMock.Verify(x => x.SavePluginSettings(newSettings), Times.Once()); 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /GGDeals.UnitTests/TestableItemCollection.cs: -------------------------------------------------------------------------------- 1 | using Playnite.SDK; 2 | using Playnite.SDK.Models; 3 | using System; 4 | using System.Collections; 5 | using System.Collections.Generic; 6 | 7 | namespace GGDeals.UnitTests 8 | { 9 | internal class TestableItemCollection : IItemCollection where T : DatabaseObject 10 | { 11 | private List _items; 12 | 13 | public TestableItemCollection(List items) 14 | { 15 | _items = items; 16 | } 17 | 18 | public int UpdateCount { get; private set; } 19 | 20 | public void Dispose() 21 | { 22 | throw new NotImplementedException(); 23 | } 24 | 25 | public bool ContainsItem(Guid id) 26 | { 27 | throw new NotImplementedException(); 28 | } 29 | 30 | public GameDatabaseCollection CollectionType { get; } 31 | 32 | public IEnumerator GetEnumerator() 33 | { 34 | return _items.GetEnumerator(); 35 | } 36 | 37 | IEnumerator IEnumerable.GetEnumerator() 38 | { 39 | return GetEnumerator(); 40 | } 41 | 42 | public void Add(T item) 43 | { 44 | _items.Add(item); 45 | } 46 | 47 | public void Clear() 48 | { 49 | throw new NotImplementedException(); 50 | } 51 | 52 | public bool Contains(T item) 53 | { 54 | throw new NotImplementedException(); 55 | } 56 | 57 | public void CopyTo(T[] array, int arrayIndex) 58 | { 59 | throw new NotImplementedException(); 60 | } 61 | 62 | public bool Remove(T item) 63 | { 64 | throw new NotImplementedException(); 65 | } 66 | 67 | public int Count { get; } 68 | public bool IsReadOnly { get; } 69 | 70 | public T Get(Guid id) 71 | { 72 | throw new NotImplementedException(); 73 | } 74 | 75 | public List Get(IList ids) 76 | { 77 | throw new NotImplementedException(); 78 | } 79 | 80 | public T Add(string itemName) 81 | { 82 | throw new NotImplementedException(); 83 | } 84 | 85 | public T Add(string itemName, Func existingComparer) 86 | { 87 | throw new NotImplementedException(); 88 | } 89 | 90 | public IEnumerable Add(List items) 91 | { 92 | throw new NotImplementedException(); 93 | } 94 | 95 | public T Add(MetadataProperty property) 96 | { 97 | throw new NotImplementedException(); 98 | } 99 | 100 | public IEnumerable Add(IEnumerable properties) 101 | { 102 | throw new NotImplementedException(); 103 | } 104 | 105 | public IEnumerable Add(List items, Func existingComparer) 106 | { 107 | throw new NotImplementedException(); 108 | } 109 | 110 | public void Add(IEnumerable items) 111 | { 112 | throw new NotImplementedException(); 113 | } 114 | 115 | public bool Remove(Guid id) 116 | { 117 | throw new NotImplementedException(); 118 | } 119 | 120 | public bool Remove(IEnumerable items) 121 | { 122 | throw new NotImplementedException(); 123 | } 124 | 125 | public void Update(T item) 126 | { 127 | UpdateCount++; 128 | } 129 | 130 | public void Update(IEnumerable items) 131 | { 132 | throw new NotImplementedException(); 133 | } 134 | 135 | public IDisposable BufferedUpdate() 136 | { 137 | throw new NotImplementedException(); 138 | } 139 | 140 | public void BeginBufferUpdate() 141 | { 142 | throw new NotImplementedException(); 143 | } 144 | 145 | public void EndBufferUpdate() 146 | { 147 | throw new NotImplementedException(); 148 | } 149 | 150 | public IEnumerable GetClone() 151 | { 152 | throw new NotImplementedException(); 153 | } 154 | 155 | public T this[Guid id] 156 | { 157 | get => throw new NotImplementedException(); 158 | set => throw new NotImplementedException(); 159 | } 160 | 161 | public event EventHandler> ItemCollectionChanged; 162 | 163 | public event EventHandler> ItemUpdated; 164 | } 165 | } -------------------------------------------------------------------------------- /TestTools.Shared/MemberAutoMoqDataAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using System.Threading; 6 | using AutoFixture; 7 | using AutoFixture.AutoMoq; 8 | using AutoFixture.Kernel; 9 | using AutoFixture.Xunit2; 10 | using Xunit; 11 | using Xunit.Sdk; 12 | 13 | // Taken from https://github.com/AutoFixture/AutoFixture/issues/1142 14 | namespace TestTools.Shared 15 | { 16 | public class MemberAutoMoqDataAttribute : DataAttribute 17 | { 18 | private readonly Lazy _fixture; 19 | private readonly MemberDataAttribute _memberDataAttribute; 20 | 21 | public MemberAutoMoqDataAttribute(string memberName, params object[] parameters) 22 | : this(memberName, parameters, () => new Fixture().Customize(new AutoMoqCustomization())) 23 | { 24 | } 25 | 26 | protected MemberAutoMoqDataAttribute(string memberName, object[] parameters, Func fixtureFactory) 27 | { 28 | if (fixtureFactory == null) 29 | { 30 | throw new ArgumentNullException(nameof(fixtureFactory)); 31 | } 32 | 33 | _memberDataAttribute = new MemberDataAttribute(memberName, parameters); 34 | _fixture = new Lazy(fixtureFactory, LazyThreadSafetyMode.PublicationOnly); 35 | } 36 | 37 | public override IEnumerable GetData(MethodInfo testMethod) 38 | { 39 | if (testMethod == null) 40 | { 41 | throw new ArgumentNullException(nameof(testMethod)); 42 | } 43 | 44 | var memberData = _memberDataAttribute.GetData(testMethod); 45 | 46 | using (var enumerator = memberData.GetEnumerator()) 47 | { 48 | if (enumerator.MoveNext()) 49 | { 50 | var specimens = GetSpecimens(testMethod.GetParameters(), enumerator.Current.Length).ToArray(); 51 | 52 | do 53 | { 54 | yield return enumerator.Current.Concat(specimens).ToArray(); 55 | } while (enumerator.MoveNext()); 56 | } 57 | } 58 | } 59 | 60 | private IEnumerable GetSpecimens(IEnumerable parameters, int skip) 61 | { 62 | foreach (var parameter in parameters.Skip(skip)) 63 | { 64 | CustomizeFixture(parameter); 65 | 66 | yield return Resolve(parameter); 67 | } 68 | } 69 | 70 | private void CustomizeFixture(ParameterInfo p) 71 | { 72 | var customizeAttributes = p.GetCustomAttributes() 73 | .OfType() 74 | .OrderBy(x => x, new CustomizeAttributeComparer()); 75 | 76 | foreach (var ca in customizeAttributes) 77 | { 78 | var c = ca.GetCustomization(p); 79 | _fixture.Value.Customize(c); 80 | } 81 | } 82 | 83 | private object Resolve(ParameterInfo p) 84 | { 85 | var context = new SpecimenContext(_fixture.Value); 86 | 87 | return context.Resolve(p); 88 | } 89 | 90 | private class CustomizeAttributeComparer : Comparer 91 | { 92 | public override int Compare(IParameterCustomizationSource x, IParameterCustomizationSource y) 93 | { 94 | var xFrozen = x is FrozenAttribute; 95 | var yFrozen = y is FrozenAttribute; 96 | 97 | if (xFrozen && !yFrozen) 98 | { 99 | return 1; 100 | } 101 | 102 | if (yFrozen && !xFrozen) 103 | { 104 | return -1; 105 | } 106 | 107 | return 0; 108 | } 109 | } 110 | } 111 | } -------------------------------------------------------------------------------- /GGDeals.UnitTests/Api/Services/RequestDataBatcherTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture.Xunit2; 2 | using GGDeals.Api.Models; 3 | using GGDeals.Api.Services; 4 | using Newtonsoft.Json; 5 | using Newtonsoft.Json.Serialization; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Text; 10 | using Xunit; 11 | 12 | namespace GGDeals.UnitTests.Api.Services 13 | { 14 | public class RequestDataBatcherTests 15 | { 16 | private readonly JsonSerializerSettings _jsonSerializerSettings; 17 | private readonly RequestDataBatcher _sut; 18 | 19 | public RequestDataBatcherTests() 20 | { 21 | _jsonSerializerSettings = new JsonSerializerSettings 22 | { 23 | ContractResolver = new DefaultContractResolver 24 | { 25 | NamingStrategy = new DefaultNamingStrategy() 26 | }, 27 | }; 28 | _sut = CreateSut(); 29 | } 30 | 31 | [Fact] 32 | public void CreateDataJsons_ThrowsException_WhenInputIsEmpty() 33 | { 34 | // Arrange 35 | var games = new List(); 36 | 37 | // Act 38 | var exception = Record.Exception(() => _sut.CreateDataJsons(games).ToList()); 39 | 40 | // Assert 41 | Assert.NotNull(exception); 42 | Assert.IsType(exception); 43 | } 44 | 45 | [Theory] 46 | [AutoData] 47 | public void CreateDataJsons_CreatesSmallJson_WhenInputIsSmall( 48 | List games) 49 | { 50 | // Arrange 51 | 52 | // Act 53 | var result = _sut.CreateDataJsons(games); 54 | 55 | // Assert 56 | var json = Assert.Single(result); 57 | var deserialized = JsonConvert.DeserializeObject>(json, _jsonSerializerSettings); 58 | Assert.Equivalent(games, deserialized); 59 | } 60 | 61 | [Theory] 62 | [AutoData] 63 | public void CreateDataJsons_CreatesTwoBatches_WhenInput1001( 64 | List games) 65 | { 66 | // Arrange 67 | var game = games.Last(); 68 | for (; games.Count < 1001;) 69 | { 70 | var newGame = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(game), _jsonSerializerSettings); 71 | newGame.Id = Guid.NewGuid(); 72 | games.Add(newGame); 73 | } 74 | 75 | var firstBatchGames = games.Take(1000).ToList(); 76 | var secondBatchGames = games.Skip(1000).ToList(); 77 | 78 | // Act 79 | var result = _sut.CreateDataJsons(games); 80 | 81 | // Assert 82 | Assert.Equal(2, result.Count()); 83 | var firstBatch = JsonConvert.DeserializeObject>(result.First(), _jsonSerializerSettings); 84 | var secondBatch = JsonConvert.DeserializeObject>(result.Last(), _jsonSerializerSettings); 85 | Assert.Equivalent(firstBatchGames, firstBatch); 86 | Assert.Equivalent(secondBatchGames, secondBatch); 87 | } 88 | 89 | [Theory] 90 | [AutoData] 91 | public void CreateDataJsons_CreatesMultipleBatches_WhenJsonIsMoreThan10MB( 92 | List games) 93 | { 94 | // Arrange 95 | var game = games.Last(); 96 | var json = JsonConvert.SerializeObject(games); 97 | while (Encoding.UTF8.GetBytes(json).Length < 10_000_000) 98 | { 99 | var newGame = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(game)); 100 | newGame.Id = Guid.NewGuid(); 101 | newGame.Name = new string('A', 5_000_000); 102 | games.Add(newGame); 103 | json = JsonConvert.SerializeObject(games); 104 | } 105 | 106 | var firstBatchGames = games.Take(games.Count - 1).ToList(); 107 | var secondBatchGames = games.Skip(games.Count - 1).ToList(); 108 | 109 | // Act 110 | var result = _sut.CreateDataJsons(games); 111 | 112 | // Assert 113 | Assert.Equal(2, result.Count()); 114 | var firstBatch = JsonConvert.DeserializeObject>(result.First(), _jsonSerializerSettings); 115 | var secondBatch = JsonConvert.DeserializeObject>(result.Last(), _jsonSerializerSettings); 116 | Assert.Equivalent(firstBatchGames, firstBatch); 117 | Assert.Equivalent(secondBatchGames, secondBatch); 118 | } 119 | 120 | private RequestDataBatcher CreateSut() 121 | { 122 | return new RequestDataBatcher(_jsonSerializerSettings); 123 | } 124 | } 125 | } -------------------------------------------------------------------------------- /GGDeals/Menu/AddGames/MVVM/AddGamesView.xaml: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /GGDeals/Settings/MVVM/GGDealsSettingsViewModel.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using GGDeals.Api.Services; 3 | using Playnite.SDK; 4 | using Playnite.SDK.Data; 5 | using Playnite.SDK.Plugins; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Diagnostics; 9 | using System.Linq; 10 | using System.Windows.Input; 11 | 12 | namespace GGDeals.Settings.MVVM 13 | { 14 | public class GGDealsSettingsViewModel : ObservableObject, ISettings 15 | { 16 | private readonly GGDeals _plugin; 17 | private readonly ILibraryToGGLauncherMap _libraryToGGLauncherMap; 18 | private GGDealsSettings _settings; 19 | private GGDealsSettings _editingClone; 20 | private List _libraryItems; 21 | 22 | public GGDealsSettingsViewModel(GGDeals plugin, ILibraryToGGLauncherMap libraryToGGLauncherMap) 23 | { 24 | _plugin = plugin; 25 | _libraryToGGLauncherMap = libraryToGGLauncherMap; 26 | 27 | var savedSettings = plugin.LoadPluginSettings(); 28 | Settings = savedSettings ?? GGDealsSettings.Default; 29 | InitializeLibraryItems(); 30 | } 31 | 32 | public GGDealsSettings Settings 33 | { 34 | get => _settings; 35 | set 36 | { 37 | _settings = value; 38 | OnPropertyChanged(); 39 | } 40 | } 41 | 42 | public void BeginEdit() 43 | { 44 | _editingClone = Serialization.GetClone(Settings); 45 | } 46 | 47 | public void CancelEdit() 48 | { 49 | Settings = _editingClone; 50 | } 51 | 52 | public void EndEdit() 53 | { 54 | _plugin.SavePluginSettings(Settings); 55 | } 56 | 57 | public bool VerifySettings(out List errors) 58 | { 59 | // Code execute when user decides to confirm changes made since BeginEdit was called. 60 | // Executed before EndEdit is called and EndEdit is not called if false is returned. 61 | // List of errors is presented to user if verification fails. 62 | errors = new List(); 63 | return true; 64 | } 65 | 66 | public List LibraryItems 67 | { 68 | get => _libraryItems; 69 | set => SetValue(ref _libraryItems, value); 70 | } 71 | 72 | public ICommand GenerateToken => new RelayCommand(() => 73 | { 74 | Process.Start("https://gg.deals/settings"); 75 | }); 76 | 77 | private void InitializeLibraryItems() 78 | { 79 | if (!Settings.LibraryMapOverride.TryGetValue(Guid.Empty, out var playniteLauncher)) 80 | { 81 | playniteLauncher = _libraryToGGLauncherMap.GetGGLauncher(Guid.Empty); 82 | } 83 | var items = new List 84 | { 85 | new LibraryItem(Guid.Empty, "Playnite", false, Settings.SyncPlayniteLibrary, playniteLauncher, UpdateSyncPlayniteLibrary, UpdateLibraryMapOverrideForLibrary) 86 | }; 87 | 88 | foreach (var plugin in _plugin.PlayniteApi.Addons.Plugins.Where(x => x is LibraryPlugin)) 89 | { 90 | var library = (LibraryPlugin)plugin; 91 | var isOffByDefault = GGDealsSettings.Default.LibrariesToSkip.Contains(library.Id); 92 | var isChecked = Settings.LibrariesToSkip.Contains(library.Id) == false; 93 | if (!Settings.LibraryMapOverride.TryGetValue(library.Id, out var ggLauncher)) 94 | { 95 | ggLauncher = _libraryToGGLauncherMap.GetGGLauncher(library.Id); 96 | } 97 | 98 | var item = new LibraryItem(library.Id, library.Name, isOffByDefault, isChecked, ggLauncher, UpdateLibrariesToSkipForLibrary, UpdateLibraryMapOverrideForLibrary); 99 | items.Add(item); 100 | } 101 | 102 | LibraryItems = items; 103 | } 104 | 105 | private void UpdateLibrariesToSkipForLibrary(Guid id, bool isChecked) 106 | { 107 | switch (isChecked) 108 | { 109 | case true when Settings.LibrariesToSkip.Contains(id): 110 | Settings.LibrariesToSkip.Remove(id); 111 | break; 112 | 113 | case false when !Settings.LibrariesToSkip.Contains(id): 114 | Settings.LibrariesToSkip.Add(id); 115 | break; 116 | } 117 | } 118 | 119 | private void UpdateLibraryMapOverrideForLibrary(Guid pluginId, GGLauncher ggLauncher) 120 | { 121 | var defaultLauncher = _libraryToGGLauncherMap.GetGGLauncher(pluginId); 122 | if (ggLauncher == defaultLauncher) 123 | { 124 | Settings.LibraryMapOverride.Remove(pluginId); 125 | } 126 | else 127 | { 128 | Settings.LibraryMapOverride[pluginId] = ggLauncher; 129 | } 130 | } 131 | 132 | private void UpdateSyncPlayniteLibrary(Guid id, bool isChecked) 133 | { 134 | Settings.SyncPlayniteLibrary = isChecked; 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /GGDeals/Menu/Failures/MVVM/ShowAddFailuresViewModel.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Models; 2 | using Playnite.SDK; 3 | using Playnite.SDK.Plugins; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Collections.ObjectModel; 7 | using System.Linq; 8 | using System.Threading.Tasks; 9 | using System.Windows; 10 | using System.Windows.Input; 11 | 12 | namespace GGDeals.Menu.Failures.MVVM 13 | { 14 | public class ShowAddFailuresViewModel : ObservableObject 15 | { 16 | private readonly ILogger _logger = LogManager.GetLogger(); 17 | private readonly GGDeals _plugin; 18 | private readonly AddFailuresManager _addFailuresManager; 19 | private ObservableCollection _failures; 20 | private bool? _isAllChecked; 21 | private bool _isAllCheckedThreeState; 22 | private bool _isLoading; 23 | 24 | public ShowAddFailuresViewModel(GGDeals plugin, AddFailuresManager addFailuresManager) 25 | { 26 | _plugin = plugin; 27 | _addFailuresManager = addFailuresManager; 28 | Load(); 29 | } 30 | 31 | public ObservableCollection Failures 32 | { 33 | get => _failures; 34 | set => SetValue(ref _failures, value); 35 | } 36 | 37 | // ReSharper disable once UnusedMember.Global 38 | public bool? IsAllChecked 39 | { 40 | get => _isAllChecked; 41 | set 42 | { 43 | IsAllCheckedThreeState = value.HasValue; 44 | if (!value.HasValue) 45 | { 46 | SetValue(ref _isAllChecked, value); 47 | return; 48 | } 49 | 50 | foreach (var failure in Failures) 51 | { 52 | failure.IsChecked = value.Value; 53 | } 54 | 55 | SetValue(ref _isAllChecked, value); 56 | } 57 | } 58 | 59 | // ReSharper disable once UnusedMember.Global 60 | public bool IsAllCheckedThreeState 61 | { 62 | get => _isAllCheckedThreeState; 63 | set => SetValue(ref _isAllCheckedThreeState, value); 64 | } 65 | 66 | public bool IsLoading 67 | { 68 | get => _isLoading; 69 | set => SetValue(ref _isLoading, value); 70 | } 71 | 72 | public void Load() 73 | { 74 | Task.Run(async () => 75 | { 76 | await LoadAsync(); 77 | }); 78 | } 79 | 80 | // ReSharper disable once UnusedMember.Global 81 | public ICommand RetryChecked => new RelayCommand(() => 82 | { 83 | Task.Run(async () => 84 | { 85 | try 86 | { 87 | Application.Current.Dispatcher.Invoke(() => 88 | { 89 | IsLoading = true; 90 | }); 91 | var gameIds = Failures.Where(x => x.IsChecked).Select(x => x.Game.Id).ToList(); 92 | await _plugin.AddGamesToGGCollectionAsync(gameIds, SyncRunSettings.All); 93 | await LoadAsync(); 94 | } 95 | catch (Exception e) 96 | { 97 | _logger.Error(e, "Failed to retry failures."); 98 | } 99 | finally 100 | { 101 | Application.Current.Dispatcher.Invoke(() => 102 | { 103 | IsLoading = false; 104 | }); 105 | } 106 | }); 107 | }); 108 | 109 | // ReSharper disable once UnusedMember.Global 110 | public ICommand RemoveChecked => new RelayCommand(() => 111 | { 112 | Task.Run(async () => 113 | { 114 | try 115 | { 116 | var games = Failures.Where(x => x.IsChecked).Select(x => x.Game.Id).ToList(); 117 | await _addFailuresManager.RemoveFailures(games); 118 | Load(); 119 | } 120 | catch (Exception e) 121 | { 122 | _logger.Error(e, "Failed to remove failures."); 123 | } 124 | }); 125 | }); 126 | 127 | private async Task LoadAsync() 128 | { 129 | try 130 | { 131 | Application.Current.Dispatcher.Invoke(() => 132 | { 133 | IsLoading = true; 134 | }); 135 | var failures = await _addFailuresManager.GetFailures(); 136 | var games = _plugin.PlayniteApi.Database.Games.Where(x => failures.ContainsKey(x.Id)); 137 | var libraries = _plugin.PlayniteApi.Addons.Plugins 138 | .Where(x => games.Select(g => g.PluginId).Contains(x.Id)) 139 | .Select(x => x as LibraryPlugin).ToList(); 140 | 141 | var failureItems = games.Select(x => new FailureItem( 142 | this, 143 | x, 144 | libraries.FirstOrDefault(l => l.Id == x.PluginId)?.Name, 145 | failures[x.Id])); 146 | 147 | Application.Current.Dispatcher.Invoke(() => 148 | { 149 | Failures = new ObservableCollection(failureItems); 150 | }); 151 | } 152 | catch (Exception e) 153 | { 154 | _logger.Error(e, "Failed to load failures."); 155 | } 156 | finally 157 | { 158 | Application.Current.Dispatcher.Invoke(() => 159 | { 160 | IsLoading = false; 161 | }); 162 | } 163 | } 164 | } 165 | } -------------------------------------------------------------------------------- /GGDeals/Services/GameStatusService.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Models; 2 | using Playnite.SDK; 3 | using Playnite.SDK.Models; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Threading; 8 | 9 | namespace GGDeals.Services 10 | { 11 | public class GameStatusService : IGameStatusService 12 | { 13 | private readonly IPlayniteAPI _playniteApi; 14 | private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); 15 | 16 | private readonly Dictionary _tagToStatusMap = 17 | new Dictionary() 18 | { 19 | { "[GGDeals] Synced", AddToCollectionResult.Synced }, 20 | { "[GGDeals] Ignored", AddToCollectionResult.Ignored }, 21 | { "[GGDeals] NotFound", AddToCollectionResult.NotFound }, 22 | }; 23 | 24 | private readonly Dictionary _statusToTagMap = new Dictionary() 25 | { 26 | { AddToCollectionResult.Added, "[GGDeals] Synced" }, 27 | { AddToCollectionResult.Synced, "[GGDeals] Synced" }, 28 | { AddToCollectionResult.Ignored, "[GGDeals] Ignored" }, 29 | { AddToCollectionResult.NotFound, "[GGDeals] NotFound" }, 30 | }; 31 | 32 | public GameStatusService(IPlayniteAPI playniteApi) 33 | { 34 | _playniteApi = playniteApi; 35 | } 36 | 37 | public AddToCollectionResult GetStatus(Game game) 38 | { 39 | Tag gameTag = null; 40 | var ggDealsTags = _playniteApi.Database.Tags.Where(t => t.Name.StartsWith("[GGDeals]")).ToList(); 41 | foreach (var ggDealsTag in ggDealsTags) 42 | { 43 | if (game.TagIds?.Contains(ggDealsTag.Id) ?? false) 44 | { 45 | gameTag = ggDealsTag; 46 | } 47 | } 48 | 49 | if (gameTag == null) 50 | { 51 | return AddToCollectionResult.New; 52 | } 53 | 54 | if (!_tagToStatusMap.TryGetValue(gameTag.Name, out var status)) 55 | { 56 | throw new Exception($"Unknown GGDeals tag: {gameTag.Name}"); 57 | } 58 | 59 | return status; 60 | } 61 | 62 | public void UpdateStatus(Game game, AddToCollectionResult status) 63 | { 64 | var tag = EnsureTagExists(status); 65 | RemoveOtherGGDealTags(game, tag); 66 | AddTag(game, tag); 67 | 68 | _playniteApi.Database.Games.Update(game); 69 | } 70 | 71 | public IDisposable BufferedUpdate() 72 | { 73 | return _playniteApi.Database.BufferedUpdate(); 74 | } 75 | 76 | private Tag EnsureTagExists(AddToCollectionResult status) 77 | { 78 | var tagName = _statusToTagMap[status]; 79 | var tag = GetTagFromDatabase(tagName); 80 | if (tag == null) 81 | { 82 | _semaphore.Wait(); 83 | tag = GetTagFromDatabase(tagName); 84 | if (tag == null) 85 | { 86 | tag = new Tag { Id = Guid.NewGuid(), Name = tagName }; 87 | _playniteApi.Database.Tags.Add(tag); 88 | } 89 | } 90 | 91 | return tag; 92 | } 93 | 94 | private void RemoveOtherGGDealTags(Game game, Tag desiredTag) 95 | { 96 | var ggDealsTags = _playniteApi.Database.Tags.Where(t => t.Name.StartsWith("[GGDeals]")).ToList(); 97 | foreach (var ggDealsTag in ggDealsTags) 98 | { 99 | if ((game.TagIds?.Contains(ggDealsTag.Id) ?? false) && ggDealsTag.Id != desiredTag.Id) 100 | { 101 | game.TagIds.Remove(ggDealsTag.Id); 102 | } 103 | } 104 | } 105 | 106 | private static void AddTag(Game game, Tag tag) 107 | { 108 | if (!(game.TagIds?.Contains(tag.Id) ?? false)) 109 | { 110 | if (game.TagIds == null) 111 | { 112 | game.TagIds = new List(); 113 | } 114 | 115 | game.TagIds.Add(tag.Id); 116 | } 117 | } 118 | 119 | private Tag GetTagFromDatabase(string tagName) 120 | { 121 | var ggDealsTags = _playniteApi.Database.Tags.Where(t => t.Name.StartsWith("[GGDeals]")).ToList(); 122 | var tag = ggDealsTags.FirstOrDefault(t => t.Name == tagName); 123 | return tag; 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /GGDeals/Services/AddGamesService.cs: -------------------------------------------------------------------------------- 1 | using GGDeals.Api.Models; 2 | using GGDeals.Api.Services; 3 | using GGDeals.Settings; 4 | using Playnite.SDK.Models; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using GGDeals.Models; 11 | using GGDeals.Progress.MVVM; 12 | 13 | namespace GGDeals.Services 14 | { 15 | public class AddGamesService : IAddGamesService 16 | { 17 | private readonly GGDealsSettings _settings; 18 | private readonly IGameToAddFilter _gameToAddFilter; 19 | private readonly IGameToGameWithLauncherConverter _gameToGameWithLauncherConverter; 20 | private readonly IRequestDataBatcher _requestDataBatcher; 21 | private readonly IGGDealsApiClient _ggDealsApiClient; 22 | 23 | public AddGamesService( 24 | GGDealsSettings settings, 25 | IGameToAddFilter gameToAddFilter, 26 | IGameToGameWithLauncherConverter gameToGameWithLauncherConverter, 27 | IRequestDataBatcher requestDataBatcher, 28 | IGGDealsApiClient ggDealsApiClient) 29 | { 30 | _settings = settings; 31 | _gameToAddFilter = gameToAddFilter; 32 | _gameToGameWithLauncherConverter = gameToGameWithLauncherConverter; 33 | _requestDataBatcher = requestDataBatcher; 34 | _ggDealsApiClient = ggDealsApiClient; 35 | } 36 | 37 | public async Task> TryAddToCollection(IReadOnlyCollection games, 38 | Action reportProgress, 39 | CancellationToken ct) 40 | { 41 | var result = new Dictionary(); 42 | var gamesToProcess = new List(); 43 | foreach (var game in games) 44 | { 45 | if (!_gameToAddFilter.ShouldTryAddGame(game, out var addResult)) 46 | { 47 | result.Add(game.Id, addResult); 48 | } 49 | else 50 | { 51 | var gameWithLauncher = _gameToGameWithLauncherConverter.GetGameWithLauncher(game); 52 | gamesToProcess.Add(gameWithLauncher); 53 | } 54 | } 55 | 56 | if (!gamesToProcess.Any()) 57 | { 58 | return result; 59 | } 60 | 61 | var requests = _requestDataBatcher.CreateDataJsons(gamesToProcess) 62 | .Select(data => new ImportRequest() { Data = data, Token = _settings.AuthenticationToken }); 63 | 64 | ReportProgress(result, games, reportProgress); 65 | foreach (var request in requests) 66 | { 67 | ct.ThrowIfCancellationRequested(); 68 | 69 | var response = await _ggDealsApiClient.ImportGames(request, ct); 70 | foreach (var item in response.Data.Result) 71 | { 72 | var addToCollectionResult = MapToAddToCollectionResult(item); 73 | result.Add(item.Id, new AddResult() { Result = addToCollectionResult, Message = item.Message, Url = item.Url }); 74 | } 75 | 76 | ReportProgress(result, games, reportProgress); 77 | } 78 | 79 | return result; 80 | } 81 | 82 | private static void ReportProgress(Dictionary result, IReadOnlyCollection games, Action reportProgress) 83 | { 84 | var percentage = result.Count * 100f / games.Count; 85 | reportProgress?.Invoke(percentage); 86 | } 87 | 88 | private static AddToCollectionResult MapToAddToCollectionResult(ImportResult item) 89 | { 90 | AddToCollectionResult addToCollectionResult; 91 | switch (item.Status) 92 | { 93 | case ImportResultStatus.Error: 94 | addToCollectionResult = AddToCollectionResult.Error; 95 | break; 96 | 97 | case ImportResultStatus.Added: 98 | addToCollectionResult = AddToCollectionResult.Added; 99 | break; 100 | 101 | case ImportResultStatus.Skipped: 102 | addToCollectionResult = AddToCollectionResult.Synced; 103 | break; 104 | 105 | case ImportResultStatus.Miss: 106 | addToCollectionResult = AddToCollectionResult.NotFound; 107 | break; 108 | 109 | case ImportResultStatus.Ignored: 110 | addToCollectionResult = AddToCollectionResult.Ignored; 111 | break; 112 | 113 | default: 114 | throw new Exception("No mapping between ImportResultStatus and AddToCollectionResult"); 115 | } 116 | 117 | return addToCollectionResult; 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /GGDeals.UnitTests/Services/PersistentProcessingQueueTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture.Xunit2; 2 | using GGDeals.Services; 3 | using Moq; 4 | using Playnite.SDK; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using GGDeals.Queue; 11 | using TestTools.Shared; 12 | using Xunit; 13 | 14 | namespace GGDeals.UnitTests.Services 15 | { 16 | public class PersistentProcessingQueueTests 17 | { 18 | [Theory] 19 | [AutoMoqData] 20 | public async Task Enqueue_WritesToFile_WhenItemsAdded( 21 | [Frozen] Mock queuePersistenceMock, 22 | List gameIds, 23 | PersistentProcessingQueue sut) 24 | { 25 | // Act 26 | await sut.Enqueue(gameIds); 27 | 28 | // Assert 29 | queuePersistenceMock.Verify(x => x.Save(gameIds)); 30 | } 31 | 32 | [Theory] 33 | [AutoMoqData] 34 | public async Task Enqueue_WritesToFileAllGames_WhenItemsAddedAndGamesWereAlreadyInTheQueue( 35 | [Frozen] Mock queuePersistenceMock, 36 | List oldGameIds, 37 | List newGameIds, 38 | PersistentProcessingQueue sut) 39 | { 40 | // Arrange 41 | await sut.Enqueue(oldGameIds); 42 | 43 | // Act 44 | await sut.Enqueue(newGameIds); 45 | 46 | // Assert 47 | queuePersistenceMock.Verify(x => 48 | x.Save( 49 | It.Is>(g => oldGameIds.Concat(newGameIds).All(g.Contains)))); 50 | } 51 | 52 | [Theory] 53 | [AutoMoqData] 54 | public async Task Constructor_LoadsFromFile_WhenIsCreated( 55 | [Frozen] Mock playniteApiMock, 56 | [Frozen] Mock queuePersistenceMock, 57 | List oldGameIds, 58 | List newGameIds) 59 | { 60 | // Arrange 61 | queuePersistenceMock.Setup(x => x.Load()).ReturnsAsync(oldGameIds); 62 | 63 | // Act 64 | var sut = new PersistentProcessingQueue(queuePersistenceMock.Object, x => Task.CompletedTask); 65 | await sut.Enqueue(newGameIds); 66 | 67 | // Assert 68 | queuePersistenceMock.Verify(x => 69 | x.Save( 70 | It.Is>(g => oldGameIds.Concat(newGameIds).All(g.Contains)))); 71 | } 72 | 73 | [Theory] 74 | [AutoMoqData] 75 | public async Task Process_ExecutesTheAction( 76 | [Frozen] Mock playniteApiMock, 77 | [Frozen] Mock queuePersistenceMock) 78 | { 79 | // Arrange 80 | var actionCalled = false; 81 | var semaphore = new SemaphoreSlim(0, 1); 82 | var sut = new PersistentProcessingQueue(queuePersistenceMock.Object, x => 83 | { 84 | actionCalled = true; 85 | semaphore.Release(); 86 | return Task.CompletedTask; 87 | }); 88 | 89 | // Act 90 | sut.ProcessInBackground(); 91 | 92 | // Assert 93 | await semaphore.WaitAsync(TimeSpan.FromSeconds(5)); 94 | Assert.True(actionCalled); 95 | } 96 | 97 | [Theory] 98 | [AutoMoqData] 99 | public async Task Process_SavesFile_WhenActionExecutes( 100 | [Frozen] Mock playniteApiMock, 101 | [Frozen] Mock queuePersistenceMock, 102 | List gameIds) 103 | { 104 | // Arrange 105 | var semaphore = new SemaphoreSlim(0, 1); 106 | var sut = new PersistentProcessingQueue(queuePersistenceMock.Object, x => 107 | { 108 | semaphore.Release(); 109 | return Task.CompletedTask; 110 | }); 111 | await sut.Enqueue(gameIds); 112 | 113 | // Act 114 | sut.ProcessInBackground(); 115 | 116 | // Assert 117 | await semaphore.WaitAsync(TimeSpan.FromSeconds(5)); 118 | queuePersistenceMock.Verify(x => x.Save(It.Is>(c => c.Count == 0))); 119 | } 120 | 121 | [Theory] 122 | [AutoMoqData] 123 | public async Task Process_DoesNotSave_WhenActionFails( 124 | [Frozen] Mock playniteApiMock, 125 | [Frozen] Mock queuePersistenceMock, 126 | List gameIds) 127 | { 128 | // Arrange 129 | var semaphore = new SemaphoreSlim(0, 1); 130 | var sut = new PersistentProcessingQueue(queuePersistenceMock.Object, x => throw new Exception()); 131 | await sut.Enqueue(gameIds); 132 | 133 | // Act 134 | sut.ProcessInBackground(); 135 | 136 | // Assert 137 | await semaphore.WaitAsync(TimeSpan.FromSeconds(1)); 138 | queuePersistenceMock.Verify(x => x.Save(It.Is>(c => c.Count == 0)), Times.Never); 139 | } 140 | 141 | [Theory] 142 | [AutoMoqData] 143 | public async Task Process_ItemsStayInQueue_WhenActionFails( 144 | [Frozen] Mock playniteApiMock, 145 | [Frozen] Mock queuePersistenceMock, 146 | List gameIds) 147 | { 148 | // Arrange 149 | var semaphore = new SemaphoreSlim(0, 1); 150 | var sut = new PersistentProcessingQueue(queuePersistenceMock.Object, x => throw new Exception()); 151 | await sut.Enqueue(gameIds); 152 | queuePersistenceMock.Reset(); 153 | 154 | // Act 155 | sut.ProcessInBackground(); 156 | 157 | // Assert 158 | await semaphore.WaitAsync(TimeSpan.FromSeconds(1)); 159 | queuePersistenceMock.Verify(x => 160 | x.Save(It.Is>(g => gameIds.All(g.Contains)))); 161 | } 162 | } 163 | } -------------------------------------------------------------------------------- /TestTools.Shared/TestTools.Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {53B7484B-BE62-481C-90D3-1E75E18250E3} 8 | Library 9 | Properties 10 | TestTools.Shared 11 | TestTools.Shared 12 | v4.6.2 13 | 512 14 | true 15 | 16 | 17 | true 18 | full 19 | false 20 | bin\Debug\ 21 | DEBUG;TRACE 22 | prompt 23 | 4 24 | 25 | 26 | pdbonly 27 | true 28 | bin\Release\ 29 | TRACE 30 | prompt 31 | 4 32 | 33 | 34 | 35 | ..\packages\AutoFixture.4.18.1\lib\net452\AutoFixture.dll 36 | 37 | 38 | ..\packages\AutoFixture.AutoMoq.4.18.1\lib\net452\AutoFixture.AutoMoq.dll 39 | 40 | 41 | ..\packages\AutoFixture.Xunit2.4.18.1\lib\net452\AutoFixture.Xunit2.dll 42 | 43 | 44 | ..\packages\Castle.Core.5.1.1\lib\net462\Castle.Core.dll 45 | 46 | 47 | ..\packages\Fare.2.1.1\lib\net35\Fare.dll 48 | 49 | 50 | ..\packages\Moq.4.20.70\lib\net462\Moq.dll 51 | 52 | 53 | 54 | 55 | 56 | 57 | ..\packages\System.Runtime.CompilerServices.Unsafe.4.5.3\lib\net461\System.Runtime.CompilerServices.Unsafe.dll 58 | 59 | 60 | ..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | ..\packages\xunit.abstractions.2.0.3\lib\net35\xunit.abstractions.dll 70 | 71 | 72 | ..\packages\xunit.extensibility.core.2.9.0\lib\net452\xunit.core.dll 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /GGDeals.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.9.34607.119 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GGDeals", "GGDeals\GGDeals.csproj", "{4FDF1E89-5BC3-4C72-8FDA-0D580E7A5D5F}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GGDeals.UnitTests", "GGDeals.UnitTests\GGDeals.UnitTests.csproj", "{6D7A45D8-7FFA-40A0-A665-E8FDD2DCC823}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionItems", "{085C4782-3247-4717-A3FE-24BDDF5A6B36}" 11 | ProjectSection(SolutionItems) = preProject 12 | .gitignore = .gitignore 13 | crowdin.yml = crowdin.yml 14 | GGDeals.sln.DotSettings = GGDeals.sln.DotSettings 15 | README.md = README.md 16 | EndProjectSection 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GGDeals.IntegrationTests", "GGDeals.IntegrationTests\GGDeals.IntegrationTests.csproj", "{225117BF-BFD9-4837-B980-F4D201DF3C77}" 19 | EndProject 20 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestTools.Shared", "TestTools.Shared\TestTools.Shared.csproj", "{53B7484B-BE62-481C-90D3-1E75E18250E3}" 21 | EndProject 22 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReleaseTools", "ReleaseTools\ReleaseTools.csproj", "{5C1FF6FE-5A69-4F8D-B161-75F58DB89666}" 23 | EndProject 24 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReleaseTools.IntegrationTests", "ReleaseTools.IntegrationTests\ReleaseTools.IntegrationTests.csproj", "{A751BF7A-3F85-4ACC-8524-ADB7A61AD22F}" 25 | EndProject 26 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReleaseTools.UnitTests", "ReleaseTools.UnitTests\ReleaseTools.UnitTests.csproj", "{49A9FEC2-B945-4298-96B0-40B4DC2690DC}" 27 | EndProject 28 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{6E0F502B-ABA0-46BF-9970-BFD4246988C9}" 29 | ProjectSection(SolutionItems) = preProject 30 | .github\workflows\crowdin.yml = .github\workflows\crowdin.yml 31 | .github\workflows\tests.yml = .github\workflows\tests.yml 32 | EndProjectSection 33 | EndProject 34 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ci", "ci", "{728DA8B6-5EAE-4F44-ACBC-E64BD7802BE3}" 35 | ProjectSection(SolutionItems) = preProject 36 | ci\Changelog.txt = ci\Changelog.txt 37 | ci\installer_manifest.yaml = ci\installer_manifest.yaml 38 | ci\release.bat = ci\release.bat 39 | ci\Release.md = ci\Release.md 40 | EndProjectSection 41 | EndProject 42 | Global 43 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 44 | Debug|Any CPU = Debug|Any CPU 45 | Release|Any CPU = Release|Any CPU 46 | EndGlobalSection 47 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 48 | {4FDF1E89-5BC3-4C72-8FDA-0D580E7A5D5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 49 | {4FDF1E89-5BC3-4C72-8FDA-0D580E7A5D5F}.Debug|Any CPU.Build.0 = Debug|Any CPU 50 | {4FDF1E89-5BC3-4C72-8FDA-0D580E7A5D5F}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {4FDF1E89-5BC3-4C72-8FDA-0D580E7A5D5F}.Release|Any CPU.Build.0 = Release|Any CPU 52 | {6D7A45D8-7FFA-40A0-A665-E8FDD2DCC823}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 53 | {6D7A45D8-7FFA-40A0-A665-E8FDD2DCC823}.Debug|Any CPU.Build.0 = Debug|Any CPU 54 | {6D7A45D8-7FFA-40A0-A665-E8FDD2DCC823}.Release|Any CPU.ActiveCfg = Release|Any CPU 55 | {6D7A45D8-7FFA-40A0-A665-E8FDD2DCC823}.Release|Any CPU.Build.0 = Release|Any CPU 56 | {225117BF-BFD9-4837-B980-F4D201DF3C77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 57 | {225117BF-BFD9-4837-B980-F4D201DF3C77}.Debug|Any CPU.Build.0 = Debug|Any CPU 58 | {225117BF-BFD9-4837-B980-F4D201DF3C77}.Release|Any CPU.ActiveCfg = Release|Any CPU 59 | {225117BF-BFD9-4837-B980-F4D201DF3C77}.Release|Any CPU.Build.0 = Release|Any CPU 60 | {53B7484B-BE62-481C-90D3-1E75E18250E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 61 | {53B7484B-BE62-481C-90D3-1E75E18250E3}.Debug|Any CPU.Build.0 = Debug|Any CPU 62 | {53B7484B-BE62-481C-90D3-1E75E18250E3}.Release|Any CPU.ActiveCfg = Release|Any CPU 63 | {53B7484B-BE62-481C-90D3-1E75E18250E3}.Release|Any CPU.Build.0 = Release|Any CPU 64 | {5C1FF6FE-5A69-4F8D-B161-75F58DB89666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 65 | {5C1FF6FE-5A69-4F8D-B161-75F58DB89666}.Debug|Any CPU.Build.0 = Debug|Any CPU 66 | {5C1FF6FE-5A69-4F8D-B161-75F58DB89666}.Release|Any CPU.ActiveCfg = Release|Any CPU 67 | {5C1FF6FE-5A69-4F8D-B161-75F58DB89666}.Release|Any CPU.Build.0 = Release|Any CPU 68 | {A751BF7A-3F85-4ACC-8524-ADB7A61AD22F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 69 | {A751BF7A-3F85-4ACC-8524-ADB7A61AD22F}.Debug|Any CPU.Build.0 = Debug|Any CPU 70 | {A751BF7A-3F85-4ACC-8524-ADB7A61AD22F}.Release|Any CPU.ActiveCfg = Release|Any CPU 71 | {A751BF7A-3F85-4ACC-8524-ADB7A61AD22F}.Release|Any CPU.Build.0 = Release|Any CPU 72 | {49A9FEC2-B945-4298-96B0-40B4DC2690DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 73 | {49A9FEC2-B945-4298-96B0-40B4DC2690DC}.Debug|Any CPU.Build.0 = Debug|Any CPU 74 | {49A9FEC2-B945-4298-96B0-40B4DC2690DC}.Release|Any CPU.ActiveCfg = Release|Any CPU 75 | {49A9FEC2-B945-4298-96B0-40B4DC2690DC}.Release|Any CPU.Build.0 = Release|Any CPU 76 | EndGlobalSection 77 | GlobalSection(SolutionProperties) = preSolution 78 | HideSolutionNode = FALSE 79 | EndGlobalSection 80 | GlobalSection(ExtensibilityGlobals) = postSolution 81 | SolutionGuid = {620DAE38-0360-4A4F-A05E-7FE07CD0E592} 82 | EndGlobalSection 83 | EndGlobal 84 | -------------------------------------------------------------------------------- /GGDeals/Localization/en_US.xaml: -------------------------------------------------------------------------------- 1 | 4 | Add Games 5 | Only add games with these GG.deals sync statuses: 6 | New 7 | Games that haven't been sent to your GG.deals collection yet 8 | Synced 9 | Games previously sent to your GG.deals collection 10 | Not found 11 | Games that haven't been matched with GG.deals database. They may be added at a later date 12 | Ignored 13 | Games ignored by API - they're either demo versions or other items not handled by GG.deals 14 | Add Games 15 | Add to GG.deals collection 16 | Add with custom rules... 17 | Add games to GG.deals collection... 18 | Show failures... 19 | Could not add {0} games due to an error. Click here for mode detailed info. 20 | Could not add games due to an error. API returned: {0}. 21 | User is not authenticated. Login in GG.deals addon settings. 22 | Error occured while adding games to GG.deals library. If error persists, create an issue in https://github.com/SparrowBrain/Playnite.GGDeals repository. 23 | GG.deals sync finished. {0} games were added. 24 | {0} games are already in the collection. 25 | Could not match {0} games in GG.deals. Click here for mode detailed info. 26 | {0} games ignored by API. They're either demo versions or other items not handled by GG.deals. 27 | Cancel 28 | Hide 29 | GG.deals Sync 30 | Cancelled 31 | Not Authenticated 32 | Authentication 33 | Authentication Token 34 | Generate Token 35 | Libraries 36 | Not recommended. Please use collection sync available on GG.deals website. 37 | Other 38 | Add GG.deals link to processed games 39 | Add custom tags to processed games 40 | Sync newly added games 41 | Show progress bar 42 | Failures 43 | Name 44 | Library 45 | Message 46 | Reason 47 | Page Not Found 48 | Not Processed 49 | Remove Checked 50 | Retry Checked 51 | Close 52 | -------------------------------------------------------------------------------- /GGDeals/Localization/ar_SA.xaml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Add Games 4 | Only add games with these GG.deals sync statuses: 5 | New 6 | Games that haven't been sent to your GG.deals collection yet 7 | Synced 8 | Games previously sent to your GG.deals collection 9 | Not found 10 | Games that haven't been matched with GG.deals database. They may be added at a later date 11 | Ignored 12 | Games ignored by API - they're either demo versions or other items not handled by GG.deals 13 | Add Games 14 | Add to GG.deals collection 15 | Add with custom rules... 16 | Add games to GG.deals collection... 17 | Show failures... 18 | Could not add {0} games due to an error. Click here for mode detailed info. 19 | Could not add games due to an error. API returned: {0}. 20 | User is not authenticated. Login in GG.deals addon settings. 21 | Error occured while adding games to GG.deals library. If error persists, create an issue in https://github.com/SparrowBrain/Playnite.GGDeals repository. 22 | GG.deals sync finished. {0} games were added. 23 | {0} games are already in the collection. 24 | Could not match {0} games in GG.deals. Click here for mode detailed info. 25 | {0} games ignored by API. They're either demo versions or other items not handled by GG.deals. 26 | Cancel 27 | Hide 28 | GG.deals Sync 29 | Cancelled 30 | Not Authenticated 31 | Authentication 32 | Authentication Token 33 | Generate Token 34 | Libraries 35 | Not recommended. Please use collection sync available on GG.deals website. 36 | Other 37 | Add GG.deals link to processed games 38 | Add custom tags to processed games 39 | Sync newly added games 40 | Show progress bar 41 | Failures 42 | Name 43 | Library 44 | Message 45 | Reason 46 | Page Not Found 47 | Not Processed 48 | Remove Checked 49 | Retry Checked 50 | Close 51 | 52 | --------------------------------------------------------------------------------