diff --git a/.github/workflows/test_integration.yml b/.github/workflows/test_integration.yml index 3325444..88f6a76 100644 --- a/.github/workflows/test_integration.yml +++ b/.github/workflows/test_integration.yml @@ -66,7 +66,7 @@ jobs: f.write(base64.b64decode(data)) - name: Extract config.zip - run: unzip config.zip + run: unzip -qq config.zip - name: Create plugin dir run: | diff --git a/ASFFreeGames.Tests/ASFFreeGames.Tests.csproj b/ASFFreeGames.Tests/ASFFreeGames.Tests.csproj index b00c897..90a701b 100644 --- a/ASFFreeGames.Tests/ASFFreeGames.Tests.csproj +++ b/ASFFreeGames.Tests/ASFFreeGames.Tests.csproj @@ -23,6 +23,8 @@ + + diff --git a/ASFFreeGames.Tests/GameIdentifierParserTests.cs b/ASFFreeGames.Tests/GameIdentifierParserTests.cs index 87213d4..4fb6b69 100644 --- a/ASFFreeGames.Tests/GameIdentifierParserTests.cs +++ b/ASFFreeGames.Tests/GameIdentifierParserTests.cs @@ -1,4 +1,7 @@ using System; +using ASFFreeGames.ASFExtentions.Games; +using Maxisoft.ASF.ASFExtentions; +using Maxisoft.ASF.ASFExtentions.Games; using Xunit; namespace Maxisoft.ASF.Tests; diff --git a/ASFFreeGames.Tests/GameIdentifierTests.cs b/ASFFreeGames.Tests/GameIdentifierTests.cs index f9ce446..616a8d3 100644 --- a/ASFFreeGames.Tests/GameIdentifierTests.cs +++ b/ASFFreeGames.Tests/GameIdentifierTests.cs @@ -1,4 +1,7 @@ using System; +using ASFFreeGames.ASFExtentions.Games; +using Maxisoft.ASF.ASFExtentions; +using Maxisoft.ASF.ASFExtentions.Games; using Xunit; namespace Maxisoft.ASF.Tests; diff --git a/ASFFreeGames.Tests/RandomUtilsTests.cs b/ASFFreeGames.Tests/RandomUtilsTests.cs index 5398497..a348ca3 100644 --- a/ASFFreeGames.Tests/RandomUtilsTests.cs +++ b/ASFFreeGames.Tests/RandomUtilsTests.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Maxisoft.ASF.Utils; using Xunit; namespace Maxisoft.ASF.Tests; diff --git a/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs b/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs index 49b1f6f..1c74d02 100644 --- a/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs +++ b/ASFFreeGames.Tests/Reddit/RedditHelperTests.cs @@ -6,7 +6,6 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; -using System.Threading; using System.Threading.Tasks; using Maxisoft.ASF.Reddit; using Maxisoft.Utils.Collections.Spans; @@ -104,12 +103,6 @@ private static async Task LoadAsfinfoEntries() { #pragma warning restore CA2007 JsonNode jsonNode = await JsonNode.ParseAsync(stream).ConfigureAwait(false) ?? JsonNode.Parse("{}")!; - return RedditHelper.LoadMessages(jsonNode["data"]?["children"]!); - } - - private static async Task ReadToEndAsync(Stream stream, CancellationToken cancellationToken) { - using StreamReader reader = new(stream); - - return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + return RedditHelper.LoadMessages(jsonNode["data"]?["children"]!).ToArray(); } } diff --git a/ASFFreeGames.Tests/Redlib/RedlibHtmlParserTests.cs b/ASFFreeGames.Tests/Redlib/RedlibHtmlParserTests.cs new file mode 100644 index 0000000..8b8189f --- /dev/null +++ b/ASFFreeGames.Tests/Redlib/RedlibHtmlParserTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Maxisoft.ASF.Redlib; +using Maxisoft.ASF.Redlib.Html; +using Xunit; + +namespace Maxisoft.ASF.Tests.Redlib; + +public class RedlibHtmlParserTests { + [Fact] + public async void Test() { + string html = await LoadHtml().ConfigureAwait(false); + + // ReSharper disable once ArgumentsStyleLiteral + IReadOnlyCollection result = RedlibHtmlParser.ParseGamesFromHtml(html, dedup: false); + Assert.NotEmpty(result); + Assert.Equal(25, result.Count); + +// ReSharper disable once ArgumentsStyleLiteral + result = RedlibHtmlParser.ParseGamesFromHtml(html, dedup: true); + Assert.NotEmpty(result); + Assert.Equal(13, result.Count); + } + + private static async Task LoadHtml() { + Assembly assembly = Assembly.GetExecutingAssembly(); + +#pragma warning disable CA2007 + await using Stream stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.redlib_asfinfo.html")!; +#pragma warning restore CA2007 + using StreamReader reader = new(stream, Encoding.UTF8, true); + + return await reader.ReadToEndAsync().ConfigureAwait(false); + } +} diff --git a/ASFFreeGames.Tests/Redlib/RedlibInstancesListTests.cs b/ASFFreeGames.Tests/Redlib/RedlibInstancesListTests.cs new file mode 100644 index 0000000..86b025f --- /dev/null +++ b/ASFFreeGames.Tests/Redlib/RedlibInstancesListTests.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using ASFFreeGames.Configurations; +using Maxisoft.ASF.Redlib; +using Maxisoft.ASF.Redlib.Html; +using Maxisoft.ASF.Redlib.Instances; +using Xunit; + +namespace Maxisoft.ASF.Tests.Redlib; + +public class RedlibInstanceListTests { + [Fact] + public async void Test() { + RedlibInstanceList lister = new(new ASFFreeGamesOptions()); + List uris = await RedlibInstanceList.ListFromEmbedded(default(CancellationToken)).ConfigureAwait(false); + + Assert.NotEmpty(uris); + } +} diff --git a/ASFFreeGames.Tests/redlib_asfinfo.html b/ASFFreeGames.Tests/redlib_asfinfo.html new file mode 100644 index 0000000..12763b3 --- /dev/null +++ b/ASFFreeGames.Tests/redlib_asfinfo.html @@ -0,0 +1,867 @@ + + + + + ASFinfo (u/ASFinfo) - Redlib + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + +
+ + + + + + +
+ + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/freegames + 11m ago + +

!addlicense asf a/2641230
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 2 + +

+
+
+
+ + Comment on r/freegames + 18h ago + +

!addlicense asf a/1001860
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 2 + +

+
+
+
+ + Comment on r/freegames + 1d ago + +

!addlicense asf s/1070196
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGamesForPC + 2d ago + +

!addlicense asf s/1070196
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 2 + +

+
+
+
+ + Comment on r/FreeGamesOnSteam + 2d ago + +

!addlicense asf s/1070196
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 2 + +

+
+
+
+ + Comment on r/Freegamestuff + 2d ago + +

!addlicense asf s/1070196
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 4 + +

+
+
+
+ + Comment on r/FreeGameFindings + 2d ago + +

!addlicense asf s/1070196
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/freegames + 2d ago + +

!addlicense asf a/1612570
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGameFindings + 2d ago + +

!addlicense asf a/277850
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGamesOnSteam + 2d ago + +

!addlicense asf a/277850
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGameFindings + 2d ago + +

!addlicense asf a/277850
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 3 + +

+
+
+
+ + Comment on r/freegames + 2d ago + +

!addlicense asf a/247000
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 4 + +

+
+
+
+ + Comment on r/FreeGameFindings + 3d ago + +

!addlicense asf a/247000
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 3 + +

+
+
+
+ + Comment on r/freegames + 3d ago + +

!addlicense asf a/2630030
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGameFindings + 3d ago + +

!addlicense asf s/1075472
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGameFindings + 3d ago + +

!addlicense asf s/1075472
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGamesOnSteam + 3d ago + +

!addlicense asf s/1075472
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGameFindings + 3d ago + +

!addlicense asf s/1075472
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 2 + +

+
+
+
+ + Comment on r/freegames + 5d ago + +

!addlicense asf a/907600
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 6 + +

+
+
+
+ + Comment on r/Freegamestuff + 6d ago + +

!addlicense asf a/2373630
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGamesOnSteam + 7d ago + +

!addlicense asf s/1041314
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/freegames + 7d ago + +

!addlicense asf a/2890000
+
+ +

This game is currently free to play.

+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGameFindings + 8d ago + +

!addlicense asf s/1041314
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/FreeGamesForPC + 9d ago + +

!addlicense asf s/1072560
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + + +
+
+

+ + 1 + +

+
+
+
+ + Comment on r/Freegamestuff + 9d ago + +

!addlicense asf s/1072560
+
+ +

I'm a bot | What is ASF | Info

+

+
+
+ + + +
+ + + +
+ + +
+ + + + + + + + diff --git a/ASFFreeGames/BotContext.cs b/ASFFreeGames/ASFExtentions/Bot/BotContext.cs similarity index 95% rename from ASFFreeGames/BotContext.cs rename to ASFFreeGames/ASFExtentions/Bot/BotContext.cs index 4f0f4a0..06a165b 100644 --- a/ASFFreeGames/BotContext.cs +++ b/ASFFreeGames/ASFExtentions/Bot/BotContext.cs @@ -2,9 +2,13 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using ArchiSteamFarm.Steam; +using ASFFreeGames.ASFExtentions.Games; +using Maxisoft.ASF; +using Maxisoft.ASF.AppLists; -namespace Maxisoft.ASF; +namespace ASFFreeGames.ASFExtentions.Bot; + +using Bot = ArchiSteamFarm.Steam.Bot; internal sealed class BotContext : IDisposable { private const ulong TriesBeforeBlacklistingGameEntry = 5; diff --git a/ASFFreeGames/BotEqualityComparer.cs b/ASFFreeGames/ASFExtentions/Bot/BotEqualityComparer.cs similarity index 85% rename from ASFFreeGames/BotEqualityComparer.cs rename to ASFFreeGames/ASFExtentions/Bot/BotEqualityComparer.cs index 7fe3360..3f1f704 100644 --- a/ASFFreeGames/BotEqualityComparer.cs +++ b/ASFFreeGames/ASFExtentions/Bot/BotEqualityComparer.cs @@ -1,8 +1,9 @@ using System; using System.Collections.Generic; -using ArchiSteamFarm.Steam; -namespace Maxisoft.ASF; +namespace ASFFreeGames.ASFExtentions.Bot; + +using Bot = ArchiSteamFarm.Steam.Bot; internal sealed class BotEqualityComparer : IEqualityComparer { public bool Equals(Bot? x, Bot? y) { diff --git a/ASFFreeGames/BotName.cs b/ASFFreeGames/ASFExtentions/Bot/BotName.cs similarity index 97% rename from ASFFreeGames/BotName.cs rename to ASFFreeGames/ASFExtentions/Bot/BotName.cs index 2c6c7a6..b7c60a5 100644 --- a/ASFFreeGames/BotName.cs +++ b/ASFFreeGames/ASFExtentions/Bot/BotName.cs @@ -1,8 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Globalization; -namespace Maxisoft.ASF { +namespace ASFFreeGames.ASFExtentions.Bot { /// /// Represents a readonly record struct that encapsulates bot's name (a string) and provides implicit conversion and comparison methods. /// diff --git a/ASFFreeGames/GameIdentifier.cs b/ASFFreeGames/ASFExtentions/Games/GameIdentifier.cs similarity index 93% rename from ASFFreeGames/GameIdentifier.cs rename to ASFFreeGames/ASFExtentions/Games/GameIdentifier.cs index 8ac814b..ef8635c 100644 --- a/ASFFreeGames/GameIdentifier.cs +++ b/ASFFreeGames/ASFExtentions/Games/GameIdentifier.cs @@ -2,10 +2,12 @@ using System.Buffers.Binary; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using Maxisoft.ASF.ASFExtentions; +using Maxisoft.ASF.ASFExtentions.Games; // ReSharper disable RedundantNullableFlowAttribute -namespace Maxisoft.ASF; +namespace ASFFreeGames.ASFExtentions.Games; /// /// Represents a readonly record struct that encapsulates a game identifier with a numeric ID and a type. diff --git a/ASFFreeGames/GameIdentifierParser.cs b/ASFFreeGames/ASFExtentions/Games/GameIdentifierParser.cs similarity index 96% rename from ASFFreeGames/GameIdentifierParser.cs rename to ASFFreeGames/ASFExtentions/Games/GameIdentifierParser.cs index d831af5..a2fb4e2 100644 --- a/ASFFreeGames/GameIdentifierParser.cs +++ b/ASFFreeGames/ASFExtentions/Games/GameIdentifierParser.cs @@ -1,7 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; +using ASFFreeGames.ASFExtentions.Games; -namespace Maxisoft.ASF; +namespace Maxisoft.ASF.ASFExtentions.Games; /// /// Represents a static class that provides methods for parsing game identifiers from strings. diff --git a/ASFFreeGames/GameIdentifierType.cs b/ASFFreeGames/ASFExtentions/Games/GameIdentifierType.cs similarity index 61% rename from ASFFreeGames/GameIdentifierType.cs rename to ASFFreeGames/ASFExtentions/Games/GameIdentifierType.cs index e5b207a..4fb0691 100644 --- a/ASFFreeGames/GameIdentifierType.cs +++ b/ASFFreeGames/ASFExtentions/Games/GameIdentifierType.cs @@ -1,4 +1,4 @@ -namespace Maxisoft.ASF; +namespace Maxisoft.ASF.ASFExtentions.Games; public enum GameIdentifierType : sbyte { None = 0, diff --git a/ASFFreeGames/ASFFreeGames.csproj b/ASFFreeGames/ASFFreeGames.csproj index dd735cd..4bd7fc8 100644 --- a/ASFFreeGames/ASFFreeGames.csproj +++ b/ASFFreeGames/ASFFreeGames.csproj @@ -72,4 +72,13 @@ Directory.Build.props + + + + + + + + + diff --git a/ASFFreeGames/ASFFreeGamesPlugin.cs b/ASFFreeGames/ASFFreeGamesPlugin.cs index d23f445..66efeed 100644 --- a/ASFFreeGames/ASFFreeGamesPlugin.cs +++ b/ASFFreeGames/ASFFreeGamesPlugin.cs @@ -8,10 +8,13 @@ using ArchiSteamFarm.Collections; using ArchiSteamFarm.Plugins.Interfaces; using ArchiSteamFarm.Steam; +using ASFFreeGames.ASFExtentions.Bot; using ASFFreeGames.Commands; using ASFFreeGames.Configurations; using JetBrains.Annotations; +using Maxisoft.ASF.ASFExtentions; using Maxisoft.ASF.Configurations; +using Maxisoft.ASF.Utils; using SteamKit2; using static ArchiSteamFarm.Core.ASF; diff --git a/ASFFreeGames/CompletedAppList.cs b/ASFFreeGames/AppLists/CompletedAppList.cs similarity index 69% rename from ASFFreeGames/CompletedAppList.cs rename to ASFFreeGames/AppLists/CompletedAppList.cs index c549b64..6d8d88d 100644 --- a/ASFFreeGames/CompletedAppList.cs +++ b/ASFFreeGames/AppLists/CompletedAppList.cs @@ -7,15 +7,17 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using ASFFreeGames.ASFExtentions.Games; +using Maxisoft.ASF.ASFExtentions; -namespace Maxisoft.ASF; +namespace Maxisoft.ASF.AppLists; internal sealed class CompletedAppList : IDisposable { - private long[]? CompletedAppBuffer; - private const int CompletedAppBufferSize = 128; - private Memory CompletedAppMemory => ((Memory) CompletedAppBuffer!)[..CompletedAppBufferSize]; - private readonly RecentGameMapping CompletedApps; - private const int FileCompletedAppBufferSize = CompletedAppBufferSize * sizeof(long) * 2; + internal long[]? CompletedAppBuffer { get; private set; } + internal const int CompletedAppBufferSize = 128; + internal Memory CompletedAppMemory => ((Memory) CompletedAppBuffer!)[..CompletedAppBufferSize]; + internal RecentGameMapping CompletedApps { get; } + internal const int FileCompletedAppBufferSize = CompletedAppBufferSize * sizeof(long) * 2; private static readonly ArrayPool LongMemoryPool = ArrayPool.Create(CompletedAppBufferSize, 10); private static readonly char Endianness = BitConverter.IsLittleEndian ? 'l' : 'b'; public static readonly string FileExtension = $".fg{Endianness}dict"; @@ -45,76 +47,89 @@ public void Dispose() { GC.SuppressFinalize(this); } + public bool Add(in GameIdentifier gameIdentifier) => CompletedApps.Add(in gameIdentifier); + public bool AddInvalid(in GameIdentifier gameIdentifier) => CompletedApps.AddInvalid(in gameIdentifier); + + public bool Contains(in GameIdentifier gameIdentifier) => CompletedApps.Contains(in gameIdentifier); + + public bool ContainsInvalid(in GameIdentifier gameIdentifier) => CompletedApps.ContainsInvalid(in gameIdentifier); +} + +public static class CompletedAppListSerializer { [SuppressMessage("Code", "CAC001:ConfigureAwaitChecker")] - public async Task SaveToFile(string filePath, CancellationToken cancellationToken = default) { + internal static async Task SaveToFile(this CompletedAppList appList, string filePath, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(filePath)) { return; } #pragma warning disable CA2007 - await using var sourceStream = new FileStream( + await using FileStream sourceStream = new( filePath, FileMode.Create, FileAccess.Write, FileShare.None, - bufferSize: FileCompletedAppBufferSize, useAsync: true + bufferSize: CompletedAppList.FileCompletedAppBufferSize, useAsync: true ); // ReSharper disable once UseAwaitUsing - using var encoder = new BrotliStream(sourceStream, CompressionMode.Compress); + using BrotliStream encoder = new(sourceStream, CompressionMode.Compress); ChangeBrotliEncoderToFastCompress(encoder); #pragma warning restore CA2007 // note: cannot use WriteAsync call due to span & async incompatibilities // but it shouldn't be an issue as we use a bigger bufferSize than the written payload - encoder.Write(MemoryMarshal.Cast(CompletedAppMemory.Span)); + encoder.Write(MemoryMarshal.Cast(appList.CompletedAppMemory.Span)); await encoder.FlushAsync(cancellationToken).ConfigureAwait(false); } [SuppressMessage("Code", "CAC001:ConfigureAwaitChecker")] - public async Task LoadFromFile(string filePath, CancellationToken cancellationToken = default) { + internal static async Task LoadFromFile(this CompletedAppList appList, string filePath, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(filePath)) { - return; + return false; } try { #pragma warning disable CA2007 - await using var sourceStream = new FileStream( + await using FileStream sourceStream = new( filePath, FileMode.Open, FileAccess.Read, FileShare.Read, - bufferSize: FileCompletedAppBufferSize, useAsync: true + bufferSize: CompletedAppList.FileCompletedAppBufferSize, useAsync: true ); // ReSharper disable once UseAwaitUsing - using var decoder = new BrotliStream(sourceStream, CompressionMode.Decompress); + using BrotliStream decoder = new(sourceStream, CompressionMode.Decompress); #pragma warning restore CA2007 ChangeBrotliEncoderToFastCompress(decoder); // ReSharper disable once UseAwaitUsing - using var ms = new MemoryStream(); + using MemoryStream ms = new(); await decoder.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); await decoder.FlushAsync(cancellationToken).ConfigureAwait(false); - if (CompletedAppBuffer is { Length: > 0 } && (ms.Length == CompletedAppMemory.Length * sizeof(long))) { + if (appList.CompletedAppBuffer is { Length: > 0 } && (ms.Length == appList.CompletedAppMemory.Length * sizeof(long))) { ms.Seek(0, SeekOrigin.Begin); - int size = ms.Read(MemoryMarshal.Cast(CompletedAppMemory.Span)); + int size = ms.Read(MemoryMarshal.Cast(appList.CompletedAppMemory.Span)); - if (size != CompletedAppMemory.Length * sizeof(long)) { + if (size != appList.CompletedAppMemory.Length * sizeof(long)) { ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError("[FreeGames] Unable to load previous completed app dict", nameof(LoadFromFile)); } try { - CompletedApps.Reload(); + appList.CompletedApps.Reload(); } catch (InvalidDataException e) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericWarningException(e, $"[FreeGames] {nameof(CompletedApps)}.{nameof(CompletedApps.Reload)}"); - CompletedApps.Reload(true); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericWarningException(e, $"[FreeGames] {nameof(appList.CompletedApps)}.{nameof(appList.CompletedApps.Reload)}"); + appList.CompletedApps.Reload(true); + + return false; } } else { ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError("[FreeGames] Unable to load previous completed app dict", nameof(LoadFromFile)); } + + return true; } catch (FileNotFoundException) { - return; + return false; } } @@ -149,11 +164,4 @@ private static void ChangeBrotliEncoderToFastCompress(BrotliStream encoder, int ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericDebuggingException(e, nameof(ChangeBrotliEncoderToFastCompress)); } } - - public bool Add(in GameIdentifier gameIdentifier) => CompletedApps.Add(in gameIdentifier); - public bool AddInvalid(in GameIdentifier gameIdentifier) => CompletedApps.AddInvalid(in gameIdentifier); - - public bool Contains(in GameIdentifier gameIdentifier) => CompletedApps.Contains(in gameIdentifier); - - public bool ContainsInvalid(in GameIdentifier gameIdentifier) => CompletedApps.ContainsInvalid(in gameIdentifier); } diff --git a/ASFFreeGames/RecentGameMapping.cs b/ASFFreeGames/AppLists/RecentGameMapping.cs similarity index 89% rename from ASFFreeGames/RecentGameMapping.cs rename to ASFFreeGames/AppLists/RecentGameMapping.cs index 73b25e4..08aaafd 100644 --- a/ASFFreeGames/RecentGameMapping.cs +++ b/ASFFreeGames/AppLists/RecentGameMapping.cs @@ -4,13 +4,14 @@ using System.IO; using System.Runtime.InteropServices; using System.Text; +using ASFFreeGames.ASFExtentions.Games; +using Maxisoft.ASF.ASFExtentions; using Maxisoft.Utils.Collections.Spans; -namespace Maxisoft.ASF; +namespace Maxisoft.ASF.AppLists; public class RecentGameMapping { - private const string Magic = "mdict"; - private static readonly ReadOnlyMemory MagicBytes = Encoding.UTF8.GetBytes(Magic); + private static ReadOnlySpan MagicBytes => "mdict"u8; private readonly Memory Buffer; private Memory SizeMemory; private Memory DictData; @@ -33,7 +34,7 @@ internal void InitMemories() { #pragma warning restore CA2201 } - MagicBytes.Span.CopyTo(MemoryMarshal.Cast(Buffer.Span)[..MagicBytes.Length]); + MagicBytes.CopyTo(MemoryMarshal.Cast(Buffer.Span)[..MagicBytes.Length]); int start = 1; @@ -48,7 +49,7 @@ internal void InitMemories() { public void Reset() => InitMemories(); internal void LoadMemories(bool allowFixes) { - ReadOnlySpan magicBytes = MagicBytes.Span; + ReadOnlySpan magicBytes = MagicBytes; ReadOnlySpan magicSpan = MemoryMarshal.Cast(Buffer.Span)[..magicBytes.Length]; // ReSharper disable once LoopCanBeConvertedToQuery @@ -73,7 +74,7 @@ internal void LoadMemories(bool allowFixes) { throw new InvalidDataException(); } - var dict = SpanDict.CreateFromBuffer(DictData.Span); + SpanDict dict = SpanDict.CreateFromBuffer(DictData.Span); if (dict.Count != CountRef) { if (!allowFixes) { diff --git a/ASFFreeGames/BloomFilters/StringBloomFilterSpan.cs b/ASFFreeGames/BloomFilters/StringBloomFilterSpan.cs deleted file mode 100644 index da695f8..0000000 --- a/ASFFreeGames/BloomFilters/StringBloomFilterSpan.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using Maxisoft.Utils.Collections.Spans; - -namespace BloomFilter; - -[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] -[SuppressMessage("Design", "CA1051")] -public ref struct StringBloomFilterSpan { - public readonly int HashFunctionCount; - - /// - /// The ratio of false to true bits in the filter. E.g., 1 true bit in a 10 bit filter means a truthiness of 0.1. - /// - public float Truthiness => (float) TrueBits() / HashBits.Count; - - public BitSpan HashBits; - - /// - /// - /// Creates a new Bloom filter. - /// - /// The anticipated number of items to be added to the filter. More than this number of items can be added, but the error rate will exceed what is expected. - /// The accepable false-positive rate (e.g., 0.01F = 1%) - public StringBloomFilterSpan(BitSpan bitSpan, float errorRate) : this(bitSpan, SolveK(bitSpan.Count, errorRate)) { } - - /// - /// Creates a new Bloom filter. - /// - /// The anticipated number of items to be added to the filter. More than this number of items can be added, but the error rate will exceed what is expected. - /// The number of hash functions to use. - public StringBloomFilterSpan(BitSpan bitSpan, int k = 1) { - // validate the params are in range - if (bitSpan.Count < 1) { - throw new ArgumentOutOfRangeException(nameof(bitSpan), bitSpan.Count, "capacity must be > 0"); - } - - HashFunctionCount = k; - HashBits = bitSpan; - } - - /// - /// Adds a new item to the filter. It cannot be removed. - /// - /// The item. - public void Add(in string item) { - // start flipping bits for each hash of item -#pragma warning disable CA1062 - int primaryHash = item.GetHashCode(StringComparison.Ordinal); -#pragma warning restore CA1062 - int secondaryHash = HashString(item); - - for (int i = 0; i < HashFunctionCount; i++) { - int hash = ComputeHash(primaryHash, secondaryHash, i); - HashBits[hash] = true; - } - } - - /// - /// Checks for the existance of the item in the filter for a given probability. - /// - /// The item. - /// The . - public bool Contains(in string item) { -#pragma warning disable CA1062 - int primaryHash = item.GetHashCode(StringComparison.Ordinal); -#pragma warning restore CA1062 - int secondaryHash = HashString(item); - - for (int i = 0; i < HashFunctionCount; i++) { - int hash = ComputeHash(primaryHash, secondaryHash, i); - - if (HashBits[hash] == false) { - return false; - } - } - - return true; - } - - public int Populate(ReadOnlySpan span) { - int leftOver = HashBits.Count % 8 == 0 ? 0 : 1; - int c = 0; - - if (span.Length != (HashBits.Count / 8) + leftOver) { - throw new ArgumentOutOfRangeException(nameof(span)); - } - - foreach (byte b in span.Slice(0, span.Length - leftOver)) { - int mask = 1; - - for (int i = 0; i < 8; i++) { - HashBits[c] = (b & mask) != 0; - mask = mask << 1; - c++; - } - } - - if (leftOver != 0) { - byte b = span[^1]; - int mask = 1; - - while (c < HashBits.Count) { - HashBits[c] = (b & mask) != 0; - mask = mask << 1; - c++; - } - } - - return c; - } - - /// - /// - /// - /// - /// - /// - /// - public static int SolveK(int m, double errorRate, int maxK = 32) { - double bestN = double.MinValue; - int bestK = 0; - bool noProgress = false; - - // TODO faster algo - // Like searching from both end and start - // Or use newton gradient methods - - for (int k = 0; k < maxK; k++) { - double n = m / (-k / Math.Log(1 - Math.Exp(Math.Log(errorRate) / k))); - - if (n > bestN) { - bestN = n; - bestK = k; - } - else if (noProgress) { - break; - } - else { - noProgress = true; - } - } - - return bestK; - } - - public byte[] ToArray() { - byte[] res = new byte[(HashBits.Count / 8) + (HashBits.Count % 8 == 0 ? 0 : 1)]; - - for (int i = 0; i < HashBits.Count; i++) { - res[i / 8] |= (byte) ((HashBits[i] ? 1 : 0) << (i % 8)); - } - - return res; - } - - /// - /// Performs Dillinger and Manolios double hashing. - /// - /// The primary hash. - /// The secondary hash. - /// The i. - /// The . - private int ComputeHash(int primaryHash, int secondaryHash, int i) { - unchecked { - int resultingHash = (primaryHash + (i * secondaryHash)) % HashBits.Count; - - return Math.Abs(resultingHash); - } - } - - /// - /// Hashes a string using Bob Jenkin's "One At A Time" method from Dr. Dobbs (http://burtleburtle.net/bob/hash/doobs.html). - /// Runtime is suggested to be 9x+9, where x = input.Length. - /// - /// The string to hash. - /// The hashed result. - private static int HashString(string s) { - int hash = 0; - - unchecked { - foreach (char t in s) { - hash += t; - hash += hash << 10; - hash ^= hash >> 6; - } - - hash += hash << 3; - hash ^= hash >> 11; - hash += hash << 15; - } - - return hash; - } - - /// - /// The true bits. - /// - /// The . - private int TrueBits() { - int output = 0; - - foreach (bool bit in HashBits) { - if (bit) { - output++; - } - } - - return output; - } -} diff --git a/ASFFreeGames/CollectIntervalManager.cs b/ASFFreeGames/CollectIntervalManager.cs index f4fb486..53ef83d 100644 --- a/ASFFreeGames/CollectIntervalManager.cs +++ b/ASFFreeGames/CollectIntervalManager.cs @@ -1,5 +1,6 @@ using System; using System.Threading; +using Maxisoft.ASF.Utils; namespace Maxisoft.ASF; @@ -27,7 +28,7 @@ internal interface ICollectIntervalManager : IDisposable { void StopTimer(); } -internal sealed class CollectIntervalManager : ICollectIntervalManager { +internal sealed class CollectIntervalManager(IASFFreeGamesPlugin plugin) : ICollectIntervalManager { private static readonly RandomUtils.GaussianRandom Random = new(); /// @@ -39,17 +40,11 @@ internal sealed class CollectIntervalManager : ICollectIntervalManager { /// /// This property is used to multiply the standard deviation of the normal distribution used to generate the random delay in the GetRandomizedTimerDelay method. If this property returns 0, then the random delay will be equal to the mean value. /// - private int RandomizeIntervalSwitch => Plugin.Options.RandomizeRecheckInterval ?? true ? 1 : 0; - - // The reference to the plugin instance - private readonly IASFFreeGamesPlugin Plugin; + private int RandomizeIntervalSwitch => plugin.Options.RandomizeRecheckInterval ?? true ? 1 : 0; // The timer instance private Timer? Timer; - // The constructor that takes a plugin instance as a parameter - public CollectIntervalManager(IASFFreeGamesPlugin plugin) => Plugin = plugin; - public void Dispose() => StopTimer(); // The public method that starts the timer if needed @@ -59,10 +54,10 @@ public void StartTimerIfNeeded() { TimeSpan initialDelay = GetRandomizedTimerDelay(30, 6 * RandomizeIntervalSwitch, 1, 5 * 60); // Get a random regular delay - TimeSpan regularDelay = GetRandomizedTimerDelay(Plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); + TimeSpan regularDelay = GetRandomizedTimerDelay(plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); // Create a new timer with the collect operation as the callback - Timer = new Timer(Plugin.CollectGamesOnClock); + Timer = new Timer(plugin.CollectGamesOnClock); // Start the timer with the initial and regular delays Timer.Change(initialDelay, regularDelay); @@ -74,12 +69,12 @@ public void StartTimerIfNeeded() { /// /// The randomized delay. /// - private TimeSpan GetRandomizedTimerDelay() => GetRandomizedTimerDelay(Plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); + private TimeSpan GetRandomizedTimerDelay() => GetRandomizedTimerDelay(plugin.Options.RecheckInterval.TotalSeconds, 7 * 60 * RandomizeIntervalSwitch); public TimeSpan RandomlyChangeCollectInterval(object? source) { // Calculate a random delay using GetRandomizedTimerDelay method TimeSpan delay = GetRandomizedTimerDelay(); - ResetTimer(() => new Timer(state => Plugin.CollectGamesOnClock(state), source, delay, delay)); + ResetTimer(() => new Timer(state => plugin.CollectGamesOnClock(state), source, delay, delay)); return delay; } diff --git a/ASFFreeGames/Commands/CommandDispatcher.cs b/ASFFreeGames/Commands/CommandDispatcher.cs index 2e9bcb3..4544da1 100644 --- a/ASFFreeGames/Commands/CommandDispatcher.cs +++ b/ASFFreeGames/Commands/CommandDispatcher.cs @@ -18,9 +18,6 @@ internal sealed class CommandDispatcher(ASFFreeGamesOptions options) : IBotComma { "FREEGAMES", new FreeGamesCommand(options) } }; - // Define a constructor that takes an plugin options instance as a parameter - // Initialize the commands dictionary with instances of GetIPCommand and FreeGamesCommand - public async Task Execute(Bot? bot, string message, string[] args, ulong steamID = 0, CancellationToken cancellationToken = default) { try { if (args is { Length: > 0 }) { @@ -36,7 +33,7 @@ internal sealed class CommandDispatcher(ASFFreeGamesOptions options) : IBotComma // ReSharper disable once RedundantAssignment bool verboseLogging = Options.VerboseLog ?? false; #if DEBUG - verboseLogging = true; // Enable verbose logging in debug mode + verboseLogging = true; // Enforce verbose logging in debug mode #endif if (verboseLogging) { diff --git a/ASFFreeGames/Commands/FreeGamesCommand.cs b/ASFFreeGames/Commands/FreeGamesCommand.cs index 17985e6..0396f60 100644 --- a/ASFFreeGames/Commands/FreeGamesCommand.cs +++ b/ASFFreeGames/Commands/FreeGamesCommand.cs @@ -8,17 +8,24 @@ using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Steam; +using ASFFreeGames.ASFExtentions.Bot; +using ASFFreeGames.ASFExtentions.Games; using ASFFreeGames.Configurations; using Maxisoft.ASF; +using Maxisoft.ASF.ASFExtentions; using Maxisoft.ASF.Configurations; +using Maxisoft.ASF.FreeGames.Strategies; using Maxisoft.ASF.HttpClientSimple; using Maxisoft.ASF.Reddit; +using Maxisoft.ASF.Utils; using SteamKit2; namespace ASFFreeGames.Commands { // Implement the IBotCommand interface internal sealed class FreeGamesCommand(ASFFreeGamesOptions options) : IBotCommand, IDisposable { public void Dispose() { + Strategy.Dispose(); + if (HttpFactory.IsValueCreated) { HttpFactory.Value.Dispose(); } @@ -36,6 +43,9 @@ public void Dispose() { private readonly Lazy HttpFactory = new(() => new SimpleHttpClientFactory(options)); + public IListFreeGamesStrategy Strategy { get; internal set; } = new ListFreeGamesMainStrategy(); + public EListFreeGamesStrategy PreviousSucessfulStrategy { get; private set; } = EListFreeGamesStrategy.Reddit | EListFreeGamesStrategy.Redlib; + // Define a constructor that takes an plugin options instance as a parameter /// @@ -212,11 +222,17 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS int res = 0; try { - ICollection games; + IReadOnlyCollection games; + + ListFreeGamesContext strategyContext = new(Options, new Lazy(() => HttpFactory.Value.CreateGeneric())) { + Strategy = Strategy, + HttpClientFactory = HttpFactory.Value, + PreviousSucessfulStrategy = PreviousSucessfulStrategy + }; try { #pragma warning disable CA2000 - games = await RedditHelper.GetGames(HttpFactory.Value.CreateForReddit(), cancellationToken).ConfigureAwait(false); + games = await Strategy.GetGames(strategyContext, cancellationToken).ConfigureAwait(false); #pragma warning restore CA2000 } catch (Exception e) when (e is InvalidOperationException or JsonException or IOException or RedditServerException) { @@ -224,13 +240,23 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericException(e); } else { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"Unable to load json from reddit {e.GetType().Name}: {e.Message}"); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"Unable to get and load json {e.GetType().Name}: {e.Message}"); } return 0; } + finally { + PreviousSucessfulStrategy = strategyContext.PreviousSucessfulStrategy; + + if (Options.VerboseLog ?? false) { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"PreviousSucessfulStrategy = {PreviousSucessfulStrategy}"); + } + } - LogNewGameCount(games, VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser); +#pragma warning disable CA1308 + string remote = strategyContext.PreviousSucessfulStrategy.ToString().ToLowerInvariant(); +#pragma warning restore CA1308 + LogNewGameCount(games, remote, VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser); foreach (Bot bot in bots) { if (cancellationToken.IsCancellationRequested) { @@ -256,7 +282,7 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS continue; } - foreach ((string? identifier, long time, bool freeToPlay, bool dlc) in games) { + foreach ((string identifier, long time, bool freeToPlay, bool dlc) in games) { if (freeToPlay && Options.SkipFreeToPlay is true) { continue; } @@ -265,7 +291,7 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS continue; } - if (identifier is null || !GameIdentifier.TryParse(identifier, out var gid)) { + if (string.IsNullOrWhiteSpace(identifier) || !GameIdentifier.TryParse(identifier, out GameIdentifier gid)) { continue; } @@ -344,7 +370,7 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS return res; } - private void LogNewGameCount(ICollection games, bool logZero = false) { + private void LogNewGameCount(IReadOnlyCollection games, string remote, bool logZero = false) { int totalAppIdCounter = PreviouslySeenAppIds.Count; int newGameCounter = 0; @@ -355,13 +381,13 @@ private void LogNewGameCount(ICollection games, bool logZero = } if ((totalAppIdCounter == 0) && (games.Count > 0)) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"[FreeGames] found potentially {games.Count} free games on reddit", nameof(CollectGames)); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"[FreeGames] found potentially {games.Count} free games on {remote}", nameof(CollectGames)); } else if (newGameCounter > 0) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"[FreeGames] found {newGameCounter} fresh free game(s) on reddit", nameof(CollectGames)); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"[FreeGames] found {newGameCounter} fresh free game(s) on {remote}", nameof(CollectGames)); } else if ((newGameCounter == 0) && logZero) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"[FreeGames] found 0 new game out of {games.Count} free games on reddit", nameof(CollectGames)); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"[FreeGames] found 0 new game out of {games.Count} free games on {remote}", nameof(CollectGames)); } } diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs index 0c1e75a..7eef9b7 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptions.cs @@ -4,7 +4,9 @@ using System.Linq; using System.Text.Json.Serialization; using ArchiSteamFarm.Steam; +using ASFFreeGames.ASFExtentions.Games; using Maxisoft.ASF; +using Maxisoft.ASF.ASFExtentions; namespace ASFFreeGames.Configurations; @@ -49,5 +51,13 @@ public bool IsBlacklisted(in GameIdentifier gid) { [JsonPropertyName("redditProxy")] public string? RedditProxy { get; set; } + + [JsonPropertyName("redlibProxy")] + public string? RedlibProxy { get; set; } #endregion + + [JsonPropertyName("redlibInstanceUrl")] +#pragma warning disable CA1056 + public string? RedlibInstanceUrl { get; set; } = "https://raw.githubusercontent.com/redlib-org/redlib-instances/main/instances.json"; +#pragma warning restore CA1056 } diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs index f4fd2b4..93e8835 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptionsLoader.cs @@ -31,6 +31,8 @@ public static void Bind(ref ASFFreeGamesOptions options) { options.RandomizeRecheckInterval = configurationRoot.GetValue("RandomizeRecheckInterval", options.RandomizeRecheckInterval); options.Proxy = configurationRoot.GetValue("Proxy", options.Proxy); options.RedditProxy = configurationRoot.GetValue("RedditProxy", options.RedditProxy); + options.RedlibProxy = configurationRoot.GetValue("RedlibProxy", options.RedlibProxy); + options.RedlibInstanceUrl = configurationRoot.GetValue("RedlibInstanceUrl", options.RedlibInstanceUrl); } finally { Semaphore.Release(); diff --git a/ASFFreeGames/Configurations/ASFFreeGamesOptionsSaver.cs b/ASFFreeGames/Configurations/ASFFreeGamesOptionsSaver.cs index dededbe..3cce75b 100644 --- a/ASFFreeGames/Configurations/ASFFreeGamesOptionsSaver.cs +++ b/ASFFreeGames/Configurations/ASFFreeGamesOptionsSaver.cs @@ -49,11 +49,12 @@ internal static int CreateOptionsBuffer(ASFFreeGamesOptions options, IMemoryOwne written += WriteNameAndProperty("verboseLog"u8, options.VerboseLog, buffer, written); written += WriteNameAndProperty("proxy"u8, options.Proxy, buffer, written); written += WriteNameAndProperty("redditProxy"u8, options.RedditProxy, buffer, written); + written += WriteNameAndProperty("redlibProxy"u8, options.RedlibProxy, buffer, written); + written += WriteNameAndProperty("redlibInstanceUrl"u8, options.RedlibInstanceUrl, buffer, written); RemoveTrailingCommaAndLineReturn(buffer, ref written); written += WriteJsonString("\n}"u8, buffer, written); - // Resize buffer if needed if (written >= buffer.Length) { throw new InvalidOperationException("Buffer overflow while saving options"); } diff --git a/ASFFreeGames/ContextRegistry.cs b/ASFFreeGames/ContextRegistry.cs index 1febb58..386712b 100644 --- a/ASFFreeGames/ContextRegistry.cs +++ b/ASFFreeGames/ContextRegistry.cs @@ -1,7 +1,10 @@ using System.Collections.Concurrent; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Steam; +using ASFFreeGames.ASFExtentions.Bot; +using Maxisoft.ASF.ASFExtentions; namespace Maxisoft.ASF { /// @@ -44,7 +47,7 @@ internal sealed class ContextRegistry : IContextRegistry { private readonly ConcurrentDictionary BotContexts = new(); /// - public BotContext? GetBotContext(Bot bot) => BotContexts.TryGetValue(bot.BotName, out BotContext? context) ? context : null; + public BotContext? GetBotContext(Bot bot) => BotContexts.GetValueOrDefault(bot.BotName); /// public ValueTask RemoveBotContext(Bot bot) => ValueTask.FromResult(BotContexts.TryRemove(bot.BotName, out _)); diff --git a/ASFFreeGames/FreeGames/Strategies/EListFreeGamesStrategy.cs b/ASFFreeGames/FreeGames/Strategies/EListFreeGamesStrategy.cs new file mode 100644 index 0000000..f088d49 --- /dev/null +++ b/ASFFreeGames/FreeGames/Strategies/EListFreeGamesStrategy.cs @@ -0,0 +1,11 @@ +using System; + +// ReSharper disable once CheckNamespace +namespace Maxisoft.ASF.FreeGames.Strategies; + +[Flags] +public enum EListFreeGamesStrategy { + None = 0, + Reddit = 1 << 0, + Redlib = 1 << 1 +} diff --git a/ASFFreeGames/FreeGames/Strategies/HttpRequestRedlibException.cs b/ASFFreeGames/FreeGames/Strategies/HttpRequestRedlibException.cs new file mode 100644 index 0000000..665f9c9 --- /dev/null +++ b/ASFFreeGames/FreeGames/Strategies/HttpRequestRedlibException.cs @@ -0,0 +1,15 @@ +using System; +using System.Net; +using Maxisoft.ASF.Redlib; + +// ReSharper disable once CheckNamespace +namespace Maxisoft.ASF.FreeGames.Strategies; + +public class HttpRequestRedlibException : RedlibException { + public required HttpStatusCode? StatusCode { get; init; } + public required Uri? Uri { get; init; } + + public HttpRequestRedlibException() { } + public HttpRequestRedlibException(string message) : base(message) { } + public HttpRequestRedlibException(string message, Exception inner) : base(message, inner) { } +} diff --git a/ASFFreeGames/FreeGames/Strategies/IListFreeGamesStrategy.cs b/ASFFreeGames/FreeGames/Strategies/IListFreeGamesStrategy.cs new file mode 100644 index 0000000..12222a5 --- /dev/null +++ b/ASFFreeGames/FreeGames/Strategies/IListFreeGamesStrategy.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Maxisoft.ASF.Reddit; + +// ReSharper disable once CheckNamespace +namespace Maxisoft.ASF.FreeGames.Strategies; + +[SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] +public interface IListFreeGamesStrategy : IDisposable { + Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken); + + public static Exception ExceptionFromTask([NotNull] Task task) { + if (task is { IsFaulted: true, Exception: not null }) { + return task.Exception.InnerExceptions.Count == 1 ? task.Exception.InnerExceptions[0] : task.Exception; + } + + if (task.IsCanceled) { + return new TaskCanceledException(); + } + + throw new InvalidOperationException("Unknown task state"); + } +} diff --git a/ASFFreeGames/FreeGames/Strategies/ListFreeGamesContext.cs b/ASFFreeGames/FreeGames/Strategies/ListFreeGamesContext.cs new file mode 100644 index 0000000..42b66cb --- /dev/null +++ b/ASFFreeGames/FreeGames/Strategies/ListFreeGamesContext.cs @@ -0,0 +1,13 @@ +using System; +using ASFFreeGames.Configurations; +using Maxisoft.ASF.HttpClientSimple; + +// ReSharper disable once CheckNamespace +namespace Maxisoft.ASF.FreeGames.Strategies; + +public sealed record ListFreeGamesContext(ASFFreeGamesOptions Options, Lazy HttpClient, uint Retry = 5) { + public required SimpleHttpClientFactory HttpClientFactory { get; init; } + public EListFreeGamesStrategy PreviousSucessfulStrategy { get; set; } + + public required IListFreeGamesStrategy Strategy { get; init; } +} diff --git a/ASFFreeGames/FreeGames/Strategies/ListFreeGamesMainStrategy.cs b/ASFFreeGames/FreeGames/Strategies/ListFreeGamesMainStrategy.cs new file mode 100644 index 0000000..5455649 --- /dev/null +++ b/ASFFreeGames/FreeGames/Strategies/ListFreeGamesMainStrategy.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Maxisoft.ASF.HttpClientSimple; +using Maxisoft.ASF.Reddit; + +// ReSharper disable once CheckNamespace +namespace Maxisoft.ASF.FreeGames.Strategies; + +[SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] +public class ListFreeGamesMainStrategy : IListFreeGamesStrategy { + private readonly RedditListFreeGamesStrategy RedditStrategy = new(); + private readonly RedlibListFreeGamesStrategy RedlibStrategy = new(); + + private SemaphoreSlim StrategySemaphore { get; } = new(1, 1); // prevents concurrent run and access to internal state + + public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + public async Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { + await StrategySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try { + return await DoGetGames(context, cancellationToken).ConfigureAwait(false); + } + finally { + StrategySemaphore.Release(); + } + } + + protected virtual void Dispose(bool disposing) { + if (disposing) { + RedditStrategy.Dispose(); + RedlibStrategy.Dispose(); + StrategySemaphore.Dispose(); + } + } + + private async Task> DoGetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + List disposables = []; + + try { + Task> redditTask1 = FirstTryRedditStrategy(context, disposables, cts.Token); + disposables.Add(redditTask1); + + try { + await WaitForFirstTryRedditStrategy(context, redditTask1, cts.Token).ConfigureAwait(false); + } + catch (Exception) { + // ignored and handled below + } + + if (redditTask1.IsCompletedSuccessfully) { + IReadOnlyCollection result = await redditTask1.ConfigureAwait(false); + + if (result.Count > 0) { + context.PreviousSucessfulStrategy |= EListFreeGamesStrategy.Reddit; + + return result; + } + } + + CancellationTokenSource cts2 = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); + disposables.Add(cts2); + cts2.CancelAfter(TimeSpan.FromSeconds(45)); + + Task> redlibTask = RedlibStrategy.GetGames(context with { HttpClient = new Lazy(() => context.HttpClientFactory.CreateForRedlib()) }, cts2.Token); + disposables.Add(redlibTask); + + Task> redditTask2 = LastTryRedditStrategy(context, redditTask1, cts2.Token); + disposables.Add(redditTask2); + + context.PreviousSucessfulStrategy = EListFreeGamesStrategy.None; + + Task>[] strategiesTasks = [redditTask1, redditTask2, redlibTask]; // note that order matters + + try { + IReadOnlyCollection? res = await WaitForStrategiesTasks(cts.Token, strategiesTasks).ConfigureAwait(false); + + if (res is { Count: > 0 }) { + return res; + } + } + finally { + if (redditTask1.IsCompletedSuccessfully || redditTask2.IsCompletedSuccessfully) { + context.PreviousSucessfulStrategy |= EListFreeGamesStrategy.Reddit; + } + +#pragma warning disable CA1849 + if (redlibTask is { IsCompletedSuccessfully: true, Result.Count: > 0 }) { +#pragma warning restore CA1849 + context.PreviousSucessfulStrategy |= EListFreeGamesStrategy.Redlib; + } + + await cts.CancelAsync().ConfigureAwait(false); + await cts2.CancelAsync().ConfigureAwait(false); + + try { + await Task.WhenAll(strategiesTasks).ConfigureAwait(false); + } + catch (Exception) { + // ignored + } + } + + List exceptions = new(strategiesTasks.Length); + exceptions.AddRange(from task in strategiesTasks where task.IsFaulted || task.IsCanceled select IListFreeGamesStrategy.ExceptionFromTask(task)); + + switch (exceptions.Count) { + case 1: + throw exceptions[0]; + case > 0: + throw new AggregateException(exceptions); + } + } + finally { + foreach (IDisposable disposable in disposables) { + disposable.Dispose(); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + throw new InvalidOperationException("This should never happen"); + } + + // ReSharper disable once SuggestBaseTypeForParameter + private async Task> FirstTryRedditStrategy(ListFreeGamesContext context, List disposables, CancellationToken cancellationToken) { + CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + disposables.Add(cts); + cts.CancelAfter(TimeSpan.FromSeconds(10)); + + if (!context.PreviousSucessfulStrategy.HasFlag(EListFreeGamesStrategy.Reddit)) { + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); + } + + return await RedditStrategy.GetGames( + context with { + Retry = 1, + HttpClient = new Lazy(() => context.HttpClientFactory.CreateForReddit()) + }, cts.Token + ).ConfigureAwait(false); + } + + private async Task> LastTryRedditStrategy(ListFreeGamesContext context, Task firstTryTask, CancellationToken cancellationToken) { + if (!firstTryTask.IsCompleted) { + try { + await firstTryTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception) { + // ignored it'll be handled by caller + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + return await RedditStrategy.GetGames( + context with { + Retry = checked(context.Retry - 1), + HttpClient = new Lazy(() => context.HttpClientFactory.CreateForReddit()) + }, cancellationToken + ).ConfigureAwait(false); + } + + private static async Task WaitForFirstTryRedditStrategy(ListFreeGamesContext context, Task redditTask, CancellationToken cancellationToken) { + if (context.PreviousSucessfulStrategy.HasFlag(EListFreeGamesStrategy.Reddit)) { + try { + await Task.WhenAny(redditTask, Task.Delay(2500, cancellationToken)).ConfigureAwait(false); + } + catch (Exception e) { + if (e is OperationCanceledException or TimeoutException && cancellationToken.IsCancellationRequested) { + throw; + } + } + } + } + + private static async Task?> WaitForStrategiesTasks(CancellationToken cancellationToken, params Task>[] p) { + LinkedList>> tasks = []; + + foreach (Task> task in p) { + tasks.AddLast(task); + } + + while ((tasks.Count != 0) && !cancellationToken.IsCancellationRequested) { + try { + await Task.WhenAny(tasks).ConfigureAwait(false); + } + catch (Exception) { + // ignored + } + + LinkedListNode>>? taskNode = tasks.First; + + while (taskNode is not null) { + if (taskNode.Value.IsCompletedSuccessfully) { + IReadOnlyCollection result = await taskNode.Value.ConfigureAwait(false); + + if (result.Count > 0) { + return result; + } + } + + if (taskNode.Value.IsCompleted) { + tasks.Remove(taskNode.Value); + taskNode = tasks.First; + + continue; + } + + taskNode = taskNode.Next; + } + } + + return null; + } +} diff --git a/ASFFreeGames/FreeGames/Strategies/RedditListFreeGamesStrategy.cs b/ASFFreeGames/FreeGames/Strategies/RedditListFreeGamesStrategy.cs new file mode 100644 index 0000000..799546d --- /dev/null +++ b/ASFFreeGames/FreeGames/Strategies/RedditListFreeGamesStrategy.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Maxisoft.ASF.Reddit; + +// ReSharper disable once CheckNamespace +namespace Maxisoft.ASF.FreeGames.Strategies; + +[SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] +public sealed class RedditListFreeGamesStrategy : IListFreeGamesStrategy { + public void Dispose() { } + + public async Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + + return await RedditHelper.GetGames(context.HttpClient.Value, context.Retry, cancellationToken).ConfigureAwait(false); + } +} diff --git a/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs b/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs new file mode 100644 index 0000000..e996d1a --- /dev/null +++ b/ASFFreeGames/FreeGames/Strategies/RedlibListFreeGamesStrategy.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ArchiSteamFarm.Core; +using Maxisoft.ASF.HttpClientSimple; +using Maxisoft.ASF.Reddit; +using Maxisoft.ASF.Redlib; +using Maxisoft.ASF.Redlib.Html; +using Maxisoft.ASF.Redlib.Instances; + +// ReSharper disable once CheckNamespace +namespace Maxisoft.ASF.FreeGames.Strategies; + +[SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] +public sealed class RedlibListFreeGamesStrategy : IListFreeGamesStrategy { + private readonly SemaphoreSlim DownloadSemaphore = new(4, 4); + private readonly CachedRedlibInstanceListStorage InstanceListCache = new(Array.Empty(), DateTimeOffset.MinValue); + + public void Dispose() => DownloadSemaphore.Dispose(); + + public async Task> GetGames([NotNull] ListFreeGamesContext context, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + + CachedRedlibInstanceList instanceList = new(context.Options, InstanceListCache); + + List instances = await instanceList.ListInstances(context.HttpClientFactory.CreateForGithub(), cancellationToken).ConfigureAwait(false); + instances = Shuffle(instances); + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(60_000); + + LinkedList>> tasks = []; + Task>[] allTasks = []; + + try { + foreach (Uri uri in instances) { + tasks.AddLast(DownloadUsingInstance(context.HttpClient.Value, uri, context.Retry, cts.Token)); + } + + allTasks = tasks.ToArray(); + IReadOnlyCollection result = await MonitorDownloads(tasks, cts.Token).ConfigureAwait(false); + + if (result.Count > 0) { + return result; + } + } + finally { + await cts.CancelAsync().ConfigureAwait(false); + + try { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + catch (Exception) { + // ignored + } + + foreach (Task> task in allTasks) { + task.Dispose(); + } + } + + List exceptions = new(allTasks.Length); + exceptions.AddRange(from task in allTasks where task.IsCanceled || task.IsFaulted select IListFreeGamesStrategy.ExceptionFromTask(task)); + + switch (exceptions.Count) { + case 1: + throw exceptions[0]; + case > 0: + throw new AggregateException(exceptions); + default: + cts.Token.ThrowIfCancellationRequested(); + + throw new InvalidOperationException("This should never happen"); + } + } + + private async Task> DoDownloadUsingInstance(SimpleHttpClient client, Uri uri, CancellationToken cancellationToken) { + await DownloadSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + string content; + + try { +#pragma warning disable CAC001 +#pragma warning disable CA2007 + await using HttpStreamResponse resp = await client.GetStreamAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false); +#pragma warning restore CA2007 +#pragma warning restore CAC001 + + if (!resp.HasValidStream) { + throw new HttpRequestRedlibException("invalid stream for " + uri) { + Uri = uri, + StatusCode = resp.StatusCode + }; + } + else if (!resp.StatusCode.IsSuccessCode()) { + throw new HttpRequestRedlibException($"invalid status code {resp.StatusCode} for {uri}") { + Uri = uri, + StatusCode = resp.StatusCode + }; + } + else { + content = await resp.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + } + finally { + DownloadSemaphore.Release(); + } + + IReadOnlyCollection entries = RedlibHtmlParser.ParseGamesFromHtml(content); + long now = DateTimeOffset.Now.ToUnixTimeMilliseconds(); // TODO read the date from the response's content + + return entries.Select(entry => entry.ToRedditGameEntry(now)).ToArray(); + } + + private async Task> DownloadUsingInstance(SimpleHttpClient client, Uri uri, uint retry, CancellationToken cancellationToken) { + Uri fullUrl = new($"{uri.ToString().TrimEnd('/')}/user/{RedditHelper.User}?sort=new", UriKind.Absolute); + + for (int t = 0; t < retry; t++) { + try { + return await DoDownloadUsingInstance(client, fullUrl, cancellationToken).ConfigureAwait(false); + } + catch (Exception) { + if ((t == retry - 1) || cancellationToken.IsCancellationRequested) { + throw; + } + + await Task.Delay(1000 * (1 << t), cancellationToken).ConfigureAwait(false); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + throw new InvalidOperationException("This should never happen"); + } + + private static async Task> MonitorDownloads(LinkedList>> tasks, CancellationToken cancellationToken) { + while (tasks.Count > 0) { + cancellationToken.ThrowIfCancellationRequested(); + + try { + await Task.WhenAny(tasks).ConfigureAwait(false); + } + catch (Exception) { + //ignored + } + + LinkedListNode>>? node = tasks.First; + + while (node is not null) { + Task> task = node.Value; + + if (task.IsCompletedSuccessfully) { + IReadOnlyCollection result = await task.ConfigureAwait(false); + + if (result.Count > 0) { + return result; + } + } + + if (task.IsCompleted) { + tasks.Remove(node); + node = tasks.First; + task.Dispose(); + + continue; + } + + node = node.Next; + } + } + + return []; + } + + /// + /// Shuffles a list of URIs.
+ /// This is done using a non performant guids generation for asf trimmed binary compatibility. + ///
+ /// The list of URIs to shuffle. + /// A shuffled list of URIs. + private static List Shuffle(TCollection list) where TCollection : ICollection { + List<(Guid, Uri)> randomized = new(list.Count); + randomized.AddRange(list.Select(static uri => (Guid.NewGuid(), uri))); + + randomized.Sort(static (x, y) => x.Item1.CompareTo(y.Item1)); + + return randomized.Select(static x => x.Item2).ToList(); + } +} diff --git a/ASFFreeGames/HttpClientSimple/SimpleHttpClientFactory.cs b/ASFFreeGames/HttpClientSimple/SimpleHttpClientFactory.cs index a9ab6ed..cf18428 100644 --- a/ASFFreeGames/HttpClientSimple/SimpleHttpClientFactory.cs +++ b/ASFFreeGames/HttpClientSimple/SimpleHttpClientFactory.cs @@ -25,7 +25,9 @@ public sealed class SimpleHttpClientFactory(ASFFreeGamesOptions options) : IDisp private enum ECacheKey { Generic, - Reddit + Reddit, + Redlib, + Github } private SimpleHttpClient CreateFor(ECacheKey key, string? proxy = null) { @@ -72,7 +74,11 @@ private SimpleHttpClient CreateFor(ECacheKey key, string? proxy = null) { } } - public SimpleHttpClient CreateForReddit() => CreateFor(ECacheKey.Reddit, options.RedditProxy); + public SimpleHttpClient CreateForReddit() => CreateFor(ECacheKey.Reddit, options.RedditProxy ?? options.Proxy); + public SimpleHttpClient CreateForRedlib() => CreateFor(ECacheKey.Redlib, options.RedlibProxy ?? options.RedditProxy ?? options.Proxy); + public SimpleHttpClient CreateForGithub() => CreateFor(ECacheKey.Github, options.Proxy); + + public SimpleHttpClient CreateGeneric() => CreateFor(ECacheKey.Generic, options.Proxy); public void Dispose() { lock (Cache) { diff --git a/ASFFreeGames/Maxisoft.Utils/IOrderedDictionary.cs b/ASFFreeGames/Maxisoft.Utils/IOrderedDictionary.cs new file mode 100644 index 0000000..48f54a3 --- /dev/null +++ b/ASFFreeGames/Maxisoft.Utils/IOrderedDictionary.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Maxisoft.Utils.Collections.Dictionaries { + public interface IOrderedDictionary : IDictionary { + public TValue this[int index] { get; set; } + public void Insert(int index, in TKey key, in TValue value); + public void RemoveAt(int index); + + public int IndexOf(in TKey key); + + public int IndexOf(in TValue value); + } +} diff --git a/ASFFreeGames/Maxisoft.Utils/OrderedDictionary.cs b/ASFFreeGames/Maxisoft.Utils/OrderedDictionary.cs new file mode 100644 index 0000000..1570079 --- /dev/null +++ b/ASFFreeGames/Maxisoft.Utils/OrderedDictionary.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; + +// ReSharper disable once RedundantNullableDirective +#nullable enable + +// ReSharper disable once CheckNamespace +namespace Maxisoft.Utils.Collections.Dictionaries { + /// + /// OrderedDictionary abstraction. Implement most 's operations using + /// generics. + /// + /// The key type. + /// The Value type. + /// The used to store the TKeys in ordered manner. + /// + /// The used to store the mapping between TKey + /// :TValue. + /// + /// + public abstract class OrderedDictionary : IOrderedDictionary + where TList : class, IList, new() where TDictionary : class, IDictionary, new() { + protected OrderedDictionary() { } + + protected internal OrderedDictionary(in TDictionary initial) : this(in initial, []) { } + + protected internal OrderedDictionary(in TDictionary initial, in TList list) { + Dictionary = initial; + Indexes = list; + +#pragma warning disable CA1062 + foreach (KeyValuePair value in initial) { +#pragma warning restore CA1062 + Indexes.Add(value.Key); + } + } + + protected TDictionary Dictionary { get; } = new(); + protected TList Indexes { get; } = []; + + public IEnumerator> GetEnumerator() { + foreach (TKey key in Indexes) { + bool res = Dictionary.TryGetValue(key, out TValue? value); + Debug.Assert(res); + +#pragma warning disable CS8604 // Possible null reference argument. + yield return new KeyValuePair(key, value); +#pragma warning restore CS8604 // Possible null reference argument. + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + /// + /// Append at the end a new TKey:TValue pair. + /// + /// + /// when the key already exists. + public void Add(KeyValuePair item) => DoAdd(item.Key, item.Value); + + public void Clear() { + Indexes.Clear(); + Dictionary.Clear(); + } + + public bool Contains(KeyValuePair item) => Contains(in item, EqualityComparer.Default); + + public void CopyTo(KeyValuePair[] array, int arrayIndex) { +#pragma warning disable CA1062 + if ((arrayIndex < 0) || (arrayIndex > array.Length)) { +#pragma warning restore CA1062 + throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + } + + if (array.Length - arrayIndex < Indexes.Count) { + throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + } + + int c = 0; + + foreach (TKey index in Indexes) { + bool res = Dictionary.TryGetValue(index, out TValue? value); + Debug.Assert(res); +#pragma warning disable CS8604 // Possible null reference argument. + array[c + arrayIndex] = new KeyValuePair(index, value); +#pragma warning restore CS8604 // Possible null reference argument. + c++; + } + } + + public bool Remove(KeyValuePair item) => Remove(item.Key); + + public int Count => Indexes.Count; + + public bool IsReadOnly => Indexes.IsReadOnly; + + /// + /// + /// Append at the end a new TKey:TValue pair. + /// + /// the key to add. + /// the value to end. + /// when the key already exists. + public void Add(TKey key, TValue value) => DoAdd(key, value); + + public bool ContainsKey(TKey key) => Dictionary.ContainsKey(key); + + public bool Remove(TKey key) { + if (Dictionary.Remove(key)) { + bool removed = Indexes.Remove(key); + + if (!removed) { + throw new InvalidOperationException(); + } + + return removed; + } + + return false; + } + +#pragma warning disable CS8601 // Possible null reference assignment. + public bool TryGetValue(TKey key, out TValue value) => Dictionary.TryGetValue(key, out value); +#pragma warning restore CS8601 // Possible null reference assignment. + + public TValue this[TKey key] { + get => Dictionary[key]; + set => DoAdd(key, value, true); + } + + public ICollection Keys => new KeyCollection>(this); + + public ICollection Values => new ValuesCollection>(this); + + public TValue this[int index] { + get => At(index).Value; + set => UpdateAt(index, value); + } + + public void Insert(int index, in TKey key, in TValue value) { + if (index == Indexes.Count) { + Add(key, value); + + return; + } + + CheckForOutOfBounds(index); + + if (ContainsKey(key)) { + throw new ArgumentException("key already exists"); + } + + Indexes.Insert(index, key); + DoUpdate(in key, in value, false); + } + + public void RemoveAt(int index) { + CheckForOutOfBounds(index); + + TKey key = Indexes[index]; + Indexes.RemoveAt(index); + + if (!Dictionary.Remove(key)) { + throw new InvalidOperationException(); + } + } + + public int IndexOf(in TKey key) => Indexes.IndexOf(key); + + public int IndexOf(in TValue value) => IndexOf(in value, EqualityComparer.Default); + + public bool Contains(in KeyValuePair item, TEqualityComparer comparer) + where TEqualityComparer : IEqualityComparer => + Dictionary.TryGetValue(item.Key, out TValue? value) && comparer.Equals(value, item.Value); + + public int IndexOf(in TValue value, TEqualityComparer comparer) + where TEqualityComparer : IEqualityComparer { + int c = 0; + + foreach (KeyValuePair pair in this) { + if (comparer.Equals(pair.Value, value)) { + return c; + } + + c++; + } + + return -1; + } + + /// + /// Access key-value pair at index like an array. + /// + /// + /// the pair at index. + /// index is out of bounds. + public KeyValuePair At(int index) { + CheckForOutOfBounds(index); + TKey key = Indexes[index]; + + return At(in key); + } + + /// + /// Access key-value pair at key like a dictionary. + /// + /// + /// the pair identified by key. + /// when the key doesn't exists. + public KeyValuePair At(in TKey key) { + bool res = Dictionary.TryGetValue(key, out TValue? value); + + if (!res) { + throw new KeyNotFoundException(); + } + +#pragma warning disable CS8604 // Possible null reference argument. + return new KeyValuePair(key, value); +#pragma warning restore CS8604 // Possible null reference argument. + } + + /// + /// Update the value for the given key. + /// + /// + /// + /// the key. + /// when the key doesn't exists. + public TKey UpdateAt(in TKey key, in TValue value) { + DoUpdate(in key, in value); + + return key; + } + + /// + /// Update the value at the given index. + /// + /// + /// + /// the key. + /// if index is out of bounds. + public TKey UpdateAt(int index, in TValue value) { + CheckForOutOfBounds(index); + + TKey key = Indexes[index]; + Debug.Assert(Dictionary.ContainsKey(key)); + DoUpdate(in key, in value, false); + + return key; + } + + /// + /// Swap the pair at the specified firstIndex to the + /// secondIndex . + /// + /// + /// + /// one of the parameters if out of bounds + public void Swap(int firstIndex, int secondIndex) { + CheckForOutOfBounds(firstIndex); + CheckForOutOfBounds(secondIndex); + (Indexes[secondIndex], Indexes[firstIndex]) = (Indexes[firstIndex], Indexes[secondIndex]); + } + + /// + /// Reorder the pair at the specified fromIndex to the + /// toIndex . + /// + /// The zero-based index of the element to move. + /// The zero-based index to move the element to. + /// one of the parameters if out of bounds + public void Move(int fromIndex, int toIndex) { + CheckForOutOfBounds(fromIndex); + CheckForOutOfBounds(toIndex); + + if (fromIndex == toIndex) { + return; + } + + // This is a naive way for the best TList compatibility + TKey tmp = Indexes[fromIndex]; + Indexes.RemoveAt(fromIndex); + Indexes.Insert(toIndex, tmp); + Debug.Assert(Dictionary.Count == Indexes.Count); + } + + protected void DoUpdate(in TKey key, in TValue value, bool ensureExists = true) { + if (ensureExists && !Dictionary.ContainsKey(key)) { + throw new KeyNotFoundException(); + } + + Dictionary[key] = value; + } + + protected void DoAdd(TKey key, TValue value, bool upsert = false) { + if (Dictionary.ContainsKey(key)) { + if (!upsert) { + throw new ArgumentException("key already exists", nameof(key)); + } + + DoUpdate(in key, in value, false); + + return; + } + + Indexes.Add(key); + + try { + Dictionary.Add(key, value); + } + catch (Exception) { + Indexes.RemoveAt(Indexes.Count - 1); + + throw; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected void CheckForOutOfBounds(int index, string paramName, string message = "") { + Debug.Assert(Dictionary.Count == Indexes.Count); + + if ((uint) index > (uint) Indexes.Count) { + throw new ArgumentOutOfRangeException(paramName, index, message); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected void CheckForOutOfBounds(int index) => CheckForOutOfBounds(index, nameof(index)); + + protected class KeyCollection : ICollection, IReadOnlyCollection + where TDict : OrderedDictionary { + private readonly TDict Dictionary; + + protected internal KeyCollection(TDict dictionary) => Dictionary = dictionary; + + [MustDisposeResource] + public IEnumerator GetEnumerator() => Dictionary.Indexes.GetEnumerator(); + + [MustDisposeResource] + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void Add(TKey item) => throw new InvalidOperationException("readonly"); + + public void Clear() => throw new InvalidOperationException("readonly"); + + public bool Contains(TKey item) => Dictionary.Indexes.Contains(item); + + public void CopyTo(TKey[] array, int arrayIndex) { +#pragma warning disable CA1062 + if ((arrayIndex < 0) || (arrayIndex > array.Length)) { +#pragma warning restore CA1062 + throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + } + + if (array.Length - arrayIndex < Dictionary.Count) { + throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + } + + Dictionary.Indexes.CopyTo(array, arrayIndex); + } + + public bool Remove(TKey item) => throw new InvalidOperationException("readonly"); + + public int Count => Dictionary.Indexes.Count; + + public bool IsReadOnly => true; + } + + protected class ValuesCollection : ICollection + where TDict : OrderedDictionary { + protected private readonly TDict Dictionary; + + protected internal ValuesCollection(TDict dictionary) => Dictionary = dictionary; + + public IEnumerator GetEnumerator() { + // ReSharper disable once LoopCanBeConvertedToQuery + foreach (KeyValuePair pair in Dictionary) { + yield return pair.Value; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void Add(TValue item) => throw new InvalidOperationException("readonly"); + + public void Clear() => throw new InvalidOperationException("readonly"); + + public bool Contains(TValue item) => Dictionary.Dictionary.Values.Contains(item); + + public void CopyTo(TValue[] array, int arrayIndex) { +#pragma warning disable CA1062 + if ((arrayIndex < 0) || (arrayIndex > array.Length)) { +#pragma warning restore CA1062 + throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + } + + if (array.Length - arrayIndex < Dictionary.Count) { + throw new ArgumentOutOfRangeException(nameof(arrayIndex), arrayIndex, "Out of bounds"); + } + + int c = 0; + + foreach (TKey index in Dictionary.Indexes) { + bool res = Dictionary.TryGetValue(index, out TValue value); + Debug.Assert(res); + array[c + arrayIndex] = value; + c++; + } + } + + public bool Remove(TValue item) => throw new InvalidOperationException("readonly"); + + public int Count => Dictionary.Count; + + public bool IsReadOnly => true; + } + } + + public class + OrderedDictionary : OrderedDictionary, Dictionary> where TKey : notnull { + public OrderedDictionary() { } + + public OrderedDictionary(int capacity) : base( + new Dictionary(capacity), + new List(capacity) + ) { } + + public OrderedDictionary(IEqualityComparer comparer) : base(new Dictionary(comparer)) { } + + public OrderedDictionary(int capacity, IEqualityComparer comparer) : base(new Dictionary(capacity, comparer), new List(capacity)) { } + } +} diff --git a/ASFFreeGames/PluginContext.cs b/ASFFreeGames/PluginContext.cs index f9e6631..150adfa 100644 --- a/ASFFreeGames/PluginContext.cs +++ b/ASFFreeGames/PluginContext.cs @@ -3,6 +3,7 @@ using System.Threading; using ArchiSteamFarm.Steam; using ASFFreeGames.Configurations; +using Maxisoft.ASF.Utils; namespace Maxisoft.ASF; diff --git a/ASFFreeGames/Reddit/EmptyStruct.cs b/ASFFreeGames/Reddit/EmptyStruct.cs new file mode 100644 index 0000000..d8fef8b --- /dev/null +++ b/ASFFreeGames/Reddit/EmptyStruct.cs @@ -0,0 +1,15 @@ +using System; + +namespace Maxisoft.ASF.Reddit; + +internal struct EmptyStruct : IEquatable { + public bool Equals(EmptyStruct other) => true; + + public override bool Equals(object? obj) => obj is EmptyStruct; + + public override int GetHashCode() => 0; + + public static bool operator ==(EmptyStruct left, EmptyStruct right) => true; + + public static bool operator !=(EmptyStruct left, EmptyStruct right) => false; +} diff --git a/ASFFreeGames/Reddit/GameEntryIdentifierEqualityComparer.cs b/ASFFreeGames/Reddit/GameEntryIdentifierEqualityComparer.cs index 8eda952..06ebe15 100644 --- a/ASFFreeGames/Reddit/GameEntryIdentifierEqualityComparer.cs +++ b/ASFFreeGames/Reddit/GameEntryIdentifierEqualityComparer.cs @@ -6,5 +6,5 @@ namespace Maxisoft.ASF.Reddit; internal readonly struct GameEntryIdentifierEqualityComparer : IEqualityComparer { public bool Equals(RedditGameEntry x, RedditGameEntry y) => string.Equals(x.Identifier, y.Identifier, StringComparison.OrdinalIgnoreCase); - public int GetHashCode(RedditGameEntry obj) => StringComparer.InvariantCultureIgnoreCase.GetHashCode(obj.Identifier); + public int GetHashCode(RedditGameEntry obj) => StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Identifier); } diff --git a/ASFFreeGames/Reddit/RedditGameEntry.cs b/ASFFreeGames/Reddit/RedditGameEntry.cs index 97c74b0..d649c92 100644 --- a/ASFFreeGames/Reddit/RedditGameEntry.cs +++ b/ASFFreeGames/Reddit/RedditGameEntry.cs @@ -1,13 +1,13 @@ namespace Maxisoft.ASF.Reddit; public readonly record struct RedditGameEntry(string Identifier, ERedditGameEntryKind Kind, long Date) { - public bool IsFreeToPlay => Kind.HasFlag(ERedditGameEntryKind.FreeToPlay); - /// - /// Indicates that the entry a DLC or a required game linked to a free DLC entry + /// Indicates that the entry a DLC or a required game linked to a free DLC entry /// public bool IsForDlc => Kind.HasFlag(ERedditGameEntryKind.Dlc); + public bool IsFreeToPlay => Kind.HasFlag(ERedditGameEntryKind.FreeToPlay); + public void Deconstruct(out string identifier, out long date, out bool freeToPlay, out bool dlc) { identifier = Identifier; date = Date; diff --git a/ASFFreeGames/Reddit/RedditHelper.cs b/ASFFreeGames/Reddit/RedditHelper.cs index 8875edd..c5f2128 100644 --- a/ASFFreeGames/Reddit/RedditHelper.cs +++ b/ASFFreeGames/Reddit/RedditHelper.cs @@ -1,154 +1,119 @@ using System; -using System.Buffers; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Net; using System.Net.Http; -using System.Text; -using System.Text.Json; // Not using System.Text.Json for JsonDocument -using System.Text.Json.Nodes; // Using System.Text.Json.Nodes for JsonNode +using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using ArchiSteamFarm.Core; -using ArchiSteamFarm.Helpers.Json; -using ArchiSteamFarm.Web; -using ArchiSteamFarm.Web.Responses; -using BloomFilter; using Maxisoft.ASF.HttpClientSimple; -using Maxisoft.Utils.Collections.Spans; +using Maxisoft.Utils.Collections.Dictionaries; namespace Maxisoft.ASF.Reddit; -internal sealed partial class RedditHelper { - private const int BloomFilterBufferSize = 8; - private const int PoolMaxGameEntry = 1024; - private const string User = "ASFinfo"; - private static readonly ArrayPool ArrayPool = ArrayPool.Create(PoolMaxGameEntry, 1); +internal static class RedditHelper { + private const int MaxGameEntry = 1024; + internal const string User = "ASFinfo"; /// - /// Gets a collection of Reddit game entries from a JSON object. + /// Gets a collection of Reddit game entries from a JSON object. /// /// A collection of Reddit game entries. - public static async ValueTask> GetGames(SimpleHttpClient httpClient, CancellationToken cancellationToken) { - RedditGameEntry[] result = Array.Empty(); - - JsonNode? jsonPayload = await GetPayload(httpClient, cancellationToken).ConfigureAwait(false); + public static async ValueTask> GetGames(SimpleHttpClient httpClient, uint retry = 5, CancellationToken cancellationToken = default) { + JsonNode? jsonPayload = await GetPayload(httpClient, cancellationToken, retry).ConfigureAwait(false); JsonNode? childrenElement = jsonPayload["data"]?["children"]; - return childrenElement is null ? result : LoadMessages(childrenElement); + return childrenElement is null ? [] : LoadMessages(childrenElement); } - internal static RedditGameEntry[] LoadMessages(JsonNode children) { - Span bloomFilterBuffer = stackalloc long[BloomFilterBufferSize]; - StringBloomFilterSpan bloomFilter = new(bloomFilterBuffer, 3); - RedditGameEntry[] buffer = ArrayPool.Rent(PoolMaxGameEntry / 2); + internal static IReadOnlyCollection LoadMessages(JsonNode children) { + OrderedDictionary games = new(new GameEntryIdentifierEqualityComparer()); - try { - SpanList list = new(buffer); + IReadOnlyCollection returnValue() { + while (games.Count is > 0 and > MaxGameEntry) { + games.RemoveAt(games.Count - 1); + } - // ReSharper disable once LoopCanBePartlyConvertedToQuery - foreach (JsonNode? comment in children.AsArray()) { - JsonNode? commentData = comment?["data"]; + return (IReadOnlyCollection) games.Keys; + } - if (commentData is null) { - continue; - } + // ReSharper disable once LoopCanBePartlyConvertedToQuery + foreach (JsonNode? comment in children.AsArray()) { + JsonNode? commentData = comment?["data"]; - long date; - string text; + if (commentData is null) { + continue; + } - try { - text = commentData["body"]?.GetValue() ?? string.Empty; + long date; + string text; - try { - date = checked((long) (commentData["created_utc"]?.GetValue() ?? 0)); - } - catch (Exception e) when (e is FormatException or InvalidOperationException) { - date = 0; - } + try { + text = commentData["body"]?.GetValue() ?? string.Empty; - if (!double.IsNormal(date) || (date <= 0)) { - date = checked((long) (commentData["created"]?.GetValue() ?? 0)); - } + try { + date = checked((long) (commentData["created_utc"]?.GetValue() ?? 0)); } catch (Exception e) when (e is FormatException or InvalidOperationException) { - continue; + date = 0; } if (!double.IsNormal(date) || (date <= 0)) { - continue; + date = checked((long) (commentData["created"]?.GetValue() ?? 0)); } + } + catch (Exception e) when (e is FormatException or InvalidOperationException) { + continue; + } + + if (!double.IsNormal(date) || (date <= 0)) { + continue; + } - MatchCollection matches = CommandRegex().Matches(text); + MatchCollection matches = RedditHelperRegexes.Command().Matches(text); - foreach (Match match in matches) { - ERedditGameEntryKind kind = ERedditGameEntryKind.None; + foreach (Match match in matches) { + ERedditGameEntryKind kind = ERedditGameEntryKind.None; - if (IsPermanentlyFreeRegex().IsMatch(text)) { - kind |= ERedditGameEntryKind.FreeToPlay; - } + if (RedditHelperRegexes.IsPermanentlyFree().IsMatch(text)) { + kind |= ERedditGameEntryKind.FreeToPlay; + } + + if (RedditHelperRegexes.IsDlc().IsMatch(text)) { + kind = ERedditGameEntryKind.Dlc; + } - if (IsDlcRegex().IsMatch(text)) { - kind = ERedditGameEntryKind.Dlc; + foreach (Group matchGroup in match.Groups) { + if (!matchGroup.Name.StartsWith("appid", StringComparison.InvariantCulture)) { + continue; } - foreach (Group matchGroup in match.Groups) { - if (!matchGroup.Name.StartsWith("appid", StringComparison.InvariantCulture)) { - continue; + foreach (Capture capture in matchGroup.Captures) { + RedditGameEntry gameEntry = new(capture.Value, kind, date); + + try { + games.Add(gameEntry, default(EmptyStruct)); } + catch (ArgumentException) { } - foreach (Capture capture in matchGroup.Captures) { - RedditGameEntry gameEntry = new(capture.Value, kind, date); - int index = -1; - - if (bloomFilter.Contains(gameEntry.Identifier)) { - index = list.IndexOf(gameEntry, new GameEntryIdentifierEqualityComparer()); - } - - if (index >= 0) { - ref RedditGameEntry oldEntry = ref list[index]; - - if (gameEntry.Date > oldEntry.Date) { - oldEntry = gameEntry; - } - } - else { - list.Add(in gameEntry); - bloomFilter.Add(gameEntry.Identifier); - } - - while (list.Count >= list.Capacity) { - list.RemoveAt(list.Count - 1); // Remove the last element instead of using a magic number - } + if (games.Count >= MaxGameEntry) { + return returnValue(); } } } } - - return list.ToArray(); - } - finally { - ArrayPool.Return(buffer); } - } - - [GeneratedRegex(@"(.addlicense)\s+(asf)?\s*((?(s/|a/)\d+)\s*,?\s*)+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] - private static partial Regex CommandRegex(); - - private static Uri GetUrl() => new($"https://www.reddit.com/user/{User}.json?sort=new", UriKind.Absolute); - [GeneratedRegex(@"free\s+DLC\s+for\s+a", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] - private static partial Regex IsDlcRegex(); - - [GeneratedRegex(@"permanently\s+free", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] - private static partial Regex IsPermanentlyFreeRegex(); + return returnValue(); + } /// - /// Tries to get a JSON object from Reddit. + /// Tries to get a JSON object from Reddit. /// /// The http client instance to use. /// @@ -168,7 +133,7 @@ private static async ValueTask GetPayload(SimpleHttpClient httpClient, { "Sec-Fetch-Dest", "empty" }, { "x-sec-fetch-dest", "empty" }, { "x-sec-fetch-mode", "no-cors" }, - { "x-sec-fetch-site", "none" }, + { "x-sec-fetch-site", "none" } }; for (int t = 0; t < retry; t++) { @@ -226,11 +191,13 @@ private static async ValueTask GetPayload(SimpleHttpClient httpClient, return JsonNode.Parse("{}")!; } + private static Uri GetUrl() => new($"https://www.reddit.com/user/{User}.json?sort=new", UriKind.Absolute); + /// - /// Handles too many requests by checking the status code and headers of the response. - /// If the status code is Forbidden or TooManyRequests, it checks the remaining rate limit - /// and the reset time. If the remaining rate limit is less than or equal to 0, it delays - /// the execution until the reset time using the cancellation token. + /// Handles too many requests by checking the status code and headers of the response. + /// If the status code is Forbidden or TooManyRequests, it checks the remaining rate limit + /// and the reset time. If the remaining rate limit is less than or equal to 0, it delays + /// the execution until the reset time using the cancellation token. /// /// The HTTP stream response to handle. /// @@ -265,12 +232,12 @@ private static async ValueTask HandleTooManyRequest(HttpStreamResponse res } /// - /// Parses a JSON object from a stream response. Using not straightforward for ASF trimmed compatibility reasons + /// Parses a JSON object from a stream response. Using not straightforward for ASF trimmed compatibility reasons /// /// The stream response containing the JSON data. /// The cancellation token. /// The parsed JSON object, or null if parsing fails. - private static async Task ParseJsonNode(HttpStreamResponse stream, CancellationToken cancellationToken) { + internal static async Task ParseJsonNode(HttpStreamResponse stream, CancellationToken cancellationToken) { string data = await stream.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return JsonNode.Parse(data); diff --git a/ASFFreeGames/Reddit/RedditHelperRegexes.cs b/ASFFreeGames/Reddit/RedditHelperRegexes.cs new file mode 100644 index 0000000..a63091d --- /dev/null +++ b/ASFFreeGames/Reddit/RedditHelperRegexes.cs @@ -0,0 +1,14 @@ +using System.Text.RegularExpressions; + +namespace Maxisoft.ASF.Reddit; + +internal static partial class RedditHelperRegexes { + [GeneratedRegex(@"(.addlicense)\s+(asf)?\s*((?(s/|a/)\d+)\s*,?\s*)+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + internal static partial Regex Command(); + + [GeneratedRegex(@"free\s+DLC\s+for\s+a", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + internal static partial Regex IsDlc(); + + [GeneratedRegex(@"permanently\s+free", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + internal static partial Regex IsPermanentlyFree(); +} diff --git a/ASFFreeGames/Redlib/EGameType.cs b/ASFFreeGames/Redlib/EGameType.cs new file mode 100644 index 0000000..ff7cc43 --- /dev/null +++ b/ASFFreeGames/Redlib/EGameType.cs @@ -0,0 +1,28 @@ +using System; +using Maxisoft.ASF.Reddit; + +namespace Maxisoft.ASF.Redlib; + +[Flags] +public enum EGameType : sbyte { + None = 0, + FreeToPlay = 1 << 0, + PermenentlyFree = 1 << 1, + Dlc = 1 << 2 +} + +public static class GameTypeExtensions { + public static ERedditGameEntryKind ToRedditGameEntryKind(this EGameType type) { + ERedditGameEntryKind res = ERedditGameEntryKind.None; + + if (type.HasFlag(EGameType.FreeToPlay)) { + res |= ERedditGameEntryKind.FreeToPlay; + } + + if (type.HasFlag(EGameType.Dlc)) { + res |= ERedditGameEntryKind.Dlc; + } + + return res; + } +} diff --git a/ASFFreeGames/Redlib/Exceptions/RedlibDisabledException.cs b/ASFFreeGames/Redlib/Exceptions/RedlibDisabledException.cs new file mode 100644 index 0000000..455b279 --- /dev/null +++ b/ASFFreeGames/Redlib/Exceptions/RedlibDisabledException.cs @@ -0,0 +1,11 @@ +using System; + +namespace Maxisoft.ASF.Redlib; + +public class RedlibDisabledException : RedlibException { + public RedlibDisabledException(string message) : base(message) { } + + public RedlibDisabledException() { } + + public RedlibDisabledException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/ASFFreeGames/Redlib/Exceptions/RedlibException.cs b/ASFFreeGames/Redlib/Exceptions/RedlibException.cs new file mode 100644 index 0000000..ebfda3d --- /dev/null +++ b/ASFFreeGames/Redlib/Exceptions/RedlibException.cs @@ -0,0 +1,11 @@ +using System; + +namespace Maxisoft.ASF.Redlib; + +public abstract class RedlibException : Exception { + protected RedlibException(string message) : base(message) { } + + protected RedlibException() { } + + protected RedlibException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/ASFFreeGames/Redlib/Exceptions/RedlibOutDatedListException.cs b/ASFFreeGames/Redlib/Exceptions/RedlibOutDatedListException.cs new file mode 100644 index 0000000..6501a19 --- /dev/null +++ b/ASFFreeGames/Redlib/Exceptions/RedlibOutDatedListException.cs @@ -0,0 +1,11 @@ +using System; + +namespace Maxisoft.ASF.Redlib; + +public class RedlibOutDatedListException : RedlibException { + public RedlibOutDatedListException(string message) : base(message) { } + + public RedlibOutDatedListException() { } + + public RedlibOutDatedListException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/ASFFreeGames/Redlib/GameIdentifiersEqualityComparer.cs b/ASFFreeGames/Redlib/GameIdentifiersEqualityComparer.cs new file mode 100644 index 0000000..2d989ba --- /dev/null +++ b/ASFFreeGames/Redlib/GameIdentifiersEqualityComparer.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using ASFFreeGames.ASFExtentions.Games; + +namespace Maxisoft.ASF.Redlib; +#pragma warning disable CA1819 + +public sealed class GameIdentifiersEqualityComparer : IEqualityComparer { + public bool Equals(RedlibGameEntry x, RedlibGameEntry y) { + if (x.GameIdentifiers.Count != y.GameIdentifiers.Count) { + return false; + } + + using IEnumerator xIt = x.GameIdentifiers.GetEnumerator(); + using IEnumerator yIt = y.GameIdentifiers.GetEnumerator(); + + while (xIt.MoveNext() && yIt.MoveNext()) { + if (!xIt.Current.Equals(yIt.Current)) { + return false; + } + } + + return true; + } + + public int GetHashCode(RedlibGameEntry obj) { + HashCode h = new(); + + foreach (GameIdentifier id in obj.GameIdentifiers) { + h.Add(id); + } + + return h.ToHashCode(); + } +} + +#pragma warning restore CA1819 diff --git a/ASFFreeGames/Redlib/Html/ParserIndices.cs b/ASFFreeGames/Redlib/Html/ParserIndices.cs new file mode 100644 index 0000000..0c13b63 --- /dev/null +++ b/ASFFreeGames/Redlib/Html/ParserIndices.cs @@ -0,0 +1,3 @@ +namespace Maxisoft.ASF.Redlib.Html; + +internal readonly record struct ParserIndices(int StartOfCommandIndex, int EndOfCommandIndex, int StartOfFooterIndex, int HrefStartIndex, int HrefEndIndex); diff --git a/ASFFreeGames/Redlib/Html/RedditHtmlParser.cs b/ASFFreeGames/Redlib/Html/RedditHtmlParser.cs new file mode 100644 index 0000000..c628e05 --- /dev/null +++ b/ASFFreeGames/Redlib/Html/RedditHtmlParser.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using ASFFreeGames.ASFExtentions.Games; +using Maxisoft.ASF.Reddit; +using Maxisoft.Utils.Collections.Dictionaries; + +namespace Maxisoft.ASF.Redlib.Html; + +public static class RedlibHtmlParser { + private const int MaxIdentifierPerEntry = 32; + + public static IReadOnlyCollection ParseGamesFromHtml(ReadOnlySpan html, bool dedup = true) { + OrderedDictionary entries = new(dedup ? new GameIdentifiersEqualityComparer() : EqualityComparer.Default); + int startIndex = 0; + + Span gameIdentifiers = stackalloc GameIdentifier[MaxIdentifierPerEntry]; + + while ((0 <= startIndex) && (startIndex < html.Length)) { + ParserIndices indices; + + try { + indices = ParseIndices(html, startIndex); + + (int startOfCommandIndex, int endOfCommandIndex, int _, _, _) = indices; + + ReadOnlySpan command = html[startOfCommandIndex..endOfCommandIndex].Trim(); + + if (!RedlibHtmlParserRegex.CommandRegex().IsMatch(command)) { + throw new SkipAndContinueParsingException("Invalid asf command") { StartIndex = startOfCommandIndex + 1 }; + } + + Span effectiveGameIdentifiers = SplitCommandAndGetGameIdentifiers(command, gameIdentifiers); + + if (effectiveGameIdentifiers.IsEmpty) { + throw new SkipAndContinueParsingException("No game identifiers found") { StartIndex = startOfCommandIndex + 1 }; + } + + EGameType flag = ParseGameTypeFlags(html[indices.StartOfCommandIndex..indices.StartOfFooterIndex]); + + ReadOnlySpan title = ExtractTitle(html, indices); + RedlibGameEntry entry = new(effectiveGameIdentifiers.ToArray(), title.ToString(), flag); + + try { + entries.Add(entry, default(EmptyStruct)); + } + catch (ArgumentException e) { + throw new SkipAndContinueParsingException("entry already found", e) { StartIndex = startOfCommandIndex + 1 }; + } + } + catch (SkipAndContinueParsingException e) { + startIndex = e.StartIndex; + + continue; + } + + startIndex = indices.StartOfFooterIndex + 1; + } + + return (IReadOnlyCollection) entries.Keys; + } + + internal static ReadOnlySpan ExtractTitle(ReadOnlySpan html, ParserIndices indices) { + Span ranges = stackalloc Range[MaxIdentifierPerEntry]; + ReadOnlySpan hrefSpan = html[indices.HrefStartIndex..indices.HrefEndIndex]; + int splitCount = hrefSpan.Split(ranges, '/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (splitCount > 2) { + Range range = ranges[..splitCount][^3]; + + return hrefSpan[range].Trim(); + } + + return ReadOnlySpan.Empty; + } + + internal static EGameType ParseGameTypeFlags(ReadOnlySpan content) { + EGameType flag = EGameType.None; + + if (RedlibHtmlParserRegex.IsDlcRegex().IsMatch(content)) { + flag |= EGameType.Dlc; + } + + if (RedlibHtmlParserRegex.IsPermanentlyFreeRegex().IsMatch(content)) { + flag |= EGameType.PermenentlyFree; + } + + if (RedlibHtmlParserRegex.IsFreeToPlayRegex().IsMatch(content)) { + flag |= EGameType.FreeToPlay; + } + + return flag; + } + + internal static ParserIndices ParseIndices(ReadOnlySpan html, int start) { + // Find the index of the next !addlicense asf command + int startIndex = html[start..].IndexOf("!addlicense asf ", StringComparison.OrdinalIgnoreCase); + + if (startIndex < 0) { + startIndex = html[start..].IndexOf("
!addlicense asf ", StringComparison.OrdinalIgnoreCase);
+
+			if (startIndex < 0) {
+				throw new SkipAndContinueParsingException("No !addlicense asf command found") { StartIndex = -1 };
+			}
+		}
+
+		startIndex += start;
+
+		int commentLinkIndex = html[start..startIndex].LastIndexOf("');
+
+		if (hrefEndIndex < 0) {
+			throw new SkipAndContinueParsingException("No comment href end found") { StartIndex = startIndex + 1 };
+		}
+
+		hrefEndIndex += hrefStartIndex;
+
+		if (!RedlibHtmlParserRegex.HrefCommentLinkRegex().IsMatch(html[hrefStartIndex..(hrefEndIndex + 1)])) {
+			throw new SkipAndContinueParsingException("Invalid comment link") { StartIndex = startIndex + 1 };
+		}
+
+		// Find the ASF info bot footer
+		int footerStartIndex = html[startIndex..].IndexOf("bot", StringComparison.InvariantCultureIgnoreCase);
+
+		if (footerStartIndex < 0) {
+			throw new SkipAndContinueParsingException("No bot in footer found") { StartIndex = startIndex + 1 };
+		}
+
+		footerStartIndex += startIndex;
+
+		int infoFooterStartIndex = html[footerStartIndex..].IndexOf("Info", StringComparison.InvariantCultureIgnoreCase);
+
+		if (infoFooterStartIndex < 0) {
+			throw new SkipAndContinueParsingException("No Info in footer found") { StartIndex = startIndex + 1 };
+		}
+
+		infoFooterStartIndex += footerStartIndex;
+
+		// now we have a kind of typical ASFInfo post
+
+		// Extract the comment link
+		int commandEndIndex = html[startIndex..infoFooterStartIndex].IndexOf("", StringComparison.InvariantCultureIgnoreCase);
+
+		if (commandEndIndex < 0) {
+			commandEndIndex = html[startIndex..infoFooterStartIndex].IndexOf("
", StringComparison.InvariantCultureIgnoreCase); + + if (commandEndIndex < 0) { + throw new SkipAndContinueParsingException("No command end found") { StartIndex = startIndex + 1 }; + } + } + + commandEndIndex += startIndex; + + startIndex = html[startIndex..commandEndIndex].IndexOf("!addlicense", StringComparison.OrdinalIgnoreCase) + startIndex; + + return new ParserIndices(startIndex, commandEndIndex, infoFooterStartIndex, hrefStartIndex, hrefEndIndex); + } + + internal static Span SplitCommandAndGetGameIdentifiers(ReadOnlySpan command, Span gameIdentifiers) { + Span ranges = stackalloc Range[MaxIdentifierPerEntry]; + int splits = command.Split(ranges, ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (splits <= 0) { + return Span.Empty; + } + + // fix the first range because it contains the command + ref Range firstRange = ref ranges[0]; + int startFirstRange = command[firstRange].LastIndexOf(' '); + firstRange = new Range(firstRange.Start.GetOffset(command.Length) + startFirstRange + 1, firstRange.End); + + int gameIdentifiersCount = 0; + + foreach (Range range in ranges[..splits]) { + ReadOnlySpan sub = command[range].Trim(); + + if (sub.IsEmpty) { + continue; + } + + if (!GameIdentifier.TryParse(sub, out GameIdentifier gameIdentifier)) { + continue; + } + + gameIdentifiers[gameIdentifiersCount++] = gameIdentifier; + } + + return gameIdentifiers[..gameIdentifiersCount]; + } +} diff --git a/ASFFreeGames/Redlib/Html/RedlibHtmlParserRegex.cs b/ASFFreeGames/Redlib/Html/RedlibHtmlParserRegex.cs new file mode 100644 index 0000000..54912e2 --- /dev/null +++ b/ASFFreeGames/Redlib/Html/RedlibHtmlParserRegex.cs @@ -0,0 +1,24 @@ +using System.Text.RegularExpressions; + +namespace Maxisoft.ASF.Redlib.Html; + +#pragma warning disable CA1052 + +public partial class RedlibHtmlParserRegex { + [GeneratedRegex(@"(.addlicense)\s+(asf)?\s*((?(s/|a/)\d+)\s*,?\s*)+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + internal static partial Regex CommandRegex(); + + [GeneratedRegex(@"href\s*=\s*.\s*/r/[\P{Cc}\P{Cn}\P{Cs}]+?comments[\P{Cc}\P{Cn}\P{Cs}/]+?.\s*/?\s*>.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + internal static partial Regex HrefCommentLinkRegex(); + + [GeneratedRegex(@".*free\s+DLC\s+for\s+a.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + internal static partial Regex IsDlcRegex(); + + [GeneratedRegex(@".*free\s+to\s+play.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + internal static partial Regex IsFreeToPlayRegex(); + + [GeneratedRegex(@".*permanently\s+free.*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + internal static partial Regex IsPermanentlyFreeRegex(); +} + +#pragma warning restore CA1052 diff --git a/ASFFreeGames/Redlib/Html/SkipAndContinueParsingException.cs b/ASFFreeGames/Redlib/Html/SkipAndContinueParsingException.cs new file mode 100644 index 0000000..f6d0b9d --- /dev/null +++ b/ASFFreeGames/Redlib/Html/SkipAndContinueParsingException.cs @@ -0,0 +1,13 @@ +using System; + +namespace Maxisoft.ASF.Redlib.Html; + +public class SkipAndContinueParsingException : Exception { + public int StartIndex { get; init; } + + public SkipAndContinueParsingException(string message, Exception innerException) : base(message, innerException) { } + + public SkipAndContinueParsingException() { } + + public SkipAndContinueParsingException(string message) : base(message) { } +} diff --git a/ASFFreeGames/Redlib/Instances/CachedRedlibInstanceList.cs b/ASFFreeGames/Redlib/Instances/CachedRedlibInstanceList.cs new file mode 100644 index 0000000..16f8679 --- /dev/null +++ b/ASFFreeGames/Redlib/Instances/CachedRedlibInstanceList.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ASFFreeGames.Configurations; +using Maxisoft.ASF.HttpClientSimple; + +namespace Maxisoft.ASF.Redlib.Instances; + +public class CachedRedlibInstanceList(ASFFreeGamesOptions options, CachedRedlibInstanceListStorage storage) : IRedlibInstanceList { + private readonly RedlibInstanceList InstanceList = new(options); + + public async Task> ListInstances([NotNull] SimpleHttpClient httpClient, CancellationToken cancellationToken) { + if (((DateTimeOffset.Now - storage.LastUpdate).Duration() > TimeSpan.FromHours(1)) || (storage.Instances.Count == 0)) { + List res = await InstanceList.ListInstances(httpClient, cancellationToken).ConfigureAwait(false); + + if (res.Count > 0) { + storage.UpdateInstances(res); + } + } + + return storage.Instances.ToList(); + } +} diff --git a/ASFFreeGames/Redlib/Instances/CachedRedlibInstanceListStorage.cs b/ASFFreeGames/Redlib/Instances/CachedRedlibInstanceListStorage.cs new file mode 100644 index 0000000..259eb36 --- /dev/null +++ b/ASFFreeGames/Redlib/Instances/CachedRedlibInstanceListStorage.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace Maxisoft.ASF.Redlib.Instances; + +public record CachedRedlibInstanceListStorage(ICollection Instances, DateTimeOffset LastUpdate) { + public ICollection Instances { get; private set; } = Instances; + public DateTimeOffset LastUpdate { get; private set; } = LastUpdate; + + /// + /// Updates the list of instances and its last update time + /// + /// The list of instances to update + internal void UpdateInstances(ICollection instances) { + Instances = instances; + LastUpdate = DateTimeOffset.Now; + } +} diff --git a/ASFFreeGames/Redlib/Instances/IRedlibInstanceList.cs b/ASFFreeGames/Redlib/Instances/IRedlibInstanceList.cs new file mode 100644 index 0000000..cbc8d94 --- /dev/null +++ b/ASFFreeGames/Redlib/Instances/IRedlibInstanceList.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Maxisoft.ASF.HttpClientSimple; + +namespace Maxisoft.ASF.Redlib.Instances; + +[SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] +public interface IRedlibInstanceList { + Task> ListInstances([NotNull] SimpleHttpClient httpClient, CancellationToken cancellationToken); +} diff --git a/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs b/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs new file mode 100644 index 0000000..fe89a4e --- /dev/null +++ b/ASFFreeGames/Redlib/Instances/RedlibInstanceList.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using ArchiSteamFarm.Core; +using ASFFreeGames.Configurations; +using Maxisoft.ASF.HttpClientSimple; +using Maxisoft.ASF.Reddit; + +// ReSharper disable once CheckNamespace +namespace Maxisoft.ASF.Redlib.Instances; + +[SuppressMessage("ReSharper", "RedundantNullableFlowAttribute")] +public class RedlibInstanceList(ASFFreeGamesOptions options) : IRedlibInstanceList { + private const string EmbeddedFileName = "redlib_instances.json"; + + private static readonly HashSet DisabledKeywords = new(StringComparer.OrdinalIgnoreCase) { + "disabled", + "off", + "no", + "false" + }; + + public async Task> ListInstances([NotNull] SimpleHttpClient httpClient, CancellationToken cancellationToken) { + if (IsDisabled(options.RedlibInstanceUrl)) { + throw new RedlibDisabledException(); + } + + if (!Uri.TryCreate(options.RedlibInstanceUrl, UriKind.Absolute, out Uri? uri)) { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError("[FreeGames] Invalid redlib instances url: " + options.RedlibInstanceUrl); + + return await ListFromEmbedded(cancellationToken).ConfigureAwait(false); + } +#pragma warning disable CAC001 +#pragma warning disable CA2007 + await using HttpStreamResponse response = await httpClient.GetStreamAsync(uri!, cancellationToken: cancellationToken).ConfigureAwait(false); +#pragma warning restore CA2007 +#pragma warning restore CAC001 + if (!response.StatusCode.IsSuccessCode()) { + return await ListFromEmbedded(cancellationToken).ConfigureAwait(false); + } + + JsonNode? node = await ParseJsonNode(response, cancellationToken).ConfigureAwait(false); + + if (node is null) { + return await ListFromEmbedded(cancellationToken).ConfigureAwait(false); + } + + CheckUpToDate(node); + + List res = ParseUrls(node); + + return res.Count > 0 ? res : await ListFromEmbedded(cancellationToken).ConfigureAwait(false); + } + + internal static void CheckUpToDate(JsonNode node) { + int currentYear = DateTime.Now.Year; + string updated = node["updated"]?.GetValue() ?? ""; + + if (!updated.StartsWith(currentYear.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal) && + !updated.StartsWith((currentYear - 1).ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)) { + throw new RedlibOutDatedListException(); + } + } + + internal static async Task> ListFromEmbedded(CancellationToken cancellationToken) { + JsonNode? node = await LoadEmbeddedInstance(cancellationToken).ConfigureAwait(false); + + if (node is null) { +#pragma warning disable CA2201 + throw new NullReferenceException($"unable to find embedded file {EmbeddedFileName}"); +#pragma warning restore CA2201 + } + + CheckUpToDate(node); + + return ParseUrls(node); + } + + internal static List ParseUrls(JsonNode json) { + JsonNode? instances = json["instances"]; + + if (instances is null) { + return []; + } + + List uris = new(instances.AsArray().Count); + + // ReSharper disable once LoopCanBePartlyConvertedToQuery + foreach (JsonNode? instance in instances.AsArray()) { + JsonNode? url = instance?["url"]; + + if (Uri.TryCreate(url?.GetValue() ?? "", UriKind.Absolute, out Uri? instanceUri) && instanceUri.Scheme is "http" or "https") { + uris.Add(instanceUri); + } + } + + return uris; + } + + private static bool IsDisabled(string? instanceUrl) => instanceUrl is not null && DisabledKeywords.Contains(instanceUrl.Trim()); + + private static async Task LoadEmbeddedInstance(CancellationToken cancellationToken) { + Assembly assembly = Assembly.GetExecutingAssembly(); + +#pragma warning disable CAC001 +#pragma warning disable CA2007 + await using Stream stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.Resouces.{EmbeddedFileName}")!; +#pragma warning restore CA2007 +#pragma warning restore CAC001 + using StreamReader reader = new(stream); // assume the encoding is UTF8, cannot be specified as per issue #91 + string data = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + + return JsonNode.Parse(data); + } + + private static Task ParseJsonNode(HttpStreamResponse stream, CancellationToken cancellationToken) => RedditHelper.ParseJsonNode(stream, cancellationToken); +} diff --git a/ASFFreeGames/Redlib/RedlibGameEntry.cs b/ASFFreeGames/Redlib/RedlibGameEntry.cs new file mode 100644 index 0000000..2ed3b38 --- /dev/null +++ b/ASFFreeGames/Redlib/RedlibGameEntry.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using ASFFreeGames.ASFExtentions.Games; +using Maxisoft.ASF.Reddit; + +namespace Maxisoft.ASF.Redlib; + +#pragma warning disable CA1819 + +public readonly record struct RedlibGameEntry(IReadOnlyCollection GameIdentifiers, string CommentLink, EGameType TypeFlags) { + public RedditGameEntry ToRedditGameEntry(long date = default) => new(string.Join(',', GameIdentifiers), TypeFlags.ToRedditGameEntryKind(), date); +} + +#pragma warning restore CA1819 diff --git a/ASFFreeGames/Resouces/redlib_instances.json b/ASFFreeGames/Resouces/redlib_instances.json new file mode 100644 index 0000000..1ad361a --- /dev/null +++ b/ASFFreeGames/Resouces/redlib_instances.json @@ -0,0 +1,155 @@ +{ + "updated": "2024-07-15", + "instances": [ + { + "url": "https://l.opnxng.com", + "country": "SG", + "version": "v0.31.0" + }, + { + "url": "https://libreddit.projectsegfau.lt", + "country": "LU", + "version": "v0.35.1" + }, + { + "url": "https://libreddit.bus-hit.me", + "country": "CA", + "version": "v0.35.1" + }, + { + "url": "https://redlib.catsarch.com", + "country": "US", + "version": "v0.35.1" + }, + { + "url": "https://redlib.freedit.eu", + "country": "US", + "version": "v0.35.1" + }, + { + "url": "https://redlib.tux.pizza", + "country": "US", + "version": "v0.35.1" + }, + { + "url": "https://redlib.vimmer.dev", + "country": "PL", + "version": "v0.35.1" + }, + { + "url": "https://libreddit.privacydev.net", + "country": "FR", + "version": "v0.35.1" + }, + { + "url": "https://lr.n8pjl.ca", + "country": "CA", + "version": "v0.35.1" + }, + { + "url": "https://rl.bloat.cat", + "country": "RO", + "version": "v0.35.1" + }, + { + "url": "https://redlib.nohost.network", + "country": "MX", + "version": "v0.35.1" + }, + { + "url": "https://redlib.ducks.party", + "country": "NL", + "version": "v0.35.1" + }, + { + "url": "https://red.ngn.tf", + "country": "TR", + "version": "v0.35.1" + }, + { + "url": "https://red.artemislena.eu", + "country": "DE", + "version": "v0.35.1", + "description": "Be crime do gay" + }, + { + "url": "https://r.darrennathanael.com", + "country": "ID", + "version": "v0.35.1", + "description": "contact noc at darrennathanael.com" + }, + { + "url": "https://redlib.privacyredirect.com", + "country": "FI", + "version": "v0.35.1" + }, + { + "url": "https://redlib.seasi.dev", + "country": "SG", + "version": "v0.35.1" + }, + { + "url": "https://redlib.incogniweb.net", + "country": "US", + "version": "v0.35.1" + }, + { + "url": "https://reddit.nerdvpn.de", + "country": "UA", + "version": "v0.35.1", + "description": "SFW only" + }, + { + "url": "https://lr.ggtyler.dev", + "country": "US", + "version": "v0.35.1" + }, + { + "url": "https://redlib.baczek.me", + "country": "PL", + "version": "v0.31.0" + }, + { + "url": "https://redlib.nadeko.net", + "country": "CL", + "version": "v0.34.0", + "description": "I don't like reddit." + }, + { + "url": "https://redlib.nirn.quest", + "country": "US", + "version": "v0.35.1" + }, + { + "url": "https://redlib.private.coffee", + "country": "AT", + "version": "v0.34.0" + }, + { + "url": "https://redlib.frontendfriendly.xyz", + "country": "XX", + "version": "v0.35.1" + }, + { + "url": "https://rl.rootdo.com", + "country": "DE", + "version": "v0.35.1" + }, + { + "url": "https://red.arancia.click", + "country": "US", + "version": "v0.35.1" + }, + { + "url": "https://redlib.reallyaweso.me", + "country": "DE", + "version": "v0.35.1", + "description": "A reallyaweso.me redlib instance!" + }, + { + "url": "https://redlib.privacy.com.de", + "country": "DE", + "version": "v0.35.1" + } + ] +} diff --git a/ASFFreeGames/LoggerFilter.cs b/ASFFreeGames/Utils/LoggerFilter.cs similarity index 98% rename from ASFFreeGames/LoggerFilter.cs rename to ASFFreeGames/Utils/LoggerFilter.cs index a9b53b3..ddd0c54 100644 --- a/ASFFreeGames/LoggerFilter.cs +++ b/ASFFreeGames/Utils/LoggerFilter.cs @@ -7,13 +7,15 @@ using System.Text.RegularExpressions; using ArchiSteamFarm.NLog; using ArchiSteamFarm.Steam; +using ASFFreeGames.ASFExtentions.Bot; +using Maxisoft.ASF.ASFExtentions; using NLog; using NLog.Config; using NLog.Filters; // ReSharper disable RedundantNullableFlowAttribute -namespace Maxisoft.ASF; +namespace Maxisoft.ASF.Utils; #nullable enable diff --git a/ASFFreeGames/RandomUtils.cs b/ASFFreeGames/Utils/RandomUtils.cs similarity index 98% rename from ASFFreeGames/RandomUtils.cs rename to ASFFreeGames/Utils/RandomUtils.cs index ac9f713..2fd3877 100644 --- a/ASFFreeGames/RandomUtils.cs +++ b/ASFFreeGames/Utils/RandomUtils.cs @@ -6,7 +6,7 @@ using System.Security.Cryptography; using System.Threading; -namespace Maxisoft.ASF; +namespace Maxisoft.ASF.Utils; #nullable enable