From 2d4459e3fc8941b8768b3e2c4ec57daca82bade9 Mon Sep 17 00:00:00 2001 From: UncleGoogle Date: Sat, 2 Oct 2021 01:49:52 +0200 Subject: [PATCH 1/7] Fix subscriptions - user plan check also - do not use changed marketing data --- src/model/subscription.py | 34 ++----------- src/plugin.py | 47 +++++++++-------- src/webservice.py | 81 +++++++++++++++++++++--------- tests/common/test_subscriptions.py | 10 ++-- tests/conftest.py | 3 +- 5 files changed, 90 insertions(+), 85 deletions(-) diff --git a/src/model/subscription.py b/src/model/subscription.py index 6662cdab..5c834d12 100644 --- a/src/model/subscription.py +++ b/src/model/subscription.py @@ -1,4 +1,5 @@ import typing as t +import json import datetime from model.game import Key @@ -21,35 +22,6 @@ def __init__(self, data: dict): self.human_name = data['human_name'] -class ChoiceMarketingData: - """Custom class based on `webpack-choice-marketing-data['monthDetails'] from https://www.humblebundle.com/subscription - { - "monthDetails": { - "previous_months": [], - "active_month": {} - }, - "userOptions": { - "email": str, - ... - }, - "navbarOptions": { - "activeContentEndDate|datetime": "2020-06-05T17:00:00", - "productHumanName": "May 2020 Humble Choice" - }, - ... - } - """ - def __init__(self, data: dict): - self.user_options = data['userOptions'] - self.active_month = ChoiceMonth(data['monthDetails']['active_month'], is_active=True) - self.month_details = [ - self.active_month - ] + [ - ChoiceMonth(month, is_active=False) - for month in data['monthDetails']['previous_months'] - ] - - class ChoiceMonth: """Below example of month from `data['monthDetails']['previous_months']` { @@ -96,10 +68,14 @@ class ChoiceMonth: }, """ def __init__(self, data: dict, is_active: bool = False): + self._data = data self.is_active: bool = is_active self.machine_name: str = data['machine_name'] self.short_human_name: str = data['short_human_name'] self.monthly_product_page_url: str = data['monthly_product_page_url'] + + def __repr__(self) -> str: + return json.dumps(self._data, indent=4) @property def last_url_part(self): diff --git a/src/plugin.py b/src/plugin.py index 81a9c24e..35ce1b3d 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -62,7 +62,6 @@ def __init__(self, reader, writer, token): self._app_finder = AppFinder() self._settings = Settings() self._library_resolver = None - self._subscription_months: t.List[ChoiceMonth] = [] self._owned_games: t.Dict[str, HumbleGame] = {} self._trove_games: t.Dict[str, TroveGame] = {} @@ -111,15 +110,18 @@ def handshake_complete(self): self._last_version = self._load_cache('last_version', default=None) self._trove_games = {g['machine_name']: TroveGame(g) for g in self._load_cache('trove_games', [])} self._choice_games = {g['id']: ChoiceGame(**g) for g in self._load_cache('choice_games', [])} - + + async def _get_user_name(self) -> str: + try: + marketing_data = await self._api.get_choice_marketing_data() + return marketing_data['userOptions']['email'].split('@')[0] + except (BackendError, KeyError, UnknownBackendResponse) as e: + logger.error(repr(e)) + return "" + async def _do_auth(self, auth_cookie) -> Authentication: user_id = await self._api.authenticate(auth_cookie) - try: - subscription_infos = await self._api.get_choice_marketing_data() - self._subscription_months = subscription_infos.month_details - user_name = subscription_infos.user_options['email'].split('@')[0] - except (BackendError, KeyError, UnknownBackendResponse): # extra safety as this data is not crucial - user_name = user_id + user_name = await self._get_user_name() or "Humble User" return Authentication(user_id, user_name) async def authenticate(self, stored_credentials=None): @@ -190,26 +192,26 @@ def _choice_name_to_slug(subscription_name: str): year, month = year_month.split('-') return f'{calendar.month_name[int(month)]}-{year}'.lower() - async def _get_subscription_plan(self, month_path: str) -> t.Optional[UserSubscriptionPlan]: - month_content = await self._api.get_choice_content_data(month_path) - return month_content.user_subscription_plan + 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] = [] - historical_subscriber = await self._api.had_subscription() + current_plan = await self._api.get_subscription_plan() active_content_unlocked = False - if historical_subscriber: + if current_plan is not None: 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) subscriptions.append(Subscription( self._normalize_subscription_name(product['productMachineName']), owned='gamekey' in product )) - if product.get('isActiveContent'): # assuming there is only one "active" month at a time - active_content_unlocked = True + active_content_unlocked |= is_active # assuming there is only one "active" month at a time if not active_content_unlocked: ''' @@ -217,19 +219,16 @@ async def get_subscriptions(self): - 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 = next(filter(lambda m: m.is_active == True, self._subscription_months)) - current_plan = await self._get_subscription_plan(active_month.last_url_part) \ - if historical_subscriber else None - + 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, - end_time=None # #117: get_last_friday.timestamp() if user_plan not in [None, Lite] else None + 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 )) subscriptions.append(Subscription( - subscription_name=TROVE_SUBSCRIPTION_NAME, - owned=active_content_unlocked or current_plan is not None + subscription_name = TROVE_SUBSCRIPTION_NAME, + owned = current_plan is not None )) return subscriptions diff --git a/src/webservice.py b/src/webservice.py index 0aa996f2..87b4983c 100644 --- a/src/webservice.py +++ b/src/webservice.py @@ -1,5 +1,6 @@ from http.cookies import SimpleCookie from http import HTTPStatus +from contextlib import contextmanager import typing as t import aiohttp import json @@ -7,11 +8,28 @@ import logging import yarl -from galaxy.http import create_client_session, handle_exception +import galaxy.http from galaxy.api.errors import UnknownBackendResponse from model.download import TroveDownload, DownloadStructItem -from model.subscription import MontlyContentData, ChoiceContentData, ChoiceMarketingData, ChoiceMonth +from model.subscription import MontlyContentData, ChoiceContentData, ChoiceMonth, UserSubscriptionPlan + + +logger = logging.getLogger(__name__) + + +@contextmanager +def handle_exception(): + """Wrapper over galaxy.http to log error details""" + with galaxy.http.handle_exception(): + try: + yield + except Exception as e: + logger.error(e) + + +class Redirected(Exception): + pass class AuthorizedHumbleAPI: @@ -38,7 +56,7 @@ class AuthorizedHumbleAPI: } def __init__(self): - self._session = create_client_session(headers=self._DEFAULT_HEADERS) + self._session = galaxy.http.create_client_session(headers=self._DEFAULT_HEADERS) @property def is_authenticated(self) -> bool: @@ -46,7 +64,7 @@ def is_authenticated(self) -> bool: async def _request(self, method, path, *args, **kwargs): url = self._AUTHORITY + path - logging.debug(f'{method}, {url}, {args}, {kwargs}') + logger.debug(f'{method}, {url}, {args}, {kwargs}') with handle_exception(): return await self._session.request(method, url, *args, **kwargs) @@ -85,7 +103,7 @@ async def authenticate(self, auth_cookie: dict) -> t.Optional[str]: async def get_gamekeys(self) -> t.List[str]: res = await self._request('get', self._ORDER_LIST_URL) parsed = await res.json() - logging.info(f"The order list:\n{parsed}") + logger.info(f"The order list:\n{parsed}") gamekeys = [it["gamekey"] for it in parsed] return gamekeys @@ -145,19 +163,6 @@ async def get_previous_subscription_months(self, from_product: str): yield ChoiceMonth(prev_month) from_product = prev_month['machine_name'] - async def had_subscription(self) -> t.Optional[bool]: - """Based on current behavior of `humblebundle.com/subscription/home` - that is accesable only by "current and former subscribers" - """ - res = await self._request('get', self._SUBSCRIPTION_HOME, allow_redirects=False) - if res.status == 200: - return True - elif res.status == 302: - return False - else: - logging.warning(f'{self._SUBSCRIPTION_HOME}, Status code: {res.status}') - return None - async def _get_webpack_data(self, path: str, webpack_id: str) -> dict: res = await self._request('GET', path, allow_redirects=False) txt = await res.text() @@ -170,6 +175,33 @@ async def _get_webpack_data(self, path: str, webpack_id: str) -> dict: raise UnknownBackendResponse('cannot parse webpack data') from e return parsed + async def get_subscription_plan(self) -> t.Optional[UserSubscriptionPlan]: + try: + sub_hub_data = await self.get_subscriber_hub_data() + return UserSubscriptionPlan(sub_hub_data["userSubscriptionPlan"]) + except (UnknownBackendResponse, KeyError) as e: + logger.error(repr(e)) + return None + + # async def _had_subscription(self) -> t.Optional[bool]: + # """Based on current behavior of `humblebundle.com/subscription/home` + # that is accesable only by "current and former subscribers" + # """ + # res = await self._request('get', self._SUBSCRIPTION_HOME, allow_redirects=False) + # if res.status == 200: + # return True + # elif res.status == 302: + # return False + # else: + # logging.warning(f'{self._SUBSCRIPTION_HOME}, Status code: {res.status}') + # return None + + async def get_subscriber_hub_data(self) -> dict: + # if self._had_subscription == False: + # return None + webpack_id = "webpack-subscriber-hub-data" + return await self._get_webpack_data(self._SUBSCRIPTION_HOME, webpack_id) + async def get_montly_trove_data(self) -> dict: """Parses a subscription/trove page to find list of recently added games. Returns json containing "newlyAdded" trove games and "standardProducts" that is @@ -179,11 +211,10 @@ async def get_montly_trove_data(self) -> dict: webpack_id = "webpack-monthly-trove-data" return await self._get_webpack_data(self._SUBSCRIPTION_TROVE, webpack_id) - async def get_choice_marketing_data(self) -> ChoiceMarketingData: + async def get_choice_marketing_data(self) -> dict: """Parsing ~155K and fast response from server""" webpack_id = "webpack-choice-marketing-data" - data = await self._get_webpack_data(self._SUBSCRIPTION, webpack_id) - return ChoiceMarketingData(data) + return await self._get_webpack_data(self._SUBSCRIPTION, webpack_id) async def get_choice_content_data(self, product_url_path) -> ChoiceContentData: """Parsing ~220K @@ -208,10 +239,10 @@ async def get_trove_details(self, from_chunk: int=0): while True: chunk_details = await self._get_trove_details(index) if type(chunk_details) != list: - logging.debug(f'chunk_details: {chunk_details}') + logger.debug(f'chunk_details: {chunk_details}') raise UnknownBackendResponse() elif len(chunk_details) == 0: - logging.debug('No more chunk pages') + logger.debug('No more chunk pages') return yield chunk_details index += 1 @@ -250,7 +281,7 @@ async def sign_url_subproduct(self, download: DownloadStructItem, download_machi await self._reedem_download( download_machine_name, {'download_url_file': filename}) except Exception as e: - logging.error(repr(e) + '. Error ignored') + logger.error(repr(e) + '. Error ignored') return urls async def sign_url_trove(self, download: TroveDownload, product_machine_name: str): @@ -261,7 +292,7 @@ async def sign_url_trove(self, download: TroveDownload, product_machine_name: st await self._reedem_download( download.machine_name, {'product': product_machine_name}) except Exception as e: - logging.error(repr(e) + '. Error ignored') + logger.error(repr(e) + '. Error ignored') return urls async def close_session(self): diff --git a/tests/common/test_subscriptions.py b/tests/common/test_subscriptions.py index a8f90c87..ae4fe6fd 100644 --- a/tests/common/test_subscriptions.py +++ b/tests/common/test_subscriptions.py @@ -37,7 +37,7 @@ def plugin_with_sub(plugin): @pytest.mark.asyncio async def test_get_subscriptions_never_subscribed(api_mock, plugin_with_sub): - api_mock.had_subscription.return_value = False + api_mock.get_subscription_plan.return_value = None res = await plugin_with_sub.get_subscriptions() assert res == [ @@ -48,7 +48,7 @@ async def test_get_subscriptions_never_subscribed(api_mock, plugin_with_sub): @pytest.mark.asyncio async def test_get_subscriptions_multiple_where_one_paused(api_mock, plugin_with_sub): - api_mock.had_subscription.return_value = True + api_mock.get_subscription_plan.return_value = {"tier": "mock_any"} content_choice_options = [ {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'may_2020_choice', 'isActiveContent': True}, {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'april_2020_choice', 'isActiveContent': False}, @@ -77,7 +77,7 @@ async def test_get_subscriptions_humble_choice_and_humble_monthly(api_mock, plug The subscription_products_with_gamekeys API returns firstly Choice months data, then old Humble Monthly subscription data. Expected: Plugin should ignore Humble Montly subscription months. """ - api_mock.had_subscription.return_value = True + api_mock.get_subscription_plan.return_value = {"tier": "mock_any"} content_choice_options = [ {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'january_2020_choice', 'isActiveContent': True}, {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'december_2019_choice', 'isActiveContent': False}, @@ -100,7 +100,7 @@ async def test_get_subscriptions_past_subscriber(api_mock, plugin_with_sub): Testcase: Currently no subscriptiion but user was subscriber in the past Expected: Active subscription months + not owned Trove & and owned active month """ - api_mock.had_subscription.return_value = True + api_mock.get_subscription_plan.return_value = {"tier": "mock_any"} api_mock.get_choice_content_data.return_value = Mock(**{'user_subscription_plan': None}) content_choice_options = [ {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'march_2020_choice', 'isActiveContent': False}, @@ -137,7 +137,7 @@ async def test_get_subscriptions_current_month_not_unlocked_yet( --- Test checks also logic for Trove ownership base on subscription status. """ - api_mock.had_subscription.return_value = True + api_mock.get_subscription_plan.return_value = {"tier": "mock_any"} api_mock.get_choice_content_data.return_value = Mock(user_subscription_plan=current_subscription_plan) content_choice_options = [ {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'april_2020_choice', 'isActiveContent': False} diff --git a/tests/conftest.py b/tests/conftest.py index 7458c8d0..f1033097 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,7 +47,7 @@ def api_mock_raw(): mock.get_order_details = AsyncMock() mock.get_gamekeys = AsyncMock() mock.get_montly_trove_data = AsyncMock() - mock.had_subscription = AsyncMock() + mock.get_subscription_plan = AsyncMock() mock.get_trove_details = AsyncMock() mock.sign_url_trove = AsyncMock() mock.sign_url_subproduct = AsyncMock() @@ -75,7 +75,6 @@ def get_details(gamekey): mock.TROVES_PER_CHUNK = 20 mock.get_gamekeys.return_value = [i['gamekey'] for i in mock.orders] mock.get_order_details.side_effect = get_details - mock.had_subscription.return_value = True mock.get_trove_details.side_effect = lambda from_chunk: get_troves(from_chunk) return mock From 158c19b11d8bc475c6b905709e7e33ed67089f65 Mon Sep 17 00:00:00 2001 From: UncleGoogle Date: Sat, 2 Oct 2021 23:29:23 +0200 Subject: [PATCH 2/7] remove unused import --- src/plugin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugin.py b/src/plugin.py index 35ce1b3d..3cdf6328 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -25,7 +25,6 @@ from webservice import AuthorizedHumbleAPI from model.game import TroveGame, Key, Subproduct, HumbleGame, ChoiceGame from model.types import HP, Tier -from model.subscription import ChoiceMonth, UserSubscriptionPlan from humbledownloader import HumbleDownloadResolver from library import LibraryResolver from local import AppFinder From 195085bb9c6ed23b8e1199e46db159e96c5ccc5d Mon Sep 17 00:00:00 2001 From: UncleGoogle Date: Sun, 3 Oct 2021 00:08:29 +0200 Subject: [PATCH 3/7] Fix tests --- src/plugin.py | 21 ++++++++------- src/webservice.py | 15 ----------- tests/common/test_subscriptions.py | 41 ++++++++---------------------- 3 files changed, 21 insertions(+), 56 deletions(-) diff --git a/src/plugin.py b/src/plugin.py index 3cdf6328..264766bb 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -200,17 +200,16 @@ async def get_subscriptions(self): current_plan = await self._api.get_subscription_plan() active_content_unlocked = False - if current_plan is not None: - 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) - 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 + 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) + 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: ''' diff --git a/src/webservice.py b/src/webservice.py index 87b4983c..8658ea91 100644 --- a/src/webservice.py +++ b/src/webservice.py @@ -183,22 +183,7 @@ async def get_subscription_plan(self) -> t.Optional[UserSubscriptionPlan]: logger.error(repr(e)) return None - # async def _had_subscription(self) -> t.Optional[bool]: - # """Based on current behavior of `humblebundle.com/subscription/home` - # that is accesable only by "current and former subscribers" - # """ - # res = await self._request('get', self._SUBSCRIPTION_HOME, allow_redirects=False) - # if res.status == 200: - # return True - # elif res.status == 302: - # return False - # else: - # logging.warning(f'{self._SUBSCRIPTION_HOME}, Status code: {res.status}') - # return None - async def get_subscriber_hub_data(self) -> dict: - # if self._had_subscription == False: - # return None webpack_id = "webpack-subscriber-hub-data" return await self._get_webpack_data(self._SUBSCRIPTION_HOME, webpack_id) diff --git a/tests/common/test_subscriptions.py b/tests/common/test_subscriptions.py index ae4fe6fd..de248962 100644 --- a/tests/common/test_subscriptions.py +++ b/tests/common/test_subscriptions.py @@ -4,40 +4,23 @@ from galaxy.api.types import Subscription from conftest import aiter -from model.subscription import ChoiceMonth +from model.subscription import UserSubscriptionPlan from model.types import Tier @pytest.fixture -def plugin_with_sub(plugin): - """ - plugin._subscription_months internal cache is expected to be set at time of getting subscriptions - """ - plugin._subscription_months = [ - ChoiceMonth({ - "machine_name": "may_2020_choice", - "short_human_name": "May 2020", - "monthly_product_page_url": "/subscription/may-2020" - }, is_active=True), - ChoiceMonth({ - "machine_name": "april_2020_choice", - "short_human_name": "April 2020", - "monthly_product_page_url": "/subscription/april-2020", - "item_count": 12 - }, is_active=False), - ChoiceMonth({ - "machine_name": "march_2020_choice", - "short_human_name": "March 2020", - "monthly_product_page_url": "/subscription/march-2020", - "item_count": 12 - }, is_active=False) - ] +def plugin_with_sub(plugin, api_mock): + api_mock.get_subscription_plan.return_value = Mock(UserSubscriptionPlan, name="unspecified subscription") + api_mock.get_choice_marketing_data.return_value = { + "activeContentMachineName": "may_2020_choice" + } return plugin @pytest.mark.asyncio async def test_get_subscriptions_never_subscribed(api_mock, plugin_with_sub): api_mock.get_subscription_plan.return_value = None + api_mock.get_subscription_products_with_gamekeys = MagicMock(return_value=aiter([])) res = await plugin_with_sub.get_subscriptions() assert res == [ @@ -48,7 +31,6 @@ async def test_get_subscriptions_never_subscribed(api_mock, plugin_with_sub): @pytest.mark.asyncio async def test_get_subscriptions_multiple_where_one_paused(api_mock, plugin_with_sub): - api_mock.get_subscription_plan.return_value = {"tier": "mock_any"} content_choice_options = [ {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'may_2020_choice', 'isActiveContent': True}, {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'april_2020_choice', 'isActiveContent': False}, @@ -77,7 +59,6 @@ async def test_get_subscriptions_humble_choice_and_humble_monthly(api_mock, plug The subscription_products_with_gamekeys API returns firstly Choice months data, then old Humble Monthly subscription data. Expected: Plugin should ignore Humble Montly subscription months. """ - api_mock.get_subscription_plan.return_value = {"tier": "mock_any"} content_choice_options = [ {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'january_2020_choice', 'isActiveContent': True}, {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'december_2019_choice', 'isActiveContent': False}, @@ -97,11 +78,11 @@ async def test_get_subscriptions_humble_choice_and_humble_monthly(api_mock, plug @pytest.mark.asyncio async def test_get_subscriptions_past_subscriber(api_mock, plugin_with_sub): """ - Testcase: Currently no subscriptiion but user was subscriber in the past - Expected: Active subscription months + not owned Trove & and owned active month + Testcase: Currently no subscription but user was subscriber in the past + Expected: Active subscription months + not owned Trove & and not owned active month """ - api_mock.get_subscription_plan.return_value = {"tier": "mock_any"} api_mock.get_choice_content_data.return_value = Mock(**{'user_subscription_plan': None}) + api_mock.get_subscription_plan.return_value = None content_choice_options = [ {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'march_2020_choice', 'isActiveContent': False}, {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'february_2020_choice', 'isActiveContent': False}, @@ -137,8 +118,8 @@ async def test_get_subscriptions_current_month_not_unlocked_yet( --- Test checks also logic for Trove ownership base on subscription status. """ - api_mock.get_subscription_plan.return_value = {"tier": "mock_any"} api_mock.get_choice_content_data.return_value = Mock(user_subscription_plan=current_subscription_plan) + api_mock.get_subscription_plan.return_value = current_subscription_plan content_choice_options = [ {'contentChoiceData': Mock(dict), 'gamekey': Mock(str), 'productMachineName': 'april_2020_choice', 'isActiveContent': False} ] From ad4fa450a22efb49a4778c6d881e8a8cedea14de Mon Sep 17 00:00:00 2001 From: UncleGoogle Date: Sun, 3 Oct 2021 21:08:55 +0200 Subject: [PATCH 4/7] improve logging --- src/webservice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webservice.py b/src/webservice.py index 8658ea91..7b690ed9 100644 --- a/src/webservice.py +++ b/src/webservice.py @@ -180,7 +180,7 @@ async def get_subscription_plan(self) -> t.Optional[UserSubscriptionPlan]: sub_hub_data = await self.get_subscriber_hub_data() return UserSubscriptionPlan(sub_hub_data["userSubscriptionPlan"]) except (UnknownBackendResponse, KeyError) as e: - logger.error(repr(e)) + logger.warning("Can't fetch userSubscriptionPlan details. %s", repr(e)) return None async def get_subscriber_hub_data(self) -> dict: @@ -225,7 +225,7 @@ async def get_trove_details(self, from_chunk: int=0): chunk_details = await self._get_trove_details(index) if type(chunk_details) != list: logger.debug(f'chunk_details: {chunk_details}') - raise UnknownBackendResponse() + raise UnknownBackendResponse("Unrecognized trove chunks structure") elif len(chunk_details) == 0: logger.debug('No more chunk pages') return From 2780c2c9bbccd501819626a7c9b972076a092de6 Mon Sep 17 00:00:00 2001 From: UncleGoogle Date: Sun, 3 Oct 2021 21:18:35 +0200 Subject: [PATCH 5/7] type for simple cookie --- src/webservice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webservice.py b/src/webservice.py index 7b690ed9..588b0fc6 100644 --- a/src/webservice.py +++ b/src/webservice.py @@ -90,7 +90,7 @@ def _decode_user_id(self, _simpleauth_sess): async def authenticate(self, auth_cookie: dict) -> t.Optional[str]: # recreate original cookie - cookie = SimpleCookie() + cookie: SimpleCookie = SimpleCookie() cookie_val = bytes(auth_cookie['value'], "utf-8").decode("unicode_escape") # some users have cookies with escaped characters, some not... # for the first group strip quotes: @@ -180,7 +180,7 @@ async def get_subscription_plan(self) -> t.Optional[UserSubscriptionPlan]: sub_hub_data = await self.get_subscriber_hub_data() return UserSubscriptionPlan(sub_hub_data["userSubscriptionPlan"]) except (UnknownBackendResponse, KeyError) as e: - logger.warning("Can't fetch userSubscriptionPlan details. %s", repr(e)) + logger.warning("Can't fetch userSubscriptionPlan details: %s", repr(e)) return None async def get_subscriber_hub_data(self) -> dict: From 69f4002a7d5485ef0d29be3567aa7e05be55ce68 Mon Sep 17 00:00:00 2001 From: UncleGoogle Date: Sun, 3 Oct 2021 21:34:49 +0200 Subject: [PATCH 6/7] handle unknown subscription plan status --- src/plugin.py | 9 +++++++-- src/webservice.py | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/plugin.py b/src/plugin.py index 264766bb..a68badec 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -197,8 +197,12 @@ async def _get_active_month_machine_name(self) -> str: async def get_subscriptions(self): subscriptions: t.List[Subscription] = [] - current_plan = await self._api.get_subscription_plan() active_content_unlocked = False + try: + current_plan = await self._api.get_subscription_plan() + except Exception as e: + logger.error("Can't fetch user subscription plan: %s", repr(e)) + current_plan = None async for product in self._api.get_subscription_products_with_gamekeys(): if 'contentChoiceData' not in product: @@ -220,7 +224,8 @@ async def get_subscriptions(self): 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 + # TODO: last month of not payed subs are still returned + owned = current_plan is not None and current_plan.tier != Tier.LITE, end_time = None # #117: get_last_friday.timestamp() if user_plan not in [None, Lite] else None )) diff --git a/src/webservice.py b/src/webservice.py index 588b0fc6..154b6905 100644 --- a/src/webservice.py +++ b/src/webservice.py @@ -179,9 +179,11 @@ async def get_subscription_plan(self) -> t.Optional[UserSubscriptionPlan]: try: sub_hub_data = await self.get_subscriber_hub_data() return UserSubscriptionPlan(sub_hub_data["userSubscriptionPlan"]) - except (UnknownBackendResponse, KeyError) as e: - logger.warning("Can't fetch userSubscriptionPlan details: %s", repr(e)) + except UnknownBackendResponse as e: + logger.debug("Can't fetch user subscription plan. Assuming user hasn't been a subscriber") return None + except KeyError as e: + raise UnknownBackendResponse("Can't fetch user subscription plan: %s", repr(e)) async def get_subscriber_hub_data(self) -> dict: webpack_id = "webpack-subscriber-hub-data" From 7cf163a0c3b34d9d52af1fbbf6d40ac1cdae211f Mon Sep 17 00:00:00 2001 From: UncleGoogle Date: Sun, 3 Oct 2021 23:02:33 +0200 Subject: [PATCH 7/7] POC --- src/model/subscription.py | 2 +- src/model/types.py | 8 +++++++ src/plugin.py | 49 ++++++++++++++++++++++++++++----------- src/webservice.py | 11 +++++---- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/src/model/subscription.py b/src/model/subscription.py index 5c834d12..ab18fdcb 100644 --- a/src/model/subscription.py +++ b/src/model/subscription.py @@ -21,7 +21,7 @@ def __init__(self, data: dict): self.machine_name = data['machine_name'] self.human_name = data['human_name'] - + class ChoiceMonth: """Below example of month from `data['monthDetails']['previous_months']` { diff --git a/src/model/types.py b/src/model/types.py index bd9d811b..936a23be 100644 --- a/src/model/types.py +++ b/src/model/types.py @@ -57,3 +57,11 @@ class Tier(enum.Enum): BASIC = 'basic' PREMIUM = 'premium' CLASSIC = 'premiumv1' + + +class SubscriptionStatus(enum.Enum): + NeverSubscribed = enum.auto() + Expired = enum.auto() + Active = enum.auto() + Unknown = enum.auto() + \ No newline at end of file diff --git a/src/plugin.py b/src/plugin.py index a68badec..91289fae 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -1,3 +1,4 @@ +from build.model.subscription import UserSubscriptionPlan import sys import platform import asyncio @@ -16,15 +17,15 @@ import sentry_sdk from sentry_sdk.integrations.logging import LoggingIntegration from galaxy.api.plugin import Plugin, create_and_run_plugin -from galaxy.api.consts import Platform, OSCompatibility +from galaxy.api.consts import Platform, OSCompatibility, SubscriptionDiscovery from galaxy.api.types import Authentication, NextStep, LocalGame, GameLibrarySettings, Subscription, SubscriptionGame from galaxy.api.errors import AuthenticationRequired, UnknownBackendResponse, UnknownError, BackendError from consts import IS_WINDOWS, TROVE_SUBSCRIPTION_NAME from settings import Settings -from webservice import AuthorizedHumbleAPI +from webservice import AuthorizedHumbleAPI, NeverSubscriberError from model.game import TroveGame, Key, Subproduct, HumbleGame, ChoiceGame -from model.types import HP, Tier +from model.types import HP, Tier, SubscriptionStatus from humbledownloader import HumbleDownloadResolver from library import LibraryResolver from local import AppFinder @@ -194,15 +195,27 @@ def _choice_name_to_slug(subscription_name: str): 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): + # TODO - before deciding on data structures, check how expired subs work and how to know if it is expired subscriptions: t.List[Subscription] = [] - active_content_unlocked = False + sub_status: SubscriptionStatus + sub_plan: t.Optional[UserSubscriptionPlan] = None + is_active_content_unlocked: bool = False + try: - current_plan = await self._api.get_subscription_plan() + sub_plan = await self._api.get_subscription_plan() + except NeverSubscriberError: + sub_status = SubscriptionStatus.NeverSubscribed except Exception as e: logger.error("Can't fetch user subscription plan: %s", repr(e)) - current_plan = None + sub_status = SubscriptionStatus.Unknown + else: + sub_status = SubscriptionStatus.Active + + discovery = SubscriptionDiscovery.USER_ENABLED + if sub_status != SubscriptionStatus.Unknown: + discovery |= SubscriptionDiscovery.AUTOMATIC async for product in self._api.get_subscription_products_with_gamekeys(): if 'contentChoiceData' not in product: @@ -211,27 +224,35 @@ async def get_subscriptions(self): is_active = product.get('isActiveContent', False) subscriptions.append(Subscription( self._normalize_subscription_name(product['productMachineName']), - owned='gamekey' in product + owned = 'gamekey' in product, + subscription_discovery = discovery )) - active_content_unlocked |= is_active # assuming there is only one "active" month at a time + is_active_content_unlocked |= is_active # assuming there is only one "active" month at a time - if not active_content_unlocked: + if not is_active_content_unlocked: ''' - - for not subscribers as potential discovery of current choice games + - for not this month 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() + if sub_status == SubscriptionStatus.Unknown: + active_month_owned = None + else: + active_month_owned = sub_status == SubscriptionStatus.Active and sub_plan.tier != Tier.LITE, + subscriptions.append(Subscription( self._normalize_subscription_name(active_month_machine_name), # TODO: last month of not payed subs are still returned - owned = current_plan is not None and current_plan.tier != Tier.LITE, - end_time = None # #117: get_last_friday.timestamp() if user_plan not in [None, Lite] else None + owned = active_month_owned, + end_time = None, # #117: get_last_friday.timestamp() if user_plan not in [None, Lite] else None + subscription_discovery = discovery )) subscriptions.append(Subscription( subscription_name = TROVE_SUBSCRIPTION_NAME, - owned = current_plan is not None + owned = sub_status == SubscriptionStatus.Active, + subscription_discovery = discovery )) return subscriptions diff --git a/src/webservice.py b/src/webservice.py index 154b6905..1f6a5acb 100644 --- a/src/webservice.py +++ b/src/webservice.py @@ -32,6 +32,10 @@ class Redirected(Exception): pass +class NeverSubscriberError(Exception): + pass + + class AuthorizedHumbleAPI: _AUTHORITY = "https://www.humblebundle.com/" _PROCESS_LOGIN = "processlogin" @@ -175,14 +179,13 @@ async def _get_webpack_data(self, path: str, webpack_id: str) -> dict: raise UnknownBackendResponse('cannot parse webpack data') from e return parsed - async def get_subscription_plan(self) -> t.Optional[UserSubscriptionPlan]: + async def get_subscription_plan(self) -> UserSubscriptionPlan: try: sub_hub_data = await self.get_subscriber_hub_data() return UserSubscriptionPlan(sub_hub_data["userSubscriptionPlan"]) except UnknownBackendResponse as e: - logger.debug("Can't fetch user subscription plan. Assuming user hasn't been a subscriber") - return None - except KeyError as e: + raise NeverSubscriberError("Can't fetch user subscription plan: %s", repr(e)) + except KeyError: raise UnknownBackendResponse("Can't fetch user subscription plan: %s", repr(e)) async def get_subscriber_hub_data(self) -> dict: