python-xbox 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_xbox-0.1.0.dist-info/METADATA +217 -0
- python_xbox-0.1.0.dist-info/RECORD +64 -0
- python_xbox-0.1.0.dist-info/WHEEL +4 -0
- python_xbox-0.1.0.dist-info/entry_points.txt +6 -0
- python_xbox-0.1.0.dist-info/licenses/LICENSE +20 -0
- pythonxbox/__init__.py +4 -0
- pythonxbox/api/__init__.py +0 -0
- pythonxbox/api/client.py +166 -0
- pythonxbox/api/language.py +76 -0
- pythonxbox/api/provider/__init__.py +0 -0
- pythonxbox/api/provider/account/__init__.py +73 -0
- pythonxbox/api/provider/account/models.py +11 -0
- pythonxbox/api/provider/achievements/__init__.py +164 -0
- pythonxbox/api/provider/achievements/models.py +133 -0
- pythonxbox/api/provider/baseprovider.py +22 -0
- pythonxbox/api/provider/catalog/__init__.py +86 -0
- pythonxbox/api/provider/catalog/const.py +15 -0
- pythonxbox/api/provider/catalog/models.py +428 -0
- pythonxbox/api/provider/cqs/__init__.py +85 -0
- pythonxbox/api/provider/cqs/models.py +59 -0
- pythonxbox/api/provider/gameclips/__init__.py +167 -0
- pythonxbox/api/provider/gameclips/models.py +58 -0
- pythonxbox/api/provider/lists/__init__.py +71 -0
- pythonxbox/api/provider/lists/models.py +33 -0
- pythonxbox/api/provider/mediahub/__init__.py +64 -0
- pythonxbox/api/provider/mediahub/models.py +82 -0
- pythonxbox/api/provider/message/__init__.py +135 -0
- pythonxbox/api/provider/message/models.py +96 -0
- pythonxbox/api/provider/people/__init__.py +193 -0
- pythonxbox/api/provider/people/models.py +252 -0
- pythonxbox/api/provider/presence/__init__.py +110 -0
- pythonxbox/api/provider/presence/models.py +53 -0
- pythonxbox/api/provider/profile/__init__.py +140 -0
- pythonxbox/api/provider/profile/models.py +47 -0
- pythonxbox/api/provider/ratelimitedprovider.py +79 -0
- pythonxbox/api/provider/screenshots/__init__.py +167 -0
- pythonxbox/api/provider/screenshots/models.py +56 -0
- pythonxbox/api/provider/smartglass/__init__.py +402 -0
- pythonxbox/api/provider/smartglass/models.py +186 -0
- pythonxbox/api/provider/titlehub/__init__.py +143 -0
- pythonxbox/api/provider/titlehub/models.py +106 -0
- pythonxbox/api/provider/usersearch/__init__.py +29 -0
- pythonxbox/api/provider/usersearch/models.py +17 -0
- pythonxbox/api/provider/userstats/__init__.py +164 -0
- pythonxbox/api/provider/userstats/models.py +44 -0
- pythonxbox/authentication/__init__.py +0 -0
- pythonxbox/authentication/manager.py +161 -0
- pythonxbox/authentication/models.py +162 -0
- pythonxbox/authentication/xal.py +348 -0
- pythonxbox/common/__init__.py +0 -0
- pythonxbox/common/exceptions.py +59 -0
- pythonxbox/common/filetimes.py +81 -0
- pythonxbox/common/models.py +34 -0
- pythonxbox/common/ratelimits/__init__.py +268 -0
- pythonxbox/common/ratelimits/models.py +23 -0
- pythonxbox/common/request_signer.py +190 -0
- pythonxbox/common/signed_session.py +60 -0
- pythonxbox/py.typed +0 -0
- pythonxbox/scripts/__init__.py +15 -0
- pythonxbox/scripts/authenticate.py +159 -0
- pythonxbox/scripts/change_gamertag.py +111 -0
- pythonxbox/scripts/friends.py +80 -0
- pythonxbox/scripts/search.py +43 -0
- pythonxbox/scripts/xal.py +113 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Titlehub - Get Title history and info
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
from pythonxbox.api.provider.baseprovider import BaseProvider
|
|
7
|
+
from pythonxbox.api.provider.titlehub.models import TitleFields, TitleHubResponse
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pythonxbox.api.client import XboxLiveClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TitlehubProvider(BaseProvider):
|
|
14
|
+
TITLEHUB_URL = "https://titlehub.xboxlive.com"
|
|
15
|
+
SEPARATOR = ","
|
|
16
|
+
|
|
17
|
+
def __init__(self, client: "XboxLiveClient") -> None:
|
|
18
|
+
"""
|
|
19
|
+
Initialize Baseclass, set 'Accept-Language' header from client instance
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
client (:class:`XboxLiveClient`): Instance of client
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(client)
|
|
25
|
+
self._headers = {
|
|
26
|
+
"x-xbl-contract-version": "2",
|
|
27
|
+
"x-xbl-client-name": "XboxApp",
|
|
28
|
+
"x-xbl-client-type": "UWA",
|
|
29
|
+
"x-xbl-client-version": "39.39.22001.0",
|
|
30
|
+
"Accept-Language": self.client.language.locale,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async def get_title_history(
|
|
34
|
+
self,
|
|
35
|
+
xuid: str,
|
|
36
|
+
fields: list[TitleFields] | None = None,
|
|
37
|
+
max_items: int | None = 5,
|
|
38
|
+
**kwargs,
|
|
39
|
+
) -> TitleHubResponse:
|
|
40
|
+
"""
|
|
41
|
+
Get recently played titles
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
xuid: Xuid
|
|
45
|
+
fields: List of titlefield
|
|
46
|
+
max_items: Maximum items
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
:class:`TitleHubResponse`: Title Hub Response
|
|
50
|
+
"""
|
|
51
|
+
if not fields:
|
|
52
|
+
fields = [
|
|
53
|
+
TitleFields.ACHIEVEMENT,
|
|
54
|
+
TitleFields.IMAGE,
|
|
55
|
+
TitleFields.SERVICE_CONFIG_ID,
|
|
56
|
+
]
|
|
57
|
+
fields = self.SEPARATOR.join(fields)
|
|
58
|
+
|
|
59
|
+
url = f"{self.TITLEHUB_URL}/users/xuid({xuid})/titles/titlehistory/decoration/{fields}"
|
|
60
|
+
params = {"maxItems": max_items}
|
|
61
|
+
resp = await self.client.session.get(
|
|
62
|
+
url, params=params, headers=self._headers, **kwargs
|
|
63
|
+
)
|
|
64
|
+
resp.raise_for_status()
|
|
65
|
+
return TitleHubResponse(**resp.json())
|
|
66
|
+
|
|
67
|
+
async def _get_title_info(
|
|
68
|
+
self, moniker: str, fields: list[TitleFields] | None = None, **kwargs
|
|
69
|
+
) -> TitleHubResponse:
|
|
70
|
+
if not fields:
|
|
71
|
+
fields = [
|
|
72
|
+
TitleFields.ACHIEVEMENT,
|
|
73
|
+
TitleFields.ALTERNATE_TITLE_ID,
|
|
74
|
+
TitleFields.DETAIL,
|
|
75
|
+
TitleFields.IMAGE,
|
|
76
|
+
TitleFields.SERVICE_CONFIG_ID,
|
|
77
|
+
]
|
|
78
|
+
fields = self.SEPARATOR.join(fields)
|
|
79
|
+
|
|
80
|
+
url = f"{self.TITLEHUB_URL}/users/xuid({self.client.xuid})/titles/{moniker}/decoration/{fields}"
|
|
81
|
+
resp = await self.client.session.get(url, headers=self._headers, **kwargs)
|
|
82
|
+
resp.raise_for_status()
|
|
83
|
+
return TitleHubResponse(**resp.json())
|
|
84
|
+
|
|
85
|
+
async def get_title_info(
|
|
86
|
+
self, title_id: str, fields: list[TitleFields] | None = None, **kwargs
|
|
87
|
+
) -> TitleHubResponse:
|
|
88
|
+
"""
|
|
89
|
+
Get info for specific title
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
title_id: Title Id
|
|
93
|
+
fields: List of title fields
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
:class:`TitleHubResponse`: Title Hub Response
|
|
97
|
+
"""
|
|
98
|
+
return await self._get_title_info(f"titleid({title_id})", fields, **kwargs)
|
|
99
|
+
|
|
100
|
+
async def get_title_info_by_pfn(
|
|
101
|
+
self, pfn: str, fields: list[TitleFields] | None = None, **kwargs
|
|
102
|
+
) -> TitleHubResponse:
|
|
103
|
+
"""
|
|
104
|
+
Get info for specific title by PFN
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
pfn: Package family name
|
|
108
|
+
fields: List of title fields
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
:class:`TitleHubResponse`: Title Hub Response
|
|
112
|
+
"""
|
|
113
|
+
return await self._get_title_info(f"pfn({pfn})", fields, **kwargs)
|
|
114
|
+
|
|
115
|
+
async def get_titles_batch(
|
|
116
|
+
self, pfns: list[str], fields: list[TitleFields] | None = None, **kwargs
|
|
117
|
+
) -> TitleHubResponse:
|
|
118
|
+
"""
|
|
119
|
+
Get Title info via PFN ids
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
pfns: List of Package family names (e.g. 'Microsoft.XboxApp_8wekyb3d8bbwe')
|
|
123
|
+
fields: List of title fields
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
:class:`TitleHubResponse`: Title Hub Response
|
|
127
|
+
"""
|
|
128
|
+
if not fields:
|
|
129
|
+
fields = [
|
|
130
|
+
TitleFields.ACHIEVEMENT,
|
|
131
|
+
TitleFields.DETAIL,
|
|
132
|
+
TitleFields.IMAGE,
|
|
133
|
+
TitleFields.SERVICE_CONFIG_ID,
|
|
134
|
+
]
|
|
135
|
+
fields = self.SEPARATOR.join(fields)
|
|
136
|
+
|
|
137
|
+
url = self.TITLEHUB_URL + f"/titles/batch/decoration/{fields}"
|
|
138
|
+
post_data = {"pfns": pfns, "windowsPhoneProductIds": []}
|
|
139
|
+
resp = await self.client.session.post(
|
|
140
|
+
url, json=post_data, headers=self._headers, **kwargs
|
|
141
|
+
)
|
|
142
|
+
resp.raise_for_status()
|
|
143
|
+
return TitleHubResponse(**resp.json())
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pythonxbox.common.models import CamelCaseModel, PascalCaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TitleFields(str, Enum):
|
|
9
|
+
SERVICE_CONFIG_ID = "scid"
|
|
10
|
+
ACHIEVEMENT = "achievement"
|
|
11
|
+
STATS = "stats"
|
|
12
|
+
GAME_PASS = "gamepass" # noqa: S105
|
|
13
|
+
IMAGE = "image"
|
|
14
|
+
DETAIL = "detail"
|
|
15
|
+
FRIENDS_WHO_PLAYED = "friendswhoplayed"
|
|
16
|
+
ALTERNATE_TITLE_ID = "alternateTitleId"
|
|
17
|
+
PRODUCT_ID = "productId"
|
|
18
|
+
CONTENT_BOARD = "contentBoard"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Achievement(CamelCaseModel):
|
|
22
|
+
current_achievements: int
|
|
23
|
+
total_achievements: int
|
|
24
|
+
current_gamerscore: int
|
|
25
|
+
total_gamerscore: int
|
|
26
|
+
progress_percentage: float
|
|
27
|
+
source_version: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Stats(CamelCaseModel):
|
|
31
|
+
source_version: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GamePass(CamelCaseModel):
|
|
35
|
+
is_game_pass: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Image(CamelCaseModel):
|
|
39
|
+
url: str
|
|
40
|
+
type: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TitleHistory(CamelCaseModel):
|
|
44
|
+
last_time_played: datetime
|
|
45
|
+
visible: bool
|
|
46
|
+
can_hide: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Attribute(CamelCaseModel):
|
|
50
|
+
applicable_platforms: list[str] | None = None
|
|
51
|
+
maximum: int | None = None
|
|
52
|
+
minimum: int | None = None
|
|
53
|
+
name: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Availability(PascalCaseModel):
|
|
57
|
+
actions: list[str]
|
|
58
|
+
availability_id: str
|
|
59
|
+
platforms: list[str]
|
|
60
|
+
sku_id: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Detail(CamelCaseModel):
|
|
64
|
+
attributes: list[Attribute]
|
|
65
|
+
availabilities: list[Availability]
|
|
66
|
+
capabilities: list[str]
|
|
67
|
+
description: str
|
|
68
|
+
developer_name: str | None = None
|
|
69
|
+
genres: list[str] | None = None
|
|
70
|
+
publisher_name: str
|
|
71
|
+
min_age: int | None = None
|
|
72
|
+
release_date: datetime | None = None
|
|
73
|
+
short_description: str | None = None
|
|
74
|
+
vui_display_name: str | None = None
|
|
75
|
+
xbox_live_gold_required: bool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Title(CamelCaseModel):
|
|
79
|
+
title_id: str
|
|
80
|
+
pfn: str | None = None
|
|
81
|
+
bing_id: str | None = None
|
|
82
|
+
service_config_id: str | None = None
|
|
83
|
+
windows_phone_product_id: str | None = None
|
|
84
|
+
name: str
|
|
85
|
+
type: str
|
|
86
|
+
devices: list[str]
|
|
87
|
+
display_image: str
|
|
88
|
+
media_item_type: str
|
|
89
|
+
modern_title_id: str | None = None
|
|
90
|
+
is_bundle: bool
|
|
91
|
+
achievement: Achievement | None = None
|
|
92
|
+
stats: Stats | None = None
|
|
93
|
+
game_pass: GamePass | None = None
|
|
94
|
+
images: list[Image] | None = None
|
|
95
|
+
title_history: TitleHistory | None = None
|
|
96
|
+
detail: Detail | None = None
|
|
97
|
+
friends_who_played: Any = None
|
|
98
|
+
alternate_title_ids: Any = None
|
|
99
|
+
content_boards: Any = None
|
|
100
|
+
xbox_live_tier: str | None = None
|
|
101
|
+
is_streamable: bool | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TitleHubResponse(CamelCaseModel):
|
|
105
|
+
xuid: str | None = None
|
|
106
|
+
titles: list[Title]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Usersearch - Search for gamertags / userprofiles
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pythonxbox.api.provider.baseprovider import BaseProvider
|
|
6
|
+
from pythonxbox.api.provider.usersearch.models import UserSearchResponse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UserSearchProvider(BaseProvider):
|
|
10
|
+
USERSEARCH_URL = "https://usersearch.xboxlive.com"
|
|
11
|
+
HEADERS_USER_SEARCH = {"x-xbl-contract-version": "1"}
|
|
12
|
+
|
|
13
|
+
async def get_live_search(self, query: str, **kwargs) -> UserSearchResponse:
|
|
14
|
+
"""
|
|
15
|
+
Get userprofiles for search query
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
query: Search query
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
:class:`UserSearchResponse`: User Search Response
|
|
22
|
+
"""
|
|
23
|
+
url = self.USERSEARCH_URL + "/suggest"
|
|
24
|
+
params = {"q": query}
|
|
25
|
+
resp = await self.client.session.get(
|
|
26
|
+
url, params=params, headers=self.HEADERS_USER_SEARCH, **kwargs
|
|
27
|
+
)
|
|
28
|
+
resp.raise_for_status()
|
|
29
|
+
return UserSearchResponse(**resp.json())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pythonxbox.common.models import CamelCaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UserDetail(CamelCaseModel):
|
|
5
|
+
id: str
|
|
6
|
+
gamertag: str
|
|
7
|
+
display_pic_uri: str
|
|
8
|
+
score: float
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UserResult(CamelCaseModel):
|
|
12
|
+
text: str
|
|
13
|
+
result: UserDetail
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UserSearchResponse(CamelCaseModel):
|
|
17
|
+
results: list[UserResult]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Userstats - Get game statistics
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pythonxbox.api.provider.ratelimitedprovider import RateLimitedProvider
|
|
6
|
+
from pythonxbox.api.provider.userstats.models import (
|
|
7
|
+
GeneralStatsField,
|
|
8
|
+
UserStatsResponse,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UserStatsProvider(RateLimitedProvider):
|
|
13
|
+
USERSTATS_URL = "https://userstats.xboxlive.com"
|
|
14
|
+
HEADERS_USERSTATS = {"x-xbl-contract-version": "2"}
|
|
15
|
+
HEADERS_USERSTATS_WITH_METADATA = {"x-xbl-contract-version": "3"}
|
|
16
|
+
SEPERATOR = ","
|
|
17
|
+
|
|
18
|
+
# NOTE: Stats Read (userstats.xboxlive.com) and Stats Write (statswrite.xboxlive.com)
|
|
19
|
+
# Are mentioned as their own objects but their rate limits are the same and do not collide
|
|
20
|
+
# (Stats Read -> read rate limit, Stats Write -> write rate limit)
|
|
21
|
+
RATE_LIMITS = {"burst": 100, "sustain": 300}
|
|
22
|
+
|
|
23
|
+
async def get_stats(
|
|
24
|
+
self,
|
|
25
|
+
xuid: str,
|
|
26
|
+
service_config_id: str,
|
|
27
|
+
stats_fields: list[GeneralStatsField] | None = None,
|
|
28
|
+
**kwargs,
|
|
29
|
+
) -> UserStatsResponse:
|
|
30
|
+
"""
|
|
31
|
+
Get userstats
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
xuid: Xbox User Id
|
|
35
|
+
service_config_id: Service Config Id of Game (scid)
|
|
36
|
+
stats_fields: List of stats fields to acquire
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
:class:`UserStatsResponse`: User Stats Response
|
|
40
|
+
"""
|
|
41
|
+
if not stats_fields:
|
|
42
|
+
stats_fields = [GeneralStatsField.MINUTES_PLAYED]
|
|
43
|
+
stats = self.SEPERATOR.join(stats_fields)
|
|
44
|
+
|
|
45
|
+
url = f"{self.USERSTATS_URL}/users/xuid({xuid})/scids/{service_config_id}/stats/{stats}"
|
|
46
|
+
resp = await self.client.session.get(
|
|
47
|
+
url,
|
|
48
|
+
headers=self.HEADERS_USERSTATS,
|
|
49
|
+
rate_limits=self.rate_limit_read,
|
|
50
|
+
**kwargs,
|
|
51
|
+
)
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
return UserStatsResponse(**resp.json())
|
|
54
|
+
|
|
55
|
+
async def get_stats_with_metadata(
|
|
56
|
+
self,
|
|
57
|
+
xuid: str,
|
|
58
|
+
service_config_id: str,
|
|
59
|
+
stats_fields: list[GeneralStatsField] | None = None,
|
|
60
|
+
**kwargs,
|
|
61
|
+
) -> UserStatsResponse:
|
|
62
|
+
"""
|
|
63
|
+
Get userstats including metadata for each stat (if available)
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
xuid: Xbox User Id
|
|
67
|
+
service_config_id: Service Config Id of Game (scid)
|
|
68
|
+
stats_fields: List of stats fields to acquire
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
:class:`UserStatsResponse`: User Stats Response
|
|
72
|
+
"""
|
|
73
|
+
if not stats_fields:
|
|
74
|
+
stats_fields = [GeneralStatsField.MINUTES_PLAYED]
|
|
75
|
+
stats = self.SEPERATOR.join(stats_fields)
|
|
76
|
+
|
|
77
|
+
url = f"{self.USERSTATS_URL}/users/xuid({xuid})/scids/{service_config_id}/stats/{stats}"
|
|
78
|
+
params = {"include": "valuemetadata"}
|
|
79
|
+
resp = await self.client.session.get(
|
|
80
|
+
url,
|
|
81
|
+
params=params,
|
|
82
|
+
headers=self.HEADERS_USERSTATS_WITH_METADATA,
|
|
83
|
+
rate_limits=self.rate_limit_read,
|
|
84
|
+
**kwargs,
|
|
85
|
+
)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
return UserStatsResponse(**resp.json())
|
|
88
|
+
|
|
89
|
+
async def get_stats_batch(
|
|
90
|
+
self,
|
|
91
|
+
xuids: list[str],
|
|
92
|
+
title_id: str,
|
|
93
|
+
stats_fields: list[GeneralStatsField] | None = None,
|
|
94
|
+
**kwargs,
|
|
95
|
+
) -> UserStatsResponse:
|
|
96
|
+
"""
|
|
97
|
+
Get userstats in batch mode
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
xuids: List of XUIDs to get stats for
|
|
101
|
+
title_id: Game Title Id
|
|
102
|
+
stats_fields: List of stats fields to acquire
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
:class:`UserStatsResponse`: User Stats Response
|
|
106
|
+
"""
|
|
107
|
+
if not stats_fields:
|
|
108
|
+
stats_fields = [GeneralStatsField.MINUTES_PLAYED]
|
|
109
|
+
|
|
110
|
+
url = self.USERSTATS_URL + "/batch"
|
|
111
|
+
post_data = {
|
|
112
|
+
"arrangebyfield": "xuid",
|
|
113
|
+
"groups": [{"name": "Hero", "titleId": title_id}],
|
|
114
|
+
"stats": [dict(name=stat, titleId=title_id) for stat in stats_fields],
|
|
115
|
+
"xuids": xuids,
|
|
116
|
+
}
|
|
117
|
+
resp = await self.client.session.post(
|
|
118
|
+
url,
|
|
119
|
+
json=post_data,
|
|
120
|
+
headers=self.HEADERS_USERSTATS,
|
|
121
|
+
rate_limits=self.rate_limit_read,
|
|
122
|
+
**kwargs,
|
|
123
|
+
)
|
|
124
|
+
resp.raise_for_status()
|
|
125
|
+
return UserStatsResponse(**resp.json())
|
|
126
|
+
|
|
127
|
+
async def get_stats_batch_by_scid(
|
|
128
|
+
self,
|
|
129
|
+
xuids: list[str],
|
|
130
|
+
service_config_id: str,
|
|
131
|
+
stats_fields: list[GeneralStatsField] | None = None,
|
|
132
|
+
**kwargs,
|
|
133
|
+
) -> UserStatsResponse:
|
|
134
|
+
"""
|
|
135
|
+
Get userstats in batch mode, via scid
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
xuids: List of XUIDs to get stats for
|
|
139
|
+
service_config_id: Service Config Id of Game (scid)
|
|
140
|
+
stats_fields: List of stats fields to acquire
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
:class:`UserStatsResponse`: User Stats Response
|
|
144
|
+
"""
|
|
145
|
+
if not stats_fields:
|
|
146
|
+
stats_fields = [GeneralStatsField.MINUTES_PLAYED]
|
|
147
|
+
|
|
148
|
+
url = self.USERSTATS_URL + "/batch"
|
|
149
|
+
|
|
150
|
+
post_data = {
|
|
151
|
+
"arrangebyfield": "xuid",
|
|
152
|
+
"groups": [{"name": "Hero", "scid": service_config_id}],
|
|
153
|
+
"stats": [dict(name=stat, scid=service_config_id) for stat in stats_fields],
|
|
154
|
+
"xuids": xuids,
|
|
155
|
+
}
|
|
156
|
+
resp = await self.client.session.post(
|
|
157
|
+
url,
|
|
158
|
+
json=post_data,
|
|
159
|
+
headers=self.HEADERS_USERSTATS,
|
|
160
|
+
rate_limits=self.rate_limit_read,
|
|
161
|
+
**kwargs,
|
|
162
|
+
)
|
|
163
|
+
resp.raise_for_status()
|
|
164
|
+
return UserStatsResponse(**resp.json())
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from pythonxbox.common.models import LowerCaseModel, PascalCaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GeneralStatsField:
|
|
5
|
+
MINUTES_PLAYED = "MinutesPlayed"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GroupProperties(PascalCaseModel):
|
|
9
|
+
ordinal: str | None = None
|
|
10
|
+
sort_order: str | None = None
|
|
11
|
+
display_name: str | None = None
|
|
12
|
+
display_format: str | None = None
|
|
13
|
+
display_semantic: str | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Properties(PascalCaseModel):
|
|
17
|
+
display_name: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Stat(LowerCaseModel):
|
|
21
|
+
group_properties: GroupProperties | None = None
|
|
22
|
+
xuid: str
|
|
23
|
+
scid: str
|
|
24
|
+
name: str
|
|
25
|
+
type: str
|
|
26
|
+
value: str
|
|
27
|
+
properties: Properties
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StatListsCollectionItem(LowerCaseModel):
|
|
31
|
+
arrange_by_field: str
|
|
32
|
+
arrange_by_field_id: str
|
|
33
|
+
stats: list[Stat]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Group(LowerCaseModel):
|
|
37
|
+
name: str
|
|
38
|
+
title_id: str | None = None
|
|
39
|
+
statlistscollection: list[StatListsCollectionItem]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UserStatsResponse(LowerCaseModel):
|
|
43
|
+
groups: list[Group] | None = None
|
|
44
|
+
statlistscollection: list[StatListsCollectionItem]
|
|
File without changes
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication Manager
|
|
3
|
+
|
|
4
|
+
Authenticate with Windows Live Server and Xbox Live.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from pythonxbox.authentication.models import (
|
|
12
|
+
OAuth2TokenResponse,
|
|
13
|
+
XAUResponse,
|
|
14
|
+
XSTSResponse,
|
|
15
|
+
)
|
|
16
|
+
from pythonxbox.common.exceptions import AuthenticationException
|
|
17
|
+
from pythonxbox.common.signed_session import SignedSession
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("authentication")
|
|
20
|
+
|
|
21
|
+
DEFAULT_SCOPES = ["Xboxlive.signin", "Xboxlive.offline_access"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthenticationManager:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
client_session: SignedSession,
|
|
28
|
+
client_id: str,
|
|
29
|
+
client_secret: str,
|
|
30
|
+
redirect_uri: str,
|
|
31
|
+
scopes: list[str] | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
if not isinstance(client_session, (SignedSession, httpx.AsyncClient)):
|
|
34
|
+
raise DeprecationWarning(
|
|
35
|
+
"""Xbox WebAPI changed to use SignedSession (wrapped httpx.AsyncClient).
|
|
36
|
+
Please check the documentation"""
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
self.session: SignedSession = client_session
|
|
40
|
+
self._client_id: str = client_id
|
|
41
|
+
self._client_secret: str = client_secret
|
|
42
|
+
self._redirect_uri: str = redirect_uri
|
|
43
|
+
self._scopes: list[str] = scopes or DEFAULT_SCOPES
|
|
44
|
+
|
|
45
|
+
self.oauth: OAuth2TokenResponse = None
|
|
46
|
+
self.user_token: XAUResponse = None
|
|
47
|
+
self.xsts_token: XSTSResponse = None
|
|
48
|
+
|
|
49
|
+
def generate_authorization_url(self, state: str | None = None) -> str:
|
|
50
|
+
"""Generate Windows Live Authorization URL."""
|
|
51
|
+
query_params = {
|
|
52
|
+
"client_id": self._client_id,
|
|
53
|
+
"response_type": "code",
|
|
54
|
+
"approval_prompt": "auto",
|
|
55
|
+
"scope": " ".join(self._scopes),
|
|
56
|
+
"redirect_uri": self._redirect_uri,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if state:
|
|
60
|
+
query_params["state"] = state
|
|
61
|
+
|
|
62
|
+
return str(
|
|
63
|
+
httpx.URL(
|
|
64
|
+
"https://login.live.com/oauth20_authorize.srf", params=query_params
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def request_tokens(self, authorization_code: str) -> None:
|
|
69
|
+
"""Request all tokens."""
|
|
70
|
+
self.oauth = await self.request_oauth_token(authorization_code)
|
|
71
|
+
self.user_token = await self.request_user_token()
|
|
72
|
+
self.xsts_token = await self.request_xsts_token()
|
|
73
|
+
|
|
74
|
+
async def refresh_tokens(self) -> None:
|
|
75
|
+
"""Refresh all tokens."""
|
|
76
|
+
if not (self.oauth and self.oauth.is_valid()):
|
|
77
|
+
self.oauth = await self.refresh_oauth_token()
|
|
78
|
+
if not (self.user_token and self.user_token.is_valid()):
|
|
79
|
+
self.user_token = await self.request_user_token()
|
|
80
|
+
if not (self.xsts_token and self.xsts_token.is_valid()):
|
|
81
|
+
self.xsts_token = await self.request_xsts_token()
|
|
82
|
+
|
|
83
|
+
async def request_oauth_token(self, authorization_code: str) -> OAuth2TokenResponse:
|
|
84
|
+
"""Request OAuth2 token."""
|
|
85
|
+
return await self._oauth2_token_request(
|
|
86
|
+
{
|
|
87
|
+
"grant_type": "authorization_code",
|
|
88
|
+
"code": authorization_code,
|
|
89
|
+
"scope": " ".join(self._scopes),
|
|
90
|
+
"redirect_uri": self._redirect_uri,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def refresh_oauth_token(self) -> OAuth2TokenResponse:
|
|
95
|
+
"""Refresh OAuth2 token."""
|
|
96
|
+
return await self._oauth2_token_request(
|
|
97
|
+
{
|
|
98
|
+
"grant_type": "refresh_token",
|
|
99
|
+
"scope": " ".join(self._scopes),
|
|
100
|
+
"refresh_token": self.oauth.refresh_token,
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def _oauth2_token_request(self, data: dict) -> OAuth2TokenResponse:
|
|
105
|
+
"""Execute token requests."""
|
|
106
|
+
data["client_id"] = self._client_id
|
|
107
|
+
if self._client_secret:
|
|
108
|
+
data["client_secret"] = self._client_secret
|
|
109
|
+
resp = await self.session.post(
|
|
110
|
+
"https://login.live.com/oauth20_token.srf", data=data
|
|
111
|
+
)
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
return OAuth2TokenResponse(**resp.json())
|
|
114
|
+
|
|
115
|
+
async def request_user_token(
|
|
116
|
+
self,
|
|
117
|
+
relying_party: str = "http://auth.xboxlive.com",
|
|
118
|
+
use_compact_ticket: bool = False,
|
|
119
|
+
) -> XAUResponse:
|
|
120
|
+
"""Authenticate via access token and receive user token."""
|
|
121
|
+
url = "https://user.auth.xboxlive.com/user/authenticate"
|
|
122
|
+
headers = {"x-xbl-contract-version": "1"}
|
|
123
|
+
data = {
|
|
124
|
+
"RelyingParty": relying_party,
|
|
125
|
+
"TokenType": "JWT",
|
|
126
|
+
"Properties": {
|
|
127
|
+
"AuthMethod": "RPS",
|
|
128
|
+
"SiteName": "user.auth.xboxlive.com",
|
|
129
|
+
"RpsTicket": self.oauth.access_token
|
|
130
|
+
if use_compact_ticket
|
|
131
|
+
else f"d={self.oauth.access_token}",
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
resp = await self.session.post(url, json=data, headers=headers)
|
|
136
|
+
resp.raise_for_status()
|
|
137
|
+
return XAUResponse(**resp.json())
|
|
138
|
+
|
|
139
|
+
async def request_xsts_token(
|
|
140
|
+
self, relying_party: str = "http://xboxlive.com"
|
|
141
|
+
) -> XSTSResponse:
|
|
142
|
+
"""Authorize via user token and receive final X token."""
|
|
143
|
+
url = "https://xsts.auth.xboxlive.com/xsts/authorize"
|
|
144
|
+
headers = {"x-xbl-contract-version": "1"}
|
|
145
|
+
data = {
|
|
146
|
+
"RelyingParty": relying_party,
|
|
147
|
+
"TokenType": "JWT",
|
|
148
|
+
"Properties": {
|
|
149
|
+
"UserTokens": [self.user_token.token],
|
|
150
|
+
"SandboxId": "RETAIL",
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
resp = await self.session.post(url, json=data, headers=headers)
|
|
155
|
+
if resp.status_code == 401: # if unauthorized
|
|
156
|
+
print(
|
|
157
|
+
"Failed to authorize you! Your password or username may be wrong or you are trying to use child account (< 18 years old)"
|
|
158
|
+
)
|
|
159
|
+
raise AuthenticationException()
|
|
160
|
+
resp.raise_for_status()
|
|
161
|
+
return XSTSResponse(**resp.json())
|