Skip to content

Commit

Permalink
Fix subscriptions - adapt to humble change & code simplification (#162)
Browse files Browse the repository at this point in the history
  • Loading branch information
UncleGoogle authored Oct 3, 2021
1 parent f96edbd commit 16726d5
Show file tree
Hide file tree
Showing 5 changed files with 89 additions and 120 deletions.
34 changes: 5 additions & 29 deletions src/model/subscription.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import typing as t
import json
import datetime

from model.game import Key
Expand All @@ -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']`
{
Expand Down Expand Up @@ -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):
Expand Down
61 changes: 29 additions & 32 deletions src/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,7 +61,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] = {}
Expand Down Expand Up @@ -111,15 +109,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):
Expand Down Expand Up @@ -190,46 +191,42 @@ 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:
async for product in self._api.get_subscription_products_with_gamekeys():
if 'contentChoiceData' not in product:
break # all Humble Choice months already yielded
async for product in self._api.get_subscription_products_with_gamekeys():
if 'contentChoiceData' not in product:
break # all Humble Choice months already yielded

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
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:
'''
- 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 = 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
Expand Down
68 changes: 42 additions & 26 deletions src/webservice.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
from http.cookies import SimpleCookie
from http import HTTPStatus
from contextlib import contextmanager
import typing as t
import aiohttp
import json
import base64
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:
Expand All @@ -38,15 +56,15 @@ 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:
return bool(self._session.cookie_jar)

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)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -170,6 +175,18 @@ 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.warning("Can't fetch userSubscriptionPlan details. %s", repr(e))
return None

async def get_subscriber_hub_data(self) -> dict:
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
Expand All @@ -179,11 +196,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
Expand All @@ -208,10 +224,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}')
raise UnknownBackendResponse()
logger.debug(f'chunk_details: {chunk_details}')
raise UnknownBackendResponse("Unrecognized trove chunks structure")
elif len(chunk_details) == 0:
logging.debug('No more chunk pages')
logger.debug('No more chunk pages')
return
yield chunk_details
index += 1
Expand Down Expand Up @@ -250,7 +266,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):
Expand All @@ -261,7 +277,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):
Expand Down
Loading

0 comments on commit 16726d5

Please sign in to comment.