Skip to content

Commit 1535af1

Browse files
authored
Rework getting subscriptions list (#138)
1 parent ffe063e commit 1535af1

File tree

10 files changed

+666
-107
lines changed

10 files changed

+666
-107
lines changed

src/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
## Unreleased
22

33
[Fixed]
4-
- error handling bug introduced in 0.9.5
4+
- Error handling bug introduced in 0.9.5
5+
- Rework getting subscriptions list #165 #167
56

67
## Version 0.9.5
78
(!) WARNING: If you're Humble subscriber, plugin reconnection is needed to sync subscriptions again (!)

src/active_month_resolver.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import typing as t
2+
3+
from galaxy.api.errors import UnknownBackendResponse
4+
5+
from webservice import AuthorizedHumbleAPI, WebpackParseError
6+
from model.subscription import UserSubscriptionInfo
7+
from model.types import Tier
8+
9+
10+
class ActiveMonthInfoByUser(t.NamedTuple):
11+
machine_name: str
12+
'''
13+
Treats two bussines cases the same way:
14+
having active month content AND not owning yet, but having payment scheduled
15+
https://support.humblebundle.com/hc/en-us/articles/217300487-Humble-Choice-Early-Unlock-Games
16+
'''
17+
is_or_will_be_owned: bool
18+
19+
20+
ActiveMonthInfoFetchStrategy = t.Callable[[AuthorizedHumbleAPI], t.Awaitable[ActiveMonthInfoByUser]]
21+
22+
23+
class _CantFetchActiveMonthInfo(Exception):
24+
pass
25+
26+
27+
class ActiveMonthResolver():
28+
def __init__(self, has_active_subscription: bool) -> None:
29+
if has_active_subscription:
30+
fetch_strategy = _get_ami_from_subscriber_fallbacking_to_marketing
31+
else:
32+
fetch_strategy = _get_ami_from_marketing
33+
self._fetch_strategy: ActiveMonthInfoFetchStrategy = fetch_strategy
34+
35+
async def resolve(self, api: AuthorizedHumbleAPI) -> ActiveMonthInfoByUser:
36+
return await self._fetch_strategy(api)
37+
38+
39+
async def _get_ami_from_subscriber_fallbacking_to_marketing(api: AuthorizedHumbleAPI) -> ActiveMonthInfoByUser:
40+
try:
41+
return await _get_ami_from_subscriber(api)
42+
except _CantFetchActiveMonthInfo:
43+
return await _get_ami_from_marketing(api)
44+
45+
46+
async def _get_ami_from_subscriber(api: AuthorizedHumbleAPI) -> ActiveMonthInfoByUser:
47+
try:
48+
raw = await api.get_subscriber_hub_data()
49+
subscriber_hub = UserSubscriptionInfo(raw)
50+
machine_name = subscriber_hub.pay_early_options.active_content_product_machine_name
51+
marked_as_owned = subscriber_hub.user_plan.tier != Tier.LITE
52+
except (WebpackParseError, KeyError, AttributeError, ValueError) as e:
53+
msg = f"Can't get info about not-yet-unlocked subscription month: {e!r}"
54+
raise _CantFetchActiveMonthInfo(msg)
55+
else:
56+
return ActiveMonthInfoByUser(machine_name, marked_as_owned)
57+
58+
59+
async def _get_ami_from_marketing(api: AuthorizedHumbleAPI) -> ActiveMonthInfoByUser:
60+
try:
61+
marketing_data = await api.get_choice_marketing_data()
62+
machine_name = marketing_data['activeContentMachineName']
63+
except (KeyError, UnknownBackendResponse) as e:
64+
raise UnknownBackendResponse(e)
65+
else:
66+
return ActiveMonthInfoByUser(machine_name, False)

src/gui/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
from .baseapp import BaseApp
22
from .keys import ShowKey
3+
4+
__all__ = [
5+
"BaseApp",
6+
"ShowKey"
7+
]

src/model/subscription.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,45 @@
66
from model.types import HP, DeliveryMethod, Tier
77

88

9+
Timestamp = float
10+
11+
12+
def datetime_parse(dt: str) -> Timestamp:
13+
return Timestamp(datetime.datetime.fromisoformat(dt).timestamp())
14+
15+
16+
def _now_time() -> Timestamp:
17+
return Timestamp(datetime.datetime.now().timestamp())
18+
19+
20+
class UserSubscriptionInfo:
21+
def __init__(self, data: dict) -> None:
22+
self._data = data
23+
24+
@property
25+
def user_plan(self) -> "UserSubscriptionPlan":
26+
return UserSubscriptionPlan(self._data["userSubscriptionPlan"])
27+
28+
@property
29+
def pay_early_options(self) -> "PayEarlyOptions":
30+
return PayEarlyOptions(self._data.get("payEarlyOptions", {}))
31+
32+
@property
33+
def subcription_join_date(self) -> Timestamp:
34+
return datetime_parse(self._data["subscriptionJoinDate|datetime"])
35+
36+
@property
37+
def subscription_expires(self) -> Timestamp:
38+
return datetime_parse(self._data["subscriptionExpires|datetime"])
39+
40+
def subscription_expired(self) -> bool:
41+
"""
42+
Due date of the last, already payed period.
43+
Note that it may return False for user that hasn't used Early Unlock to get active month content.
44+
"""
45+
return _now_time() > self.subscription_expires
46+
47+
948
class UserSubscriptionPlan:
1049
"""
1150
{
@@ -22,6 +61,19 @@ def __init__(self, data: dict):
2261
self.human_name = data['human_name']
2362

2463

64+
class PayEarlyOptions:
65+
def __init__(self, data: dict) -> None:
66+
self._data = data
67+
68+
@property
69+
def active_content_product_machine_name(self) -> str:
70+
return self._data["productMachineName"]
71+
72+
@property
73+
def active_content_start(self) -> Timestamp:
74+
return datetime_parse(self._data["activeContentStart|datetime"])
75+
76+
2577
class ChoiceMonth:
2678
"""Below example of month from `data['monthDetails']['previous_months']`
2779
{
@@ -178,6 +230,23 @@ def remained_choices(self) -> int:
178230
return self.MAX_CHOICES - len(self._content_choices_made)
179231

180232

233+
class ContentMonthlyOptions:
234+
"""
235+
"machine_name": "september_2019_monthly",
236+
"highlights": [
237+
"8 Games",
238+
"$179.00 Value"
239+
],
240+
"order_url": "/downloads?key=Ge882ERvybaawmWd",
241+
"short_human_name": "September 2019",
242+
"hero_image": "https://hb.imgix.net/a25aa69d4c827d42142d631a716b3fbd89c15733.jpg?auto=compress,format&fit=crop&h=600&w=1200&s=789fedc066299f3d3ed802f6f1e55b6f",
243+
"early_unlock_string": "Slay the Spire and Squad (Early Access)"
244+
"""
245+
def __init__(self, data: dict):
246+
for k, v in data.items():
247+
setattr(self, k, v)
248+
249+
181250
class MontlyContentData:
182251
"""
183252
"webpack_json": {
@@ -248,9 +317,9 @@ def __init__(self, data: dict):
248317
self.content_choice_options = ContentChoiceOptions(data['contentChoiceOptions'])
249318

250319
@property
251-
def active_content_start(self) -> t.Optional[datetime.datetime]:
320+
def active_content_start(self) -> t.Optional[Timestamp]:
252321
try:
253-
iso = self.pay_early_options['activeContentStart|datetime']
322+
dt = self.pay_early_options['activeContentStart|datetime']
254323
except KeyError:
255324
return None
256-
return datetime.datetime.fromisoformat(iso)
325+
return datetime_parse(dt)

src/plugin.py

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import calendar
1010
import typing as t
1111
from functools import partial
12-
from distutils.version import LooseVersion # pylint: disable=no-name-in-module,import-error
12+
from contextlib import suppress
13+
from distutils.version import LooseVersion
1314

1415
sys.path.insert(0, str(pathlib.PurePath(__file__).parent / 'modules'))
1516

@@ -22,13 +23,17 @@
2223

2324
from consts import IS_WINDOWS, TROVE_SUBSCRIPTION_NAME
2425
from settings import Settings
25-
from webservice import AuthorizedHumbleAPI
26+
from webservice import AuthorizedHumbleAPI, WebpackParseError
2627
from model.game import TroveGame, Key, Subproduct, HumbleGame, ChoiceGame
27-
from model.types import HP, Tier
28+
from model.types import HP
2829
from humbledownloader import HumbleDownloadResolver
2930
from library import LibraryResolver
3031
from local import AppFinder
3132
from privacy import SensitiveFilter
33+
from active_month_resolver import (
34+
ActiveMonthInfoByUser,
35+
ActiveMonthResolver,
36+
)
3237
from utils.decorators import double_click_effect
3338
from gui.options import OPTIONS_MODE
3439
import guirunner as gui
@@ -112,7 +117,7 @@ def handshake_complete(self):
112117

113118
async def _get_user_name(self) -> str:
114119
try:
115-
marketing_data = await self._api.get_choice_marketing_data()
120+
marketing_data = await self._api.get_main_page_webpack_data()
116121
return marketing_data['userOptions']['email'].split('@')[0]
117122
except (BackendError, KeyError, UnknownBackendResponse) as e:
118123
logger.error(repr(e))
@@ -182,56 +187,53 @@ def _normalize_subscription_name(machine_name: str):
182187
'november': '11',
183188
'december': '12'
184189
}
185-
month, year, type_ = machine_name.split('_')
190+
try:
191+
month, year, type_ = machine_name.split('_')
192+
except Exception:
193+
assert False, f"is {machine_name}"
186194
return f'Humble {type_.title()} {year}-{month_map[month]}'
187195

188196
@staticmethod
189197
def _choice_name_to_slug(subscription_name: str):
190-
_, type_, year_month = subscription_name.split(' ')
198+
_, _, year_month = subscription_name.split(' ')
191199
year, month = year_month.split('-')
192200
return f'{calendar.month_name[int(month)]}-{year}'.lower()
193201

194-
async def _get_active_month_machine_name(self) -> str:
195-
marketing_data = await self._api.get_choice_marketing_data()
196-
return marketing_data['activeContentMachineName']
197-
198202
async def get_subscriptions(self):
199203
subscriptions: t.List[Subscription] = []
200-
current_plan = await self._api.get_subscription_plan()
201-
active_content_unlocked = False
204+
subscription_state = await self._api.get_user_subscription_state()
205+
# perks are Trove and store discount; paused month makes perks "inactive"
206+
has_active_subscription = subscription_state.get("perksStatus") == "active"
207+
owns_active_content = subscription_state.get("monthlyOwnsActiveContent")
208+
209+
subscriptions.append(Subscription(
210+
subscription_name = TROVE_SUBSCRIPTION_NAME,
211+
owned = has_active_subscription
212+
))
202213

203214
async for product in self._api.get_subscription_products_with_gamekeys():
204215
if 'contentChoiceData' not in product:
205216
break # all Humble Choice months already yielded
206-
207-
is_active = product.get('isActiveContent', False)
217+
is_product_unlocked = 'gamekey' in product
208218
subscriptions.append(Subscription(
209219
self._normalize_subscription_name(product['productMachineName']),
210-
owned='gamekey' in product
211-
))
212-
active_content_unlocked |= is_active # assuming there is only one "active" month at a time
213-
214-
if not active_content_unlocked:
215-
'''
216-
- for not subscribers as potential discovery of current choice games
217-
- for subscribers who has not used "Early Unlock" yet:
218-
https://support.humblebundle.com/hc/en-us/articles/217300487-Humble-Choice-Early-Unlock-Games
219-
'''
220-
active_month_machine_name = await self._get_active_month_machine_name()
221-
subscriptions.append(Subscription(
222-
self._normalize_subscription_name(active_month_machine_name),
223-
owned = current_plan is not None and current_plan.tier != Tier.LITE, # TODO: last month of not payed subs are still returned
224-
end_time = None # #117: get_last_friday.timestamp() if user_plan not in [None, Lite] else None
220+
owned = is_product_unlocked
225221
))
226222

227-
subscriptions.append(Subscription(
228-
subscription_name = TROVE_SUBSCRIPTION_NAME,
229-
owned = current_plan is not None
230-
))
223+
if not owns_active_content:
224+
active_month_resolver = ActiveMonthResolver(has_active_subscription)
225+
active_month_info: ActiveMonthInfoByUser = await active_month_resolver.resolve(self._api)
226+
227+
if active_month_info.machine_name:
228+
subscriptions.append(Subscription(
229+
self._normalize_subscription_name(active_month_info.machine_name),
230+
owned = active_month_info.is_or_will_be_owned,
231+
end_time = None # #117: get_last_friday.timestamp() if user_plan not in [None, Lite] else None
232+
))
231233

232234
return subscriptions
233235

234-
async def _get_trove_games(self):
236+
async def _get_trove_games(self) -> t.AsyncGenerator[t.List[SubscriptionGame], None]:
235237
def parse_and_cache(troves):
236238
games: t.List[SubscriptionGame] = []
237239
for trove in troves:
@@ -243,12 +245,13 @@ def parse_and_cache(troves):
243245
logging.warning(f"Error while parsing trove {repr(e)}: {trove}", extra={'data': trove})
244246
return games
245247

246-
newly_added = (await self._api.get_montly_trove_data()).get('newlyAdded', [])
247-
if newly_added:
248-
yield parse_and_cache(newly_added)
248+
with suppress(WebpackParseError):
249+
newly_added = (await self._api.get_montly_trove_data()).get('newlyAdded', [])
250+
if newly_added:
251+
yield parse_and_cache(newly_added)
249252
async for troves in self._api.get_trove_details():
250253
yield parse_and_cache(troves)
251-
254+
252255
async def get_subscription_games(self, subscription_name, context):
253256
if subscription_name == TROVE_SUBSCRIPTION_NAME:
254257
async for troves in self._get_trove_games():

0 commit comments

Comments
 (0)