Skip to content

Commit 4eb61d7

Browse files
committed
Merge branch '1.5'
2 parents 12c678e + e1d3048 commit 4eb61d7

15 files changed

+222
-37
lines changed

FreePackages.Tests/Filters.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,4 +414,21 @@ public void CanFilterByWishlist() {
414414

415415
Assert.IsTrue(PackageFilter.IsAppWantedByFilter(app, Filter));
416416
}
417+
418+
[TestMethod]
419+
public void CanFilterByReleaseDate() {
420+
var app = new FilterableApp(KeyValue.LoadAsText("app_which_is_free.txt"));
421+
422+
Filter.MinDaysOld = 0;
423+
424+
Assert.IsTrue(PackageFilter.IsAppWantedByFilter(app, Filter));
425+
426+
Filter.MinDaysOld = 1;
427+
428+
Assert.IsFalse(PackageFilter.IsAppWantedByFilter(app, Filter));
429+
430+
Filter.MinDaysOld = 20000;
431+
432+
Assert.IsTrue(PackageFilter.IsAppWantedByFilter(app, Filter));
433+
}
417434
}

FreePackages/Commands.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
using System.ComponentModel;
44
using System.Globalization;
55
using System.Linq;
6+
using System.Runtime.CompilerServices;
67
using System.Text;
78
using ArchiSteamFarm.Core;
89
using ArchiSteamFarm.Steam;
10+
using FreePackages.Localization;
911

