Skip to content

Commit

Permalink
Rework getting subscriptions list (#138)
Browse files Browse the repository at this point in the history
  • Loading branch information
UncleGoogle authored Jan 22, 2022
1 parent ffe063e commit 1535af1
Show file tree
Hide file tree
Showing 10 changed files with 666 additions and 107 deletions.
3 changes: 2 additions & 1 deletion src/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
## Unreleased

[Fixed]
- error handling bug introduced in 0.9.5
- Error handling bug introduced in 0.9.5
- Rework getting subscriptions list #165 #167

## Version 0.9.5
(!) WARNING: If you're Humble subscriber, plugin reconnection is needed to sync subscriptions again (!)
Expand Down
66 changes: 66 additions & 0 deletions src/active_month_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import typing as t

from galaxy.api.errors import UnknownBackendResponse

from webservice import AuthorizedHumbleAPI, WebpackParseError
from model.subscription import UserSubscriptionInfo
from model.types import Tier


class ActiveMonthInfoByUser(t.NamedTuple):
machine_name: str
'''
Treats two bussines cases the same way:
having active month content AND not owning yet, but having payment scheduled
https://support.humblebundle.com/hc/en-us/articles/217300487-Humble-Choice-Early-Unlock-Games
'''
is_or_will_be_owned: bool


ActiveMonthInfoFetchStrategy = t.Callable[[AuthorizedHumbleAPI], t.Awaitable[ActiveMonthInfoByUser]]


class _CantFetchActiveMonthInfo(Exception):
pass


class ActiveMonthResolver():
def __init__(self, has_active_subscription: bool) -> None:
if has_active_subscription:
fetch_strategy = _get_ami_from_subscriber_fallbacking_to_marketing
else:
fetch_strategy = _get_ami_from_marketing
self._fetch_strategy: ActiveMonthInfoFetchStrategy = fetch_strategy

async def resolve(self, api: AuthorizedHumbleAPI) -> ActiveMonthInfoByUser:
return await self._fetch_strategy(api)


async def _get_ami_from_subscriber_fallbacking_to_marketing(api: AuthorizedHumbleAPI) -> ActiveMonthInfoByUser:
try:
return await _get_ami_from_subscriber(api)
except _CantFetchActiveMonthInfo:
return await _get_ami_from_marketing(api)


async def _get_ami_from_subscriber(api: AuthorizedHumbleAPI) -> ActiveMonthInfoByUser:
try:
raw = await api.get_subscriber_hub_data()
subscriber_hub = UserSubscriptionInfo(raw)
machine_name = subscriber_hub.pay_early_options.active_content_product_machine_name
marked_as_owned = subscriber_hub.user_plan.tier != Tier.LITE
except (WebpackParseError, KeyError, AttributeError, ValueError) as e:
msg = f"Can't get info about not-yet-unlocked subscription month: {e!r}"
raise _CantFetchActiveMonthInfo(msg)
else:
return ActiveMonthInfoByUser(machine_name, marked_as_owned)


async def _get_ami_from_marketing(api: AuthorizedHumbleAPI) -> ActiveMonthInfoByUser:
try:
marketing_data = await api.get_choice_marketing_data()
machine_name = marketing_data['activeContentMachineName']
except (KeyError, UnknownBackendResponse) as e:
raise UnknownBackendResponse(e)
else:
return ActiveMonthInfoByUser(machine_name, False)
5 changes: 5 additions & 0 deletions src/gui/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
from .baseapp import BaseApp
from .keys import ShowKey

__all__ = [
"BaseApp",
"ShowKey"
]
75 changes: 72 additions & 3 deletions src/model/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,45 @@
from model.types import HP, DeliveryMethod, Tier


Timestamp = float


def datetime_parse(dt: str) -> Timestamp:
return Timestamp(datetime.datetime.fromisoformat(dt).timestamp())


def _now_time() -> Timestamp:
return Timestamp(datetime.datetime.now().timestamp())


class UserSubscriptionInfo:
def __init__(self, data: dict) -> None:
self._data = data

@property
def user_plan(self) -> "UserSubscriptionPlan":
return UserSubscriptionPlan(self._data["userSubscriptionPlan"])

@property
def pay_early_options(self) -> "PayEarlyOptions":
return PayEarlyOptions(self._data.get("payEarlyOptions", {}))

@property
def subcription_join_date(self) -> Timestamp:
return datetime_parse(self._data["subscriptionJoinDate|datetime"])

@property
def subscription_expires(self) -> Timestamp:
return datetime_parse(self._data["subscriptionExpires|datetime"])

def subscription_expired(self) -> bool:
"""
Due date of the last, already payed period.
Note that it may return False for user that hasn't used Early Unlock to get active month content.
"""
return _now_time() > self.subscription_expires


class UserSubscriptionPlan:
"""
{
Expand All @@ -22,6 +61,19 @@ def __init__(self, data: dict):
self.human_name = data['human_name']


class PayEarlyOptions:
def __init__(self, data: dict) -> None:
self._data = data

@property
def active_content_product_machine_name(self) -> str:
return self._data["productMachineName"]

@property
def active_content_start(self) -> Timestamp:
return datetime_parse(self._data["activeContentStart|datetime"])


class ChoiceMonth:
"""Below example of month from `data['monthDetails']['previous_months']`
{
Expand Down Expand Up @@ -178,6 +230,23 @@ def remained_choices(self) -> int:
return self.MAX_CHOICES - len(self._content_choices_made)


class ContentMonthlyOptions:
"""
"machine_name": "september_2019_monthly",
"highlights": [
"8 Games",
"$179.00 Value"
],
"order_url": "/downloads?key=Ge882ERvybaawmWd",
"short_human_name": "September 2019",
"hero_image": "https://hb.imgix.net/a25aa69d4c827d42142d631a716b3fbd89c15733.jpg?auto=compress,format&fit=crop&h=600&w=1200&s=789fedc066299f3d3ed802f6f1e55b6f",
"early_unlock_string": "Slay the Spire and Squad (Early Access)"
"""
def __init__(self, data: dict):
for k, v in data.items():
setattr(self, k, v)


class MontlyContentData:
"""
"webpack_json": {
Expand Down Expand Up @@ -248,9 +317,9 @@ def __init__(self, data: dict):
self.content_choice_options = ContentChoiceOptions(data['contentChoiceOptions'])

@property
def active_content_start(self) -> t.Optional[datetime.datetime]:
def active_content_start(self) -> t.Optional[Timestamp]:
try:
iso = self.pay_early_options['activeContentStart|datetime']
dt = self.pay_early_options['activeContentStart|datetime']
except KeyError:
return None
return datetime.datetime.fromisoformat(iso)
return datetime_parse(dt)
79 changes: 41 additions & 38 deletions src/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import calendar
import typing as t
from functools import partial
from distutils.version import LooseVersion # pylint: disable=no-name-in-module,import-error
from contextlib import suppress
from distutils.version import LooseVersion

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

Expand All @@ -22,13 +23,17 @@

from consts import IS_WINDOWS, TROVE_SUBSCRIPTION_NAME
from settings import Settings
from webservice import AuthorizedHumbleAPI
from webservice import AuthorizedHumbleAPI, WebpackParseError
from model.game import TroveGame, Key, Subproduct, HumbleGame, ChoiceGame
from model.types import HP, Tier
from model.types import HP
from humbledownloader import HumbleDownloadResolver
from library import LibraryResolver
from local import AppFinder
from privacy import SensitiveFilter
from active_month_resolver import (
ActiveMonthInfoByUser,
ActiveMonthResolver,
)
from utils.decorators import double_click_effect
from gui.options import OPTIONS_MODE
import guirunner as gui
Expand Down Expand Up @@ -112,7 +117,7 @@ def handshake_complete(self):

async def _get_user_name(self) -> str:
try:
marketing_data = await self._api.get_choice_marketing_data()
marketing_data = await self._api.get_main_page_webpack_data()
return marketing_data['userOptions']['email'].split('@')[0]
except (BackendError, KeyError, UnknownBackendResponse) as e:
logger.error(repr(e))
Expand Down Expand Up @@ -182,56 +187,53 @@ def _normalize_subscription_name(machine_name: str):
'november': '11',
'december': '12'
}
month, year, type_ = machine_name.split('_')
try:
month, year, type_ = machine_name.split('_')
except Exception:
assert False, f"is {machine_name}"
return f'Humble {type_.title()} {year}-{month_map[month]}'

@staticmethod
def _choice_name_to_slug(subscription_name: str):
_, type_, year_month = subscription_name.split(' ')
_, _, year_month = subscription_name.split(' ')
year, month = year_month.split('-')
return f'{calendar.month_name[int(month)]}-{year}'.lower()

async def _get_active_month_machine_name(self) -> str:
marketing_data = await self._api.get_choice_marketing_data()
return marketing_data['activeContentMachineName']

async def get_subscriptions(self):
subscriptions: t.List[Subscription] = []
current_plan = await self._api.get_subscription_plan()
active_content_unlocked = False
subscription_state = await self._api.get_user_subscription_state()
# perks are Trove and store discount; paused month makes perks "inactive"
has_active_subscription = subscription_state.get("perksStatus") == "active"
owns_active_content = subscription_state.get("monthlyOwnsActiveContent")

subscriptions.append(Subscription(
subscription_name = TROVE_SUBSCRIPTION_NAME,
owned = has_active_subscription
))

async for product in self._api.get_subscription_products_with_gamekeys():
if 'contentChoiceData' not in product:
break # all Humble Choice months already yielded

is_active = product.get('isActiveContent', False)
is_product_unlocked = 'gamekey' in product
subscriptions.append(Subscription(
self._normalize_subscription_name(product['productMachineName']),
owned='gamekey' in product
))
active_content_unlocked |= is_active # assuming there is only one "active" month at a time

if not active_content_unlocked:
'''
- for not subscribers as potential discovery of current choice games
- for subscribers who has not used "Early Unlock" yet:
https://support.humblebundle.com/hc/en-us/articles/217300487-Humble-Choice-Early-Unlock-Games
'''
active_month_machine_name = await self._get_active_month_machine_name()
subscriptions.append(Subscription(
self._normalize_subscription_name(active_month_machine_name),
owned = current_plan is not None and current_plan.tier != Tier.LITE, # TODO: last month of not payed subs are still returned
end_time = None # #117: get_last_friday.timestamp() if user_plan not in [None, Lite] else None
owned = is_product_unlocked
))

subscriptions.append(Subscription(
subscription_name = TROVE_SUBSCRIPTION_NAME,
owned = current_plan is not None
))
if not owns_active_content:
active_month_resolver = ActiveMonthResolver(has_active_subscription)
active_month_info: ActiveMonthInfoByUser = await active_month_resolver.resolve(self._api)

if active_month_info.machine_name:
subscriptions.append(Subscription(
self._normalize_subscription_name(active_month_info.machine_name),
owned = active_month_info.is_or_will_be_owned,
end_time = None # #117: get_last_friday.timestamp() if user_plan not in [None, Lite] else None
))

return subscriptions

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

newly_added = (await self._api.get_montly_trove_data()).get('newlyAdded', [])
if newly_added:
yield parse_and_cache(newly_added)
with suppress(WebpackParseError):
newly_added = (await self._api.get_montly_trove_data()).get('newlyAdded', [])
if newly_added:
yield parse_and_cache(newly_added)
async for troves in self._api.get_trove_details():
yield parse_and_cache(troves)

async def get_subscription_games(self, subscription_name, context):
if subscription_name == TROVE_SUBSCRIPTION_NAME:
async for troves in self._get_trove_games():
Expand Down
Loading

0 comments on commit 1535af1

Please sign in to comment.