diff --git a/FreePackages.Tests/Filters.cs b/FreePackages.Tests/Filters.cs index 4ee2fd9..a669277 100644 --- a/FreePackages.Tests/Filters.cs +++ b/FreePackages.Tests/Filters.cs @@ -414,4 +414,21 @@ public void CanFilterByWishlist() { Assert.IsTrue(PackageFilter.IsAppWantedByFilter(app, Filter)); } + + [TestMethod] + public void CanFilterByReleaseDate() { + var app = new FilterableApp(KeyValue.LoadAsText("app_which_is_free.txt")); + + Filter.MinDaysOld = 0; + + Assert.IsTrue(PackageFilter.IsAppWantedByFilter(app, Filter)); + + Filter.MinDaysOld = 1; + + Assert.IsFalse(PackageFilter.IsAppWantedByFilter(app, Filter)); + + Filter.MinDaysOld = 20000; + + Assert.IsTrue(PackageFilter.IsAppWantedByFilter(app, Filter)); + } } diff --git a/FreePackages/Commands.cs b/FreePackages/Commands.cs index 3473aa0..2fa67d8 100644 --- a/FreePackages/Commands.cs +++ b/FreePackages/Commands.cs @@ -3,9 +3,11 @@ using System.ComponentModel; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using ArchiSteamFarm.Core; using ArchiSteamFarm.Steam; +using FreePackages.Localization; namespace FreePackages { internal static class Commands { @@ -69,7 +71,7 @@ internal static class Commands { } if (!PackageHandler.Handlers.Keys.Contains(bot.BotName)) { - return FormatBotResponse(bot, "Free Packages plugin not enabled"); + return FormatBotResponse(bot, Strings.PluginNotEnabled); } return FormatBotResponse(bot, PackageHandler.Handlers[bot.BotName].ClearQueue()); @@ -103,7 +105,7 @@ internal static class Commands { } if (!PackageHandler.Handlers.Keys.Contains(bot.BotName)) { - return FormatBotResponse(bot, "Free Packages plugin not enabled"); + return FormatBotResponse(bot, Strings.PluginNotEnabled); } return FormatBotResponse(bot, PackageHandler.Handlers[bot.BotName].GetStatus()); @@ -127,7 +129,7 @@ internal static class Commands { return responses.Count > 0 ? String.Join(Environment.NewLine, responses) : null; } - private static string? ResponseQueueLicense(Bot bot, EAccess access, string licenses, bool useFilter = false) { + private static string? ResponseQueueLicense(Bot bot, EAccess access, string licenses, bool useFilter = false, [CallerMemberName] string? previousMethodName = null) { if (access < EAccess.Master) { return null; } @@ -178,6 +180,10 @@ internal static class Commands { response.AppendLine(FormatBotResponse(bot, PackageHandler.Handlers[bot.BotName].AddPackage(packageType, gameID, useFilter))); } + if (previousMethodName == nameof(Response)) { + Utilities.InBackground(async() => await PackageHandler.HandleChanges().ConfigureAwait(false)); + } + return response.Length > 0 ? response.ToString() : null; } @@ -196,6 +202,8 @@ internal static class Commands { List responses = new(results.Where(result => !String.IsNullOrEmpty(result))); + Utilities.InBackground(async() => await PackageHandler.HandleChanges().ConfigureAwait(false)); + return responses.Count > 0 ? String.Join(Environment.NewLine, responses) : null; } diff --git a/FreePackages/Data/ASFInfo.cs b/FreePackages/Data/ASFInfo.cs index b571128..cd0a4f2 100644 --- a/FreePackages/Data/ASFInfo.cs +++ b/FreePackages/Data/ASFInfo.cs @@ -92,6 +92,7 @@ private static async Task DoUpdate() { PackageHandler.Handlers.Values.ToList().ForEach(x => x.BotCache.AddChanges(appIDs, packageIDs)); FreePackages.GlobalCache.UpdateASFInfoItemCount(itemCount); + Utilities.InBackground(async() => await PackageHandler.HandleChanges().ConfigureAwait(false)); } } } \ No newline at end of file diff --git a/FreePackages/Data/BotCache.cs b/FreePackages/Data/BotCache.cs index b0e9f58..dffe736 100644 --- a/FreePackages/Data/BotCache.cs +++ b/FreePackages/Data/BotCache.cs @@ -85,6 +85,7 @@ internal BotCache(string filePath) : this() { return null; } + botCache.Packages = new(botCache.Packages.GroupBy(package => package, new PackageComparer()).Select(group => group.First()), new PackageComparer()); botCache.FilePath = filePath; return botCache; @@ -121,13 +122,14 @@ internal bool RemovePackage(Package package) { } internal bool RemoveAppPackages(HashSet appIDsToRemove) { - Packages.Where(x => appIDsToRemove.Contains(x.ID)).ToList().ForEach(x => Packages.Remove(x)); + Packages.Where(x => x.Type == EPackageType.App && appIDsToRemove.Contains(x.ID)).ToList().ForEach(x => Packages.Remove(x)); Utilities.InBackground(Save); return true; } internal Package? GetNextPackage() { + // Return the package which should be activated first, prioritizing first packages which have a start and end date ulong now = DateUtils.DateTimeToUnixTime(DateTime.UtcNow); Package? package = Packages.FirstOrDefault(x => x.StartTime != null && now > x.StartTime); if (package != null) { diff --git a/FreePackages/Data/FilterConfig.cs b/FreePackages/Data/FilterConfig.cs index dd6febe..421bb9f 100644 --- a/FreePackages/Data/FilterConfig.cs +++ b/FreePackages/Data/FilterConfig.cs @@ -58,6 +58,9 @@ internal sealed class FilterConfig { [JsonInclude] internal bool WishlistOnly { get; set; } = false; + [JsonInclude] + internal uint MinDaysOld { get; set; } = 0; + [JsonConstructor] internal FilterConfig() { } } diff --git a/FreePackages/Data/FilterableApp.cs b/FreePackages/Data/FilterableApp.cs index 3f92703..31e8249 100644 --- a/FreePackages/Data/FilterableApp.cs +++ b/FreePackages/Data/FilterableApp.cs @@ -27,6 +27,7 @@ internal sealed class FilterableApp { internal uint PlayTestType; internal List? OSList; internal uint DeckCompatibility; + internal DateTime SteamReleaseDate; internal bool Hidden; internal FilterableApp(SteamApps.PICSProductInfoCallback.PICSProductInfo productInfo) : this(productInfo.ID, productInfo.KeyValues) {} @@ -55,6 +56,7 @@ internal FilterableApp(uint id, KeyValue kv) { PlayTestType = kv["extended"]["playtest_type"].AsUnsignedInteger(); OSList = kv["common"]["oslist"].AsString()?.ToUpper().Split(",").ToList(); DeckCompatibility = kv["common"]["steam_deck_compatibility"]["category"].AsUnsignedInteger(); + SteamReleaseDate = DateTimeOffset.FromUnixTimeSeconds(kv["common"]["steam_release_date"].AsUnsignedInteger()).UtcDateTime; Hidden = kv["common"] == KeyValue.Invalid; // Fix the category for games which do have trading cards, but which don't have the trading card category, Ex: https://steamdb.info/app/316260/ diff --git a/FreePackages/Data/FilterablePackage.cs b/FreePackages/Data/FilterablePackage.cs index 5227b46..53b1572 100644 --- a/FreePackages/Data/FilterablePackage.cs +++ b/FreePackages/Data/FilterablePackage.cs @@ -5,7 +5,7 @@ namespace FreePackages { internal sealed class FilterablePackage { - internal bool IsNew; + internal bool IsNew; // This is used when finding DLC for new games added to account, and is not related to any Steam package property internal List PackageContents = new(); internal HashSet PackageContentIDs; internal HashSet PackageContentParentIDs = new(); @@ -18,6 +18,7 @@ internal sealed class FilterablePackage { internal ulong ExpiryTime; internal ulong StartTime; internal uint DontGrantIfAppIDOwned; + internal uint MustOwnAppToPurchase; internal List? RestrictedCountries; internal bool OnlyAllowRestrictedCountries; internal List? PurchaseRestrictedCountries; @@ -38,6 +39,7 @@ internal FilterablePackage(uint id, KeyValue kv, bool isNew) { ExpiryTime = kv["extended"]["expirytime"].AsUnsignedLong(); StartTime = kv["extended"]["starttime"].AsUnsignedLong(); DontGrantIfAppIDOwned = kv["extended"]["dontgrantifappidowned"].AsUnsignedInteger(); + MustOwnAppToPurchase = kv["extended"]["mustownapptopurchase"].AsUnsignedInteger(); RestrictedCountries = kv["extended"]["restrictedcountries"].AsString()?.ToUpper().Split(" ").ToList(); OnlyAllowRestrictedCountries = kv["extended"]["onlyallowrestrictedcountries"].AsBoolean(); PurchaseRestrictedCountries = kv["extended"]["purchaserestrictedcountries"].AsString()?.ToUpper().Split(" ").ToList(); @@ -111,6 +113,12 @@ internal bool IsAvailable() { return false; } + if (ID == 17906) { + // Special case: Anonymous Dedicated Server Comp (https://steamdb.info/sub/17906/) + // This always returns AccessDenied/InvalidPackage + return false; + } + return true; } diff --git a/FreePackages/Data/SharedExternalResource.cs b/FreePackages/Data/SharedExternalResource.cs new file mode 100644 index 0000000..f0f9057 --- /dev/null +++ b/FreePackages/Data/SharedExternalResource.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +// This resource may be used zero or more times independently and, when used, needs to be fetched from an external source. +// If it's used zero times we don't fetch it at all. +// If it's used once or more then we only fetch it once. + +namespace FreePackages { + internal sealed class SharedExternalResource { + private SemaphoreSlim FetchSemaphore = new SemaphoreSlim(1, 1); + private T? Resource; + private bool Fetched = false; + + internal SharedExternalResource() {} + + internal async Task Fetch(Func> fetchResource) { + if (Fetched) { + return Resource; + } + + await FetchSemaphore.WaitAsync().ConfigureAwait(false); + try { + if (Fetched) { + return Resource; + } + + Resource = await fetchResource().ConfigureAwait(false); + Fetched = true; + + return Resource; + } finally { + FetchSemaphore.Release(); + } + } + } +} diff --git a/FreePackages/FreePackages.csproj b/FreePackages/FreePackages.csproj index a3d0704..649866c 100644 --- a/FreePackages/FreePackages.csproj +++ b/FreePackages/FreePackages.csproj @@ -2,7 +2,7 @@ Citrinate - 1.4.5.2 + 1.5.0.0 enable latest net8.0 diff --git a/FreePackages/Handlers/PackageFilter.cs b/FreePackages/Handlers/PackageFilter.cs index c7aba4a..3fdfd0b 100644 --- a/FreePackages/Handlers/PackageFilter.cs +++ b/FreePackages/Handlers/PackageFilter.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; using System.Linq; +using AngleSharp.Dom; using ArchiSteamFarm.Core; +using ArchiSteamFarm.Steam.Integration; +using ArchiSteamFarm.Web.Responses; using SteamKit2; namespace FreePackages { @@ -129,6 +132,11 @@ internal bool IsAppWantedByFilter(FilterableApp app, FilterConfig filter) { // Unwated to due not being wishlisted or followed on the Steam storefront return false; } + + if (filter.MinDaysOld > 0 && DateTime.UtcNow.AddDays(-filter.MinDaysOld) > app.SteamReleaseDate) { + // Unwanted because the app isn't new enough + return false; + } return true; } @@ -183,6 +191,28 @@ internal bool IsAppIgnoredByFilter(FilterableApp app, FilterConfig filter) { return false; } + internal bool IsAppFreeAndValidOnStore(AppDetails? appDetails) { + if (appDetails == null) { + // Indeterminate, assume the app is free and valid + return true; + } + + if (!appDetails.Success) { + // App doesn't have a store page + // Usually this is true, but not always. Example: https://store.steampowered.com/api/appdetails/?appids=317780 (on May 13, 2024) + // App 317780, also passes all of the checks below, but cannot be activated and doesn't have a store page. It's type is listed as "advertising". + return false; + } + + bool isFree = appDetails?.Data?.IsFree ?? false; + if (!isFree) { + // App is not free + return false; + } + + return true; + } + internal bool IsRedeemablePackage(FilterablePackage package) { if (UserData == null) { throw new InvalidOperationException(nameof(UserData)); @@ -207,6 +237,11 @@ internal bool IsRedeemablePackage(FilterablePackage package) { } if (package.DontGrantIfAppIDOwned > 0 && OwnedAppIDs.Contains(package.DontGrantIfAppIDOwned)) { + // Owns an app that blocks activation + return false; + } + + if (package.MustOwnAppToPurchase > 0 && !OwnedAppIDs.Contains(package.MustOwnAppToPurchase)) { // Don't own required app return false; } @@ -264,7 +299,7 @@ internal bool IsPackageIgnoredByFilter(FilterablePackage package, FilterConfig f internal bool IsRedeemablePlaytest(FilterableApp app) { // More than half of playtests we try to join will be invalid. // Some of these will be becase there's no free packages (which we can't determine here), Ex: playtest is activated by key: https://steamdb.info/sub/858277/ - // For most, There seems to be no difference at all between invalid playtest and valid ones. The only way to resolve these would be to scrape the parent's store page. + // For most, There seems to be no difference at all between invalid playtest and valid ones. The only way to resolve these is to scrape the parent's store page. if (app.Parent == null) { return false; @@ -318,6 +353,32 @@ internal bool IsPlaytestWantedByFilter(FilterableApp app, FilterConfig filter) { return true; } + internal bool IsPlaytestValidOnStore(HtmlDocumentResponse? storePage) { + if (storePage == null) { + // Indeterminate, assume the playtest is valid + return true; + } + + bool hasStorePage = storePage.FinalUri != ArchiWebHandler.SteamStoreURL; + if (!hasStorePage) { + // App doesnt have a store page (redirects to homepage) + return false; + } + + if (storePage.Content == null || !storePage.StatusCode.IsSuccessCode()) { + // Indeterminate (this will catch age gated store pages), assume the playtest is valid + return true; + } + + bool hasPlaytestButton = storePage.Content.SelectNodes("//script").Any(static node => node.TextContent.Contains("RequestPlaytestAccess")); + if (!hasPlaytestButton) { + // Playtest is not active (doesn't have a "Request Access" button on store page) + return false; + } + + return true; + } + internal bool FilterOnlyAllowsPackages(FilterConfig filter) { if (filter.NoCostOnly) { // NoCost is a property value that only applies to packages, so ignore all non-packages diff --git a/FreePackages/Handlers/PackageHandler.cs b/FreePackages/Handlers/PackageHandler.cs index 6e73ae2..09d8993 100644 --- a/FreePackages/Handlers/PackageHandler.cs +++ b/FreePackages/Handlers/PackageHandler.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using ArchiSteamFarm.Core; using ArchiSteamFarm.Steam; +using ArchiSteamFarm.Web.Responses; using FreePackages.Localization; using SteamKit2; @@ -138,10 +139,7 @@ private async static Task IsReady(uint maxWaitTimeSeconds = 120) { } internal async static Task HandleChanges() { - if (!await ProcessChangesSemaphore.WaitAsync(0).ConfigureAwait(false)) { - return; - } - + await ProcessChangesSemaphore.WaitAsync().ConfigureAwait(false); try { await IsReady().ConfigureAwait(false); @@ -198,9 +196,11 @@ private async static Task HandleProductInfo(List { if (app.Type == EAppType.Beta) { - Handlers.Values.ToList().ForEach(x => x.HandlePlaytest(app)); + SharedExternalResource storePageResource = new(); + Handlers.Values.ToList().ForEach(x => Utilities.InBackground(async() => await x.HandlePlaytest(app, storePageResource).ConfigureAwait(false))); } else { - Handlers.Values.ToList().ForEach(x => x.HandleFreeApp(app)); + SharedExternalResource appDetailsResource = new(); + Handlers.Values.ToList().ForEach(x => Utilities.InBackground(async() => await x.HandleFreeApp(app, appDetailsResource).ConfigureAwait(false))); } }); } @@ -292,7 +292,7 @@ private async static Task HandleProductInfo(List x.BotCache.SaveChanges()); } - private void HandleFreeApp(FilterableApp app) { + private async Task HandleFreeApp(FilterableApp app, SharedExternalResource appDetailsResource) { if (!BotCache.ChangedApps.Contains(app.ID)) { return; } @@ -310,6 +310,10 @@ private void HandleFreeApp(FilterableApp app) { return; } + if (!PackageFilter.IsAppFreeAndValidOnStore(await appDetailsResource.Fetch(async() => await WebRequest.GetAppDetails(Bot, app.ID).ConfigureAwait(false)).ConfigureAwait(false))) { + return; + } + PackageQueue.AddPackage(new Package(EPackageType.App, app.ID)); } finally { BotCache.RemoveChange(appID: app.ID); @@ -340,7 +344,7 @@ private void HandleFreePackage(FilterablePackage package) { } } - private void HandlePlaytest(FilterableApp app) { + private async Task HandlePlaytest(FilterableApp app, SharedExternalResource storePageResource) { if (!BotCache.ChangedApps.Contains(app.ID)) { return; } @@ -362,6 +366,10 @@ private void HandlePlaytest(FilterableApp app) { return; } + if (!PackageFilter.IsPlaytestValidOnStore(await storePageResource.Fetch(async() => await WebRequest.GetStorePage(Bot, app.Parent.ID).ConfigureAwait(false)).ConfigureAwait(false))) { + return; + } + PackageQueue.AddPackage(new Package(EPackageType.Playtest, app.Parent.ID)); } finally { BotCache.RemoveChange(appID: app.ID); @@ -397,6 +405,7 @@ private void HandleNewPackage(FilterablePackage package) { if (dlcAppIDs.Count != 0) { BotCache.AddChanges(appIDs: dlcAppIDs); + Utilities.InBackground(async() => await HandleChanges().ConfigureAwait(false)); } } finally { BotCache.RemoveChange(newOwnedPackageID: package.ID); @@ -407,8 +416,14 @@ internal void HandleLicenseList(SteamApps.LicenseListCallback callback) { HashSet ownedPackageIDs = callback.LicenseList.Select(license => license.PackageID).ToHashSet(); HashSet newOwnedPackageIDs = ownedPackageIDs.Except(BotCache.SeenPackages).ToHashSet(); + if (newOwnedPackageIDs.Count == 0) { + return; + } + + // Cached seen packages need to be initialized if (BotCache.SeenPackages.Count > 0) { BotCache.AddChanges(newOwnedPackageIDs: newOwnedPackageIDs); + Utilities.InBackground(async() => await HandleChanges().ConfigureAwait(false)); } BotCache.UpdateSeenPackages(newOwnedPackageIDs); @@ -485,4 +500,4 @@ internal void AddPackages(HashSet? appIDs, HashSet? packageIDs, bool PackageQueue.AddPackages(packages); } } -} \ No newline at end of file +} diff --git a/FreePackages/Handlers/PackageQueue.cs b/FreePackages/Handlers/PackageQueue.cs index 7033eab..3a4c4c0 100644 --- a/FreePackages/Handlers/PackageQueue.cs +++ b/FreePackages/Handlers/PackageQueue.cs @@ -42,7 +42,9 @@ internal void AddPackage(Package package, HashSet? appIDsToRemove = null) } if (package.Type == EPackageType.Sub && appIDsToRemove != null) { - // Remove duplicates. Whenever we're trying to activate and app and also an package for that app, get rid of the app. Because error messages for packages are more descriptive and useful. + // Used to remove duplicates. + // Whenever we're trying to activate an app and also an package for that app, get rid of the app. + // I only really like to do this because the error messages for packages are more descriptive and useful. BotCache.RemoveAppPackages(appIDsToRemove); } } @@ -60,13 +62,16 @@ private async Task ProcessQueue() { return; } - if (BotCache.Packages.Count == 0) { + Package? package = BotCache.GetNextPackage(); + if (package == null) { + // No packages to activate UpdateTimer(DateTime.Now.AddMinutes(1)); return; } if (BotCache.NumActivationsPastHour() >= ActivationsPerHour) { + // Rate limit reached DateTime resumeTime = BotCache.GetLastActivation()!.Value.AddHours(1).AddMinutes(1); Bot.ArchiLogger.LogGenericInfo(String.Format(Strings.ActivationPaused, String.Format("{0:T}", resumeTime))); UpdateTimer(resumeTime); @@ -81,14 +86,6 @@ private async Task ProcessQueue() { return; } - Package? package = BotCache.GetNextPackage(); - if (package == null) { - // There are packages to redeem, but they aren't active yet - UpdateTimer(DateTime.Now.AddMinutes(1)); - - return; - } - EResult result = await ClaimPackage(package).ConfigureAwait(false); if (result == EResult.RateLimitExceeded) { @@ -102,7 +99,7 @@ private async Task ProcessQueue() { } // Note: Not everything counts against the activation limit, ex: All playtests?, Some sub errors (dunno which), Maybe some app errors - // Might be worth revisiting later, but for now I feel comfortable just assuming everything counts + // Might be worth revisiting later, but for now I feel comfortable just assuming everything that doesnt get a rate limit response counts BotCache.AddActivation(DateTime.Now); if (result == EResult.OK || result == EResult.Invalid) { @@ -151,7 +148,7 @@ private async Task ClaimFreeApp(uint appID) { // The Result returned by RequestFreeLicense is useless and I've only ever seen it return EResult.OK if (response.Result != EResult.OK) { - Bot.ArchiLogger.LogGenericInfo(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("app/{0}", appID), response.Result)); + Bot.ArchiLogger.LogGenericDebug(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("app/{0}", appID), response.Result)); return EResult.Fail; } @@ -173,7 +170,7 @@ private async Task ClaimFreeApp(uint appID) { bool isComingSoon = appDetails?.Data?.ReleaseDate?.ComingSoon ?? true; if (!success || !isFree || isComingSoon) { - Bot.ArchiLogger.LogGenericInfo(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("app/{0}", appID), EResult.Invalid)); + Bot.ArchiLogger.LogGenericDebug(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("app/{0}", appID), EResult.Invalid)); return EResult.Invalid; } @@ -183,7 +180,7 @@ private async Task ClaimFreeApp(uint appID) { if (hasPackages) { // Replace the app with the appropriate package and when we try to activate that we'll find out for sure if we're rate limited or not // Note: This is mostly wishful thinking. /api/appdetails rarely shows the free packages for free apps (one example where it does: https://steamdb.info/app/2119270/) - Bot.ArchiLogger.LogGenericInfo(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("app/{0}", appID), String.Format(Strings.ReplacedWith, String.Join(", ", appDetails!.Data!.Packages.Select(x => $"sub/{x}"))))); + Bot.ArchiLogger.LogGenericDebug(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("app/{0}", appID), String.Format(Strings.ReplacedWith, String.Join(", ", appDetails!.Data!.Packages.Select(x => $"sub/{x}"))))); BotCache.AddChanges(packageIDs: appDetails.Data.Packages); return EResult.OK; @@ -192,7 +189,7 @@ private async Task ClaimFreeApp(uint appID) { // We could be rate limited, but the app could also be invalid beacause it has no available licenses. It's necessary to assume invalid so we don't get into an infinite loop. // Examples: https://steamdb.info/app/2401570/ on Oct 2, 2023, Attempting to download demo through Steam client gives error "no licenses" // Free games that still have store pages but display "At the request of the publisher, ___ is unlisted on the Steam store and will not appear in search.": https://store.steampowered.com/app/376570/WildStar/ - Bot.ArchiLogger.LogGenericInfo(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("app/{0}", appID), Strings.Unknown)); + Bot.ArchiLogger.LogGenericDebug(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("app/{0}", appID), Strings.Unknown)); return EResult.Invalid; } @@ -211,7 +208,11 @@ private async Task ClaimFreeSub(uint subID) { return EResult.Invalid; } - Bot.ArchiLogger.LogGenericInfo(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("sub/{0}", subID), String.Format("{0}/{1}", result, purchaseResult))); + if (result == EResult.OK) { + Bot.ArchiLogger.LogGenericInfo(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("sub/{0}", subID), String.Format("{0}/{1}", result, purchaseResult))); + } else { + Bot.ArchiLogger.LogGenericDebug(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("sub/{0}", subID), String.Format("{0}/{1}", result, purchaseResult))); + } if (purchaseResult == EPurchaseResultDetail.RateLimited) { return EResult.RateLimitExceeded; @@ -233,14 +234,14 @@ private async Task ClaimPlaytest(uint appID) { if (response == null) { // Playtest does not exist currently - Bot.ArchiLogger.LogGenericInfo(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("playtest/{0}", appID), Strings.Invalid)); + Bot.ArchiLogger.LogGenericDebug(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("playtest/{0}", appID), Strings.Invalid)); return EResult.Invalid; } if (response.Success != 1) { // Not sure if/when this happens - Bot.ArchiLogger.LogGenericInfo(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("playtest/{0}", appID), Strings.Failed)); + Bot.ArchiLogger.LogGenericDebug(String.Format(ArchiSteamFarm.Localization.Strings.BotAddLicense, String.Format("playtest/{0}", appID), Strings.Failed)); return EResult.Invalid; } @@ -285,4 +286,4 @@ internal string GetStatus() { private static int GetMillisecondsFromNow(DateTime then) => Math.Max(0, (int) (then - DateTime.Now).TotalMilliseconds); private void UpdateTimer(DateTime then) => Timer?.Change(GetMillisecondsFromNow(then), Timeout.Infinite); } -} \ No newline at end of file +} diff --git a/FreePackages/IPC/Api/FreePackagesController.cs b/FreePackages/IPC/Api/FreePackagesController.cs index 97be617..562a95f 100644 --- a/FreePackages/IPC/Api/FreePackagesController.cs +++ b/FreePackages/IPC/Api/FreePackagesController.cs @@ -269,7 +269,9 @@ public ActionResult QueueLicenses(string botNames, [FromBody] Q PackageHandler.Handlers[bot.BotName].AddPackages(request.AppIDs, request.PackageIDs, request.UseFilter); } + Utilities.InBackground(async() => await PackageHandler.HandleChanges().ConfigureAwait(false)); + return Ok(new GenericResponse(true)); } } -} \ No newline at end of file +} diff --git a/FreePackages/WebRequest.cs b/FreePackages/WebRequest.cs index abb08ee..59a2bb6 100644 --- a/FreePackages/WebRequest.cs +++ b/FreePackages/WebRequest.cs @@ -6,13 +6,16 @@ using ArchiSteamFarm.Core; using ArchiSteamFarm.Steam; using ArchiSteamFarm.Steam.Integration; +using ArchiSteamFarm.Web; using ArchiSteamFarm.Web.Responses; using SteamKit2; namespace FreePackages { internal static class WebRequest { private static SemaphoreSlim AppDetailsSemaphore = new SemaphoreSlim(1, 1); + private static SemaphoreSlim StorePageSemaphore = new SemaphoreSlim(1, 1); private const int AppDetailsDelaySeconds = 2; + private const int StorePageDelaySeconds = 2; internal static async Task GetUserData(Bot bot) { Uri request = new(ArchiWebHandler.SteamStoreURL, "/dynamicstore/userdata/"); @@ -26,7 +29,7 @@ internal static class WebRequest { await AppDetailsSemaphore.WaitAsync().ConfigureAwait(false); try { Uri request = new(ArchiWebHandler.SteamStoreURL, String.Format("/api/appdetails/?appids={0}", appID)); - ObjectResponse>? appDetailsResponse = await bot.ArchiWebHandler.UrlGetToJsonObjectWithSession>(request).ConfigureAwait(false); + ObjectResponse>? appDetailsResponse = await bot.ArchiWebHandler.UrlGetToJsonObjectWithSession>(request, maxTries: 1).ConfigureAwait(false); return appDetailsResponse?.Content?[appID]; } finally { @@ -62,5 +65,23 @@ internal static class WebRequest { return playtestAccessResponse?.Content; } + + internal static async Task GetStorePage(Bot bot, uint? appID) { + ArgumentNullException.ThrowIfNull(appID); + + await StorePageSemaphore.WaitAsync().ConfigureAwait(false); + try { + Uri request = new(ArchiWebHandler.SteamStoreURL, String.Format("/app/{0}", appID)); + + return await bot.ArchiWebHandler.UrlGetToHtmlDocumentWithSession(request, maxTries: 1, requestOptions: WebBrowser.ERequestOptions.ReturnRedirections); + } finally { + Utilities.InBackground( + async() => { + await Task.Delay(TimeSpan.FromSeconds(StorePageDelaySeconds)).ConfigureAwait(false); + StorePageSemaphore.Release(); + } + ); + } + } } } \ No newline at end of file diff --git a/README.md b/README.md index a06b29a..2b98660 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ By default, the plugin will attempt to activate all free non-playtest packages. "Languages": [], "Systems": [], "MinReviewScore": 0, + "MinDaysOld": 0, "IgnoredContentDescriptors": [], "IgnoredTypes": [], "IgnoredTags": [], @@ -306,6 +307,12 @@ All filter options are explained below: --- +#### MinDaysOld + +`uint` type with default value of `0`. Packages must contain an app which was released on Steam within the last `MinDaysOld` days or they will not be added to your account. You can leave this at `0` to not filter by release date. + +--- + #### IgnoredContentDescriptors `HashSet` type with default value of `[]`. Packages containing apps with any of the `ContentDescriptorIDs` specified here will not be added to your account. Detailed information about content descriptors can be found [here](https://store.steampowered.com/account/preferences/) under "Mature Content Filtering".