1012
namespace FreePackages {
1113
internal static class Commands {
@@ -69,7 +71,7 @@ internal static class Commands {
6971
}
7072

7173
if (!PackageHandler.Handlers.Keys.Contains(bot.BotName)) {
72-
return FormatBotResponse(bot, "Free Packages plugin not enabled");
74+
return FormatBotResponse(bot, Strings.PluginNotEnabled);
7375
}
7476

7577
return FormatBotResponse(bot, PackageHandler.Handlers[bot.BotName].ClearQueue());
@@ -103,7 +105,7 @@ internal static class Commands {
103105
}
104106

105107
if (!PackageHandler.Handlers.Keys.Contains(bot.BotName)) {
106-
return FormatBotResponse(bot, "Free Packages plugin not enabled");
108+
return FormatBotResponse(bot, Strings.PluginNotEnabled);
107109
}
108110

109111
return FormatBotResponse(bot, PackageHandler.Handlers[bot.BotName].GetStatus());
@@ -127,7 +129,7 @@ internal static class Commands {
127129
return responses.Count > 0 ? String.Join(Environment.NewLine, responses) : null;
128130
}
129131

130-
private static string? ResponseQueueLicense(Bot bot, EAccess access, string licenses, bool useFilter = false) {
132+
private static string? ResponseQueueLicense(Bot bot, EAccess access, string licenses, bool useFilter = false, [CallerMemberName] string? previousMethodName = null) {
131133
if (access < EAccess.Master) {
132134
return null;
133135
}
@@ -178,6 +180,10 @@ internal static class Commands {
178180
response.AppendLine(FormatBotResponse(bot, PackageHandler.Handlers[bot.BotName].AddPackage(packageType, gameID, useFilter)));
179181
}
180182

183+
if (previousMethodName == nameof(Response)) {
184+
Utilities.InBackground(async() => await PackageHandler.HandleChanges().ConfigureAwait(false));
185+
}
186+
181187
return response.Length > 0 ? response.ToString() : null;
182188
}
183189

@@ -196,6 +202,8 @@ internal static class Commands {
196202

197203
List<string?> responses = new(results.Where(result => !String.IsNullOrEmpty(result)));
198204

205+
Utilities.InBackground(async() => await PackageHandler.HandleChanges().ConfigureAwait(false));
206+
199207
return responses.Count > 0 ? String.Join(Environment.NewLine, responses) : null;
200208
}
201209

FreePackages/Data/ASFInfo.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ private static async Task DoUpdate() {
9292

9393
PackageHandler.Handlers.Values.ToList().ForEach(x => x.BotCache.AddChanges(appIDs, packageIDs));
9494
FreePackages.GlobalCache.UpdateASFInfoItemCount(itemCount);
95+
Utilities.InBackground(async() => await PackageHandler.HandleChanges().ConfigureAwait(false));
9596
}
9697
}
9798
}

FreePackages/Data/BotCache.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ internal BotCache(string filePath) : this() {
8585
return null;
8686
}
8787

88+
botCache.Packages = new(botCache.Packages.GroupBy(package => package, new PackageComparer()).Select(group => group.First()), new PackageComparer());
8889
botCache.FilePath = filePath;
8990

9091
return botCache;
@@ -121,13 +122,14 @@ internal bool RemovePackage(Package package) {
121122
}
122123

123124
internal bool RemoveAppPackages(HashSet<uint> appIDsToRemove) {
124-
Packages.Where(x => appIDsToRemove.Contains(x.ID)).ToList().ForEach(x => Packages.Remove(x));
125+
Packages.Where(x => x.Type == EPackageType.App && appIDsToRemove.Contains(x.ID)).ToList().ForEach(x => Packages.Remove(x));
125126
Utilities.InBackground(Save);
126127

127128
return true;
128129
}
129130

130131
internal Package? GetNextPackage() {
132+
// Return the package which should be activated first, prioritizing first packages which have a start and end date
131133
ulong now = DateUtils.DateTimeToUnixTime(DateTime.UtcNow);
132134
Package? package = Packages.FirstOrDefault(x => x.StartTime != null && now > x.StartTime);
133135
if (package != null) {

FreePackages/Data/FilterConfig.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ internal sealed class FilterConfig {
5858
[JsonInclude]
5959
internal bool WishlistOnly { get; set; } = false;
6060

61+
[JsonInclude]
62+
internal uint MinDaysOld { get; set; } = 0;
63+
6164
[JsonConstructor]
6265
internal FilterConfig() { }
6366
}

FreePackages/Data/FilterableApp.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ internal sealed class FilterableApp {
2727
internal uint PlayTestType;
2828
internal List<string>? OSList;
2929
internal uint DeckCompatibility;
30+
internal DateTime SteamReleaseDate;
3031
internal bool Hidden;
3132

3233
internal FilterableApp(SteamApps.PICSProductInfoCallback.PICSProductInfo productInfo) : this(productInfo.ID, productInfo.KeyValues) {}
@@ -55,6 +56,7 @@ internal FilterableApp(uint id, KeyValue kv) {
5556
PlayTestType = kv["extended"]["playtest_type"].AsUnsignedInteger();
5657
OSList = kv["common"]["oslist"].AsString()?.ToUpper().Split(",").ToList();
5758
DeckCompatibility = kv["common"]["steam_deck_compatibility"]["category"].AsUnsignedInteger();
59+
SteamReleaseDate = DateTimeOffset.FromUnixTimeSeconds(kv["common"]["steam_release_date"].AsUnsignedInteger()).UtcDateTime;
5860
Hidden = kv["common"] == KeyValue.Invalid;
5961

6062
// 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/

FreePackages/Data/FilterablePackage.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
namespace FreePackages {
77
internal sealed class FilterablePackage {
8-
internal bool IsNew;
8+
internal bool IsNew; // This is used when finding DLC for new games added to account, and is not related to any Steam package property
99
internal List<FilterableApp> PackageContents = new();
1010
internal HashSet<uint> PackageContentIDs;
1111
internal HashSet<uint> PackageContentParentIDs = new();
@@ -18,6 +18,7 @@ internal sealed class FilterablePackage {
1818
internal ulong ExpiryTime;
1919
internal ulong StartTime;
2020
internal uint DontGrantIfAppIDOwned;
21+
internal uint MustOwnAppToPurchase;
2122
internal List<string>? RestrictedCountries;
2223
internal bool OnlyAllowRestrictedCountries;
2324
internal List<string>? PurchaseRestrictedCountries;
@@ -38,6 +39,7 @@ internal FilterablePackage(uint id, KeyValue kv, bool isNew) {
3839
ExpiryTime = kv["extended"]["expirytime"].AsUnsignedLong();
3940
StartTime = kv["extended"]["starttime"].AsUnsignedLong();
4041
DontGrantIfAppIDOwned = kv["extended"]["dontgrantifappidowned"].AsUnsignedInteger();
42+
MustOwnAppToPurchase = kv["extended"]["mustownapptopurchase"].AsUnsignedInteger();
4143
RestrictedCountries = kv["extended"]["restrictedcountries"].AsString()?.ToUpper().Split(" ").ToList();
4244
OnlyAllowRestrictedCountries = kv["extended"]["onlyallowrestrictedcountries"].AsBoolean();
4345
PurchaseRestrictedCountries = kv["extended"]["purchaserestrictedcountries"].AsString()?.ToUpper().Split(" ").ToList();
@@ -111,6 +113,12 @@ internal bool IsAvailable() {
111113
return false;
112114
}
113115

116+
if (ID == 17906) {
117+
// Special case: Anonymous Dedicated Server Comp (https://steamdb.info/sub/17906/)
118+
// This always returns AccessDenied/InvalidPackage
119+
return false;
120+
}
121+
114122
return true;
115123
}
116124

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
5+
// This resource may be used zero or more times independently and, when used, needs to be fetched from an external source.
6+
// If it's used zero times we don't fetch it at all.
7+
// If it's used once or more then we only fetch it once.
8+
9+
namespace FreePackages {
10+
internal sealed class SharedExternalResource<T> {
11+
private SemaphoreSlim FetchSemaphore = new SemaphoreSlim(1, 1);
12+
private T? Resource;
13+
private bool Fetched = false;
14+
15+
internal SharedExternalResource() {}
16+
17+
internal async Task<T?> Fetch(Func<Task<T?>> fetchResource) {
18+
if (Fetched) {
19+
return Resource;
20+
}
21+
22+
await FetchSemaphore.WaitAsync().ConfigureAwait(false);
23+
try {
24+
if (Fetched) {
25+
return Resource;
26+
}
27+
28+
Resource = await fetchResource().ConfigureAwait(false);
29+
Fetched = true;
30+
31+
return Resource;
32+
} finally {
33+
FetchSemaphore.Release();
34+
}
35+
}
36+
}
37+
}

FreePackages/FreePackages.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<Authors>Citrinate</Authors>
5-
<AssemblyVersion>1.4.5.2</AssemblyVersion>
5+
<AssemblyVersion>1.5.0.0</AssemblyVersion>
66
<Nullable>enable</Nullable>
77
<LangVersion>latest</LangVersion>
88
<TargetFramework>net8.0</TargetFramework>

FreePackages/Handlers/PackageFilter.cs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using AngleSharp.Dom;
45
using ArchiSteamFarm.Core;
6+
using ArchiSteamFarm.Steam.Integration;
7+
using ArchiSteamFarm.Web.Responses;
58
using SteamKit2;
69

710
namespace FreePackages {
@@ -129,6 +132,11 @@ internal bool IsAppWantedByFilter(FilterableApp app, FilterConfig filter) {
129132
// Unwated to due not being wishlisted or followed on the Steam storefront
130133
return false;
131134
}
135+
136+
if (filter.MinDaysOld > 0 && DateTime.UtcNow.AddDays(-filter.MinDaysOld) > app.SteamReleaseDate) {
137+
// Unwanted because the app isn't new enough
138+
return false;
139+
}
132140

133141
return true;
134142
}
@@ -183,6 +191,28 @@ internal bool IsAppIgnoredByFilter(FilterableApp app, FilterConfig filter) {
183191
return false;
184192
}
185193

194+
internal bool IsAppFreeAndValidOnStore(AppDetails? appDetails) {
195+
if (appDetails == null) {
196+
// Indeterminate, assume the app is free and valid
197+
return true;
198+
}
199+
200+
if (!appDetails.Success) {
201+
// App doesn't have a store page
202+
// Usually this is true, but not always. Example: https://store.steampowered.com/api/appdetails/?appids=317780 (on May 13, 2024)
203+
// 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".
204+
return false;
205+
}
206+
207+
bool isFree = appDetails?.Data?.IsFree ?? false;
208+
if (!isFree) {
209+
// App is not free
210+
return false;
211+
}
212+
213+
return true;
214+
}
215+
186216
internal bool IsRedeemablePackage(FilterablePackage package) {
187217
if (UserData == null) {
188218
throw new InvalidOperationException(nameof(UserData));
@@ -207,6 +237,11 @@ internal bool IsRedeemablePackage(FilterablePackage package) {
207237
}
208238

209239
if (package.DontGrantIfAppIDOwned > 0 && OwnedAppIDs.Contains(package.DontGrantIfAppIDOwned)) {
240+
// Owns an app that blocks activation
241+
return false;
242+
}
243+
244+
if (package.MustOwnAppToPurchase > 0 && !OwnedAppIDs.Contains(package.MustOwnAppToPurchase)) {
210245
// Don't own required app
211246
return false;
212247
}
@@ -264,7 +299,7 @@ internal bool IsPackageIgnoredByFilter(FilterablePackage package, FilterConfig f
264299
internal bool IsRedeemablePlaytest(FilterableApp app) {
265300
// More than half of playtests we try to join will be invalid.
266301
// 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/
267-
// 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.
302+
// 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.
268303

269304
if (app.Parent == null) {
270305
return false;
@@ -318,6 +353,32 @@ internal bool IsPlaytestWantedByFilter(FilterableApp app, FilterConfig filter) {
318353
return true;
319354
}
320355

356+
internal bool IsPlaytestValidOnStore(HtmlDocumentResponse? storePage) {
357+
if (storePage == null) {
358+
// Indeterminate, assume the playtest is valid
359+
return true;
360+
}
361+
362+
bool hasStorePage = storePage.FinalUri != ArchiWebHandler.SteamStoreURL;
363+
if (!hasStorePage) {
364+
// App doesnt have a store page (redirects to homepage)
365+
return false;
366+
}
367+
368+
if (storePage.Content == null || !storePage.StatusCode.IsSuccessCode()) {
369+
// Indeterminate (this will catch age gated store pages), assume the playtest is valid
370+
return true;
371+
}
372+
373+
bool hasPlaytestButton = storePage.Content.SelectNodes("//script").Any(static node => node.TextContent.Contains("RequestPlaytestAccess"));
374+
if (!hasPlaytestButton) {
375+
// Playtest is not active (doesn't have a "Request Access" button on store page)
376+
return false;
377+
}
378+
379+
return true;
380+
}
381+
321382
internal bool FilterOnlyAllowsPackages(FilterConfig filter) {
322383
if (filter.NoCostOnly) {
323384
// NoCost is a property value that only applies to packages, so ignore all non-packages

0 commit comments

Comments
 (0)