lghorizon 0.6.4__py3-none-any.whl → 0.6.6__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.
- lghorizon/const.py +12 -23
- lghorizon/lghorizon_api.py +198 -113
- lghorizon/models.py +162 -97
- {lghorizon-0.6.4.dist-info → lghorizon-0.6.6.dist-info}/METADATA +1 -1
- lghorizon-0.6.6.dist-info/RECORD +12 -0
- lghorizon-0.6.4.dist-info/RECORD +0 -12
- {lghorizon-0.6.4.dist-info → lghorizon-0.6.6.dist-info}/LICENSE +0 -0
- {lghorizon-0.6.4.dist-info → lghorizon-0.6.6.dist-info}/WHEEL +0 -0
- {lghorizon-0.6.4.dist-info → lghorizon-0.6.6.dist-info}/top_level.txt +0 -0
lghorizon/const.py
CHANGED
|
@@ -40,7 +40,6 @@ BE_AUTH_URL = "https://login.prd.telenet.be/openid/login.do"
|
|
|
40
40
|
COUNTRY_SETTINGS = {
|
|
41
41
|
"nl": {
|
|
42
42
|
"api_url": "https://prod.spark.ziggogo.tv",
|
|
43
|
-
"personalization_url_format": "https://prod.spark.ziggogo.tv/nld/web/personalization-service/v1/customer/{household_id}/devices",
|
|
44
43
|
"mqtt_url": "obomsg.prod.nl.horizon.tv",
|
|
45
44
|
"use_oauth": False,
|
|
46
45
|
"channels": [
|
|
@@ -73,16 +72,12 @@ COUNTRY_SETTINGS = {
|
|
|
73
72
|
},
|
|
74
73
|
"ch": {
|
|
75
74
|
"api_url": "https://prod.spark.sunrisetv.ch",
|
|
76
|
-
"personalization_url_format": "https://prod.spark.sunrisetv.ch/eng/web/personalization-service/v1/customer/{householdId}/devices",
|
|
77
|
-
"mqtt_url": "messagebroker-prod-ch.gnp.cloud.dmdsdp.com",
|
|
78
75
|
"use_oauth": False,
|
|
79
76
|
"channels": [],
|
|
80
77
|
"language": "de",
|
|
81
78
|
},
|
|
82
79
|
"be-nl": {
|
|
83
|
-
"api_url": "https://prod.
|
|
84
|
-
"personalization_url_format": "https://prod.spark.telenettv.be/nld/web/personalization-service/v1/customer/{household_id}/devices",
|
|
85
|
-
"mqtt_url": "obomsg.prod.be.horizon.tv",
|
|
80
|
+
"api_url": "https://spark-prod-be.gnp.cloud.telenet.tv",
|
|
86
81
|
"use_oauth": True,
|
|
87
82
|
"oauth_username_fieldname": "j_username",
|
|
88
83
|
"oauth_password_fieldname": "j_password",
|
|
@@ -130,18 +125,16 @@ COUNTRY_SETTINGS = {
|
|
|
130
125
|
# "oauth_redirect_header": "Location",
|
|
131
126
|
# "channels": [],
|
|
132
127
|
# },
|
|
133
|
-
"at": {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
},
|
|
128
|
+
# "at": {
|
|
129
|
+
# "api_url": "https://prod.spark.magentatv.at",
|
|
130
|
+
# "personalization_url_format": "https://prod.spark.magentatv.at/deu/web/personalization-service/v1/customer/{householdId}/devices",
|
|
131
|
+
# "mqtt_url": "obomsg.prod.at.horizon.tv",
|
|
132
|
+
# "use_oauth": False,
|
|
133
|
+
# "channels": [],
|
|
134
|
+
# "language": "de",
|
|
135
|
+
# },
|
|
141
136
|
"gb": {
|
|
142
|
-
"api_url": "https://prod.
|
|
143
|
-
"personalization_url_format": "https://prod.spark.virginmedia.com/eng/web/personalization-service/v1/customer/{household_id}/devices",
|
|
144
|
-
"mqtt_url": "obomsg.prod.gb.horizon.tv",
|
|
137
|
+
"api_url": "https://spark-prod-gb.gnp.cloud.virgintvgo.virginmedia.com",
|
|
145
138
|
"oauth_url": "https://id.virginmedia.com/rest/v40/session/start?protocol=oidc&rememberMe=true",
|
|
146
139
|
"channels": [],
|
|
147
140
|
"oesp_url": "https://prod.oesp.virginmedia.com/oesp/v4/GB/eng/web",
|
|
@@ -161,17 +154,13 @@ COUNTRY_SETTINGS = {
|
|
|
161
154
|
# "channels": [],
|
|
162
155
|
# },
|
|
163
156
|
"ie": {
|
|
164
|
-
"api_url": "https://prod.
|
|
165
|
-
"personalization_url_format": "https://prod.spark.virginmediatv.ie/eng/web/personalization-service/v1/customer/{householdId}/devices",
|
|
166
|
-
"mqtt_url": "obomsg.prod.ie.horizon.tv",
|
|
157
|
+
"api_url": "https://spark-prod-ie.gnp.cloud.virginmediatv.ie",
|
|
167
158
|
"use_oauth": False,
|
|
168
159
|
"channels": [],
|
|
169
160
|
"language": "en",
|
|
170
161
|
},
|
|
171
162
|
"pl": {
|
|
172
|
-
"api_url": "https://prod.
|
|
173
|
-
"personalization_url_format": "https://prod.spark.upctv.pl/pol/web/personalization-service/v1/customer/{householdId}/devices",
|
|
174
|
-
"mqtt_url": "obomsg.prod.pl.horizon.tv",
|
|
163
|
+
"api_url": "https://spark-prod-pl.gnp.cloud.upctv.pl",
|
|
175
164
|
"use_oauth": False,
|
|
176
165
|
"channels": [],
|
|
177
166
|
"language": "pl",
|
lghorizon/lghorizon_api.py
CHANGED
|
@@ -8,7 +8,7 @@ from requests import Session, exceptions as request_exceptions
|
|
|
8
8
|
from paho.mqtt.client import WebsocketConnectionError
|
|
9
9
|
import re
|
|
10
10
|
from .models import (
|
|
11
|
-
LGHorizonAuth,
|
|
11
|
+
LGHorizonAuth,
|
|
12
12
|
LGHorizonBox,
|
|
13
13
|
LGHorizonMqttClient,
|
|
14
14
|
LGHorizonCustomer,
|
|
@@ -20,8 +20,8 @@ from .models import (
|
|
|
20
20
|
LGHorizonBaseRecording,
|
|
21
21
|
LGHorizonRecordingListSeasonShow,
|
|
22
22
|
LGHorizonRecordingEpisode,
|
|
23
|
-
LGHorizonRecordingShow
|
|
24
|
-
|
|
23
|
+
LGHorizonRecordingShow,
|
|
24
|
+
)
|
|
25
25
|
|
|
26
26
|
from .const import (
|
|
27
27
|
COUNTRY_SETTINGS,
|
|
@@ -32,12 +32,14 @@ from .const import (
|
|
|
32
32
|
BOX_PLAY_STATE_VOD,
|
|
33
33
|
RECORDING_TYPE_SINGLE,
|
|
34
34
|
RECORDING_TYPE_SEASON,
|
|
35
|
-
RECORDING_TYPE_SHOW
|
|
35
|
+
RECORDING_TYPE_SHOW,
|
|
36
|
+
)
|
|
36
37
|
from typing import Any, Dict, List
|
|
37
38
|
|
|
38
39
|
_logger = logging.getLogger(__name__)
|
|
39
40
|
_supported_platforms = ["EOS", "EOS2", "HORIZON", "APOLLO"]
|
|
40
41
|
|
|
42
|
+
|
|
41
43
|
class LGHorizonApi:
|
|
42
44
|
"""Main class for handling connections with LGHorizon Settop boxes."""
|
|
43
45
|
|
|
@@ -48,12 +50,19 @@ class LGHorizonApi:
|
|
|
48
50
|
_mqttClient: LGHorizonMqttClient = None
|
|
49
51
|
_channels: Dict[str, LGHorizonChannel] = None
|
|
50
52
|
_country_settings = None
|
|
51
|
-
_country_code:str = None
|
|
52
|
-
recording_capacity:int = None
|
|
53
|
-
_entitlements:List[str] = None
|
|
54
|
-
_identifier:str = None
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
_country_code: str = None
|
|
54
|
+
recording_capacity: int = None
|
|
55
|
+
_entitlements: List[str] = None
|
|
56
|
+
_identifier: str = None
|
|
57
|
+
_config: str = None
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
username: str,
|
|
62
|
+
password: str,
|
|
63
|
+
country_code: str = "nl",
|
|
64
|
+
identifier: str = None,
|
|
65
|
+
) -> None:
|
|
57
66
|
"""Create LGHorizon API."""
|
|
58
67
|
self.username = username
|
|
59
68
|
self.password = password
|
|
@@ -66,11 +75,13 @@ class LGHorizonApi:
|
|
|
66
75
|
self._entitlements = []
|
|
67
76
|
self._identifier = identifier
|
|
68
77
|
|
|
69
|
-
@backoff.on_exception(
|
|
78
|
+
@backoff.on_exception(
|
|
79
|
+
backoff.expo, LGHorizonApiConnectionError, max_tries=3, logger=_logger
|
|
80
|
+
)
|
|
70
81
|
def _authorize(self) -> None:
|
|
71
|
-
if self._country_code ==
|
|
82
|
+
if self._country_code == "be-nl":
|
|
72
83
|
self.authorize_telenet()
|
|
73
|
-
elif self._country_code ==
|
|
84
|
+
elif self._country_code == "gb":
|
|
74
85
|
self.authorize_gb()
|
|
75
86
|
else:
|
|
76
87
|
self._authorize_default()
|
|
@@ -78,18 +89,15 @@ class LGHorizonApi:
|
|
|
78
89
|
def _authorize_default(self) -> None:
|
|
79
90
|
_logger.debug("Authorizing")
|
|
80
91
|
auth_url = f"{self._country_settings['api_url']}/auth-service/v1/authorization"
|
|
81
|
-
auth_headers = {
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
auth_payload = {
|
|
85
|
-
"password": self.password,
|
|
86
|
-
"username": self.username
|
|
87
|
-
}
|
|
92
|
+
auth_headers = {"x-device-code": "web"}
|
|
93
|
+
auth_payload = {"password": self.password, "username": self.username}
|
|
88
94
|
try:
|
|
89
|
-
auth_response = self._session.post(
|
|
95
|
+
auth_response = self._session.post(
|
|
96
|
+
auth_url, headers=auth_headers, json=auth_payload
|
|
97
|
+
)
|
|
90
98
|
except Exception as ex:
|
|
91
99
|
raise LGHorizonApiConnectionError("Unknown connection failure") from ex
|
|
92
|
-
|
|
100
|
+
|
|
93
101
|
if not auth_response.ok:
|
|
94
102
|
error_json = auth_response.json()
|
|
95
103
|
error = error_json["error"]
|
|
@@ -104,10 +112,9 @@ class LGHorizonApi:
|
|
|
104
112
|
_logger.debug("Authorization succeeded")
|
|
105
113
|
|
|
106
114
|
def authorize_gb(self):
|
|
107
|
-
|
|
108
115
|
try:
|
|
109
116
|
login_session = Session()
|
|
110
|
-
|
|
117
|
+
####################################
|
|
111
118
|
_logger.debug("Step 1 - Get Authorization data")
|
|
112
119
|
auth_url = f"{self._country_settings['oesp_url']}/authorization"
|
|
113
120
|
auth_response = login_session.get(auth_url)
|
|
@@ -118,38 +125,36 @@ class LGHorizonApi:
|
|
|
118
125
|
auth_state = auth_session["state"]
|
|
119
126
|
authorizationUri = auth_session["authorizationUri"]
|
|
120
127
|
authValidityToken = auth_session["validityToken"]
|
|
121
|
-
|
|
128
|
+
####################################
|
|
122
129
|
_logger.debug("Step 2 - Get Authorization cookie")
|
|
123
130
|
|
|
124
131
|
auth_cookie_response = login_session.get(authorizationUri)
|
|
125
132
|
if not auth_cookie_response.ok:
|
|
126
133
|
raise LGHorizonApiConnectionError("Can't connect to authorization URL")
|
|
127
|
-
|
|
134
|
+
####################################
|
|
128
135
|
_logger.debug("Step 3 - Login")
|
|
129
|
-
payload = {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
headers = {
|
|
134
|
-
"accept": "application/json; charset=UTF-8, */*"
|
|
135
|
-
}
|
|
136
|
-
|
|
136
|
+
payload = {"username": self.username, "credential": self.password}
|
|
137
|
+
headers = {"accept": "application/json; charset=UTF-8, */*"}
|
|
138
|
+
|
|
137
139
|
login_response = login_session.post(
|
|
138
|
-
self._country_settings["oauth_url"],
|
|
140
|
+
self._country_settings["oauth_url"],
|
|
141
|
+
json.dumps(payload),
|
|
142
|
+
headers=headers,
|
|
143
|
+
allow_redirects=False,
|
|
139
144
|
)
|
|
140
145
|
if not login_response.ok:
|
|
141
146
|
raise LGHorizonApiConnectionError("Can't connect to authorization URL")
|
|
142
|
-
|
|
147
|
+
|
|
143
148
|
if not "x-redirect-location" in login_response.headers:
|
|
144
149
|
raise LGHorizonApiConnectionError("No redirect location in headers.")
|
|
145
|
-
|
|
150
|
+
|
|
146
151
|
redirect_url = login_response.headers["x-redirect-location"]
|
|
147
|
-
|
|
152
|
+
####################################
|
|
148
153
|
_logger.debug("Step 4 - Follow redirect")
|
|
149
154
|
redirect_response = login_session.get(redirect_url, allow_redirects=False)
|
|
150
155
|
if not "Location" in redirect_response.headers:
|
|
151
156
|
raise LGHorizonApiConnectionError("No success url in redirect.")
|
|
152
|
-
|
|
157
|
+
####################################
|
|
153
158
|
_logger.debug("Step 5 - Extract auth code")
|
|
154
159
|
success_url = redirect_response.headers["Location"]
|
|
155
160
|
codeMatches = re.findall(r"code=(.*)&", success_url)
|
|
@@ -160,36 +165,47 @@ class LGHorizonApi:
|
|
|
160
165
|
if len(codeMatches) == 0:
|
|
161
166
|
raise LGHorizonApiConnectionError("No state in redirect headers")
|
|
162
167
|
authorizationState = stateMatches[0]
|
|
163
|
-
_logger.debug(
|
|
164
|
-
|
|
168
|
+
_logger.debug(
|
|
169
|
+
f"Auth code: {authorizationCode}, Auth state: {authorizationState}"
|
|
170
|
+
)
|
|
171
|
+
####################################
|
|
165
172
|
_logger.debug("Step 6 - Post auth data with valid code")
|
|
166
173
|
authorization_payload = {
|
|
167
|
-
"authorizationGrant":{
|
|
168
|
-
"authorizationCode":authorizationCode,
|
|
169
|
-
"validityToken":authValidityToken,
|
|
170
|
-
"state": authorizationState
|
|
174
|
+
"authorizationGrant": {
|
|
175
|
+
"authorizationCode": authorizationCode,
|
|
176
|
+
"validityToken": authValidityToken,
|
|
177
|
+
"state": authorizationState,
|
|
171
178
|
}
|
|
172
179
|
}
|
|
173
180
|
headers = {
|
|
174
|
-
"content-type":"application/json",
|
|
181
|
+
"content-type": "application/json",
|
|
175
182
|
}
|
|
176
|
-
# VM requires the client to pass the response from /authorization verbatim to /session?token=true
|
|
177
|
-
post_authorization_result = login_session.post(
|
|
178
|
-
|
|
183
|
+
# VM requires the client to pass the response from /authorization verbatim to /session?token=true
|
|
184
|
+
post_authorization_result = login_session.post(
|
|
185
|
+
self._country_settings["oesp_url"] + "/authorization",
|
|
186
|
+
json.dumps(authorization_payload),
|
|
187
|
+
headers=headers,
|
|
188
|
+
)
|
|
189
|
+
post_session_result = login_session.post(
|
|
190
|
+
self._country_settings["oesp_url"] + "/session?token=true",
|
|
191
|
+
json.dumps(post_authorization_result.json()),
|
|
192
|
+
headers=headers,
|
|
193
|
+
)
|
|
179
194
|
|
|
180
195
|
self._auth.fill(post_session_result.json())
|
|
181
196
|
self._session.cookies["ACCESSTOKEN"] = self._auth.accessToken
|
|
182
|
-
|
|
197
|
+
####################################
|
|
183
198
|
except Exception as ex:
|
|
184
199
|
pass
|
|
185
200
|
|
|
186
201
|
def authorize_telenet(self):
|
|
187
|
-
|
|
188
202
|
try:
|
|
189
203
|
login_session = Session()
|
|
190
|
-
#Step 1 - Get Authorization data
|
|
204
|
+
# Step 1 - Get Authorization data
|
|
191
205
|
_logger.debug("Step 1 - Get Authorization data")
|
|
192
|
-
auth_url =
|
|
206
|
+
auth_url = (
|
|
207
|
+
f"{self._country_settings['api_url']}/auth-service/v1/sso/authorization"
|
|
208
|
+
)
|
|
193
209
|
auth_response = login_session.get(auth_url)
|
|
194
210
|
if not auth_response.ok:
|
|
195
211
|
raise LGHorizonApiConnectionError("Can't connect to authorization URL")
|
|
@@ -197,13 +213,13 @@ class LGHorizonApi:
|
|
|
197
213
|
authorizationUri = auth_response_json["authorizationUri"]
|
|
198
214
|
authValidtyToken = auth_response_json["validityToken"]
|
|
199
215
|
|
|
200
|
-
#Step 2 - Get Authorization cookie
|
|
216
|
+
# Step 2 - Get Authorization cookie
|
|
201
217
|
_logger.debug("Step 2 - Get Authorization cookie")
|
|
202
218
|
|
|
203
219
|
auth_cookie_response = login_session.get(authorizationUri)
|
|
204
220
|
if not auth_cookie_response.ok:
|
|
205
221
|
raise LGHorizonApiConnectionError("Can't connect to authorization URL")
|
|
206
|
-
|
|
222
|
+
|
|
207
223
|
_logger.debug("Step 3 - Login")
|
|
208
224
|
|
|
209
225
|
username_fieldname = self._country_settings["oauth_username_fieldname"]
|
|
@@ -212,43 +228,50 @@ class LGHorizonApi:
|
|
|
212
228
|
payload = {
|
|
213
229
|
username_fieldname: self.username,
|
|
214
230
|
pasword_fieldname: self.password,
|
|
215
|
-
"rememberme":
|
|
231
|
+
"rememberme": "true",
|
|
216
232
|
}
|
|
217
233
|
|
|
218
|
-
|
|
219
234
|
login_response = login_session.post(
|
|
220
235
|
self._country_settings["oauth_url"], payload, allow_redirects=False
|
|
221
236
|
)
|
|
222
237
|
if not login_response.ok:
|
|
223
238
|
raise LGHorizonApiConnectionError("Can't connect to authorization URL")
|
|
224
|
-
redirect_url = login_response.headers[
|
|
225
|
-
|
|
239
|
+
redirect_url = login_response.headers[
|
|
240
|
+
self._country_settings["oauth_redirect_header"]
|
|
241
|
+
]
|
|
242
|
+
|
|
226
243
|
if not self._identifier is None:
|
|
227
244
|
redirect_url += f"&dtv_identifier={self._identifier}"
|
|
228
245
|
redirect_response = login_session.get(redirect_url, allow_redirects=False)
|
|
229
|
-
success_url = redirect_response.headers[
|
|
246
|
+
success_url = redirect_response.headers[
|
|
247
|
+
self._country_settings["oauth_redirect_header"]
|
|
248
|
+
]
|
|
230
249
|
codeMatches = re.findall(r"code=(.*)&", success_url)
|
|
231
|
-
|
|
250
|
+
|
|
232
251
|
authorizationCode = codeMatches[0]
|
|
233
252
|
|
|
234
253
|
new_payload = {
|
|
235
|
-
"authorizationGrant":{
|
|
236
|
-
"authorizationCode":authorizationCode,
|
|
237
|
-
"validityToken":authValidtyToken
|
|
254
|
+
"authorizationGrant": {
|
|
255
|
+
"authorizationCode": authorizationCode,
|
|
256
|
+
"validityToken": authValidtyToken,
|
|
238
257
|
}
|
|
239
258
|
}
|
|
240
259
|
headers = {
|
|
241
|
-
"content-type":"application/json",
|
|
260
|
+
"content-type": "application/json",
|
|
242
261
|
}
|
|
243
|
-
post_result = login_session.post(
|
|
262
|
+
post_result = login_session.post(
|
|
263
|
+
auth_url, json.dumps(new_payload), headers=headers
|
|
264
|
+
)
|
|
244
265
|
self._auth.fill(post_result.json())
|
|
245
266
|
self._session.cookies["ACCESSTOKEN"] = self._auth.accessToken
|
|
246
267
|
except Exception as ex:
|
|
247
|
-
pass
|
|
268
|
+
pass
|
|
248
269
|
|
|
249
270
|
def _obtain_mqtt_token(self):
|
|
250
271
|
_logger.debug("Obtain mqtt token...")
|
|
251
|
-
mqtt_response = self._do_api_call(
|
|
272
|
+
mqtt_response = self._do_api_call(
|
|
273
|
+
f"{self._config['authorizationService']['URL']}/v1/mqtt/token"
|
|
274
|
+
)
|
|
252
275
|
self._auth.mqttToken = mqtt_response["token"]
|
|
253
276
|
_logger.debug(f"MQTT token: {self._auth.mqttToken}")
|
|
254
277
|
|
|
@@ -257,19 +280,29 @@ class LGHorizonApi:
|
|
|
257
280
|
self._session.headers["x-oesp-token"] = self._auth.accessToken
|
|
258
281
|
self._session.headers["x-oesp-username"] = self._auth.username
|
|
259
282
|
|
|
260
|
-
mqtt_response = self._do_api_call(
|
|
283
|
+
mqtt_response = self._do_api_call(
|
|
284
|
+
f"{self._country_settings['oesp_url']}/tokens/jwt"
|
|
285
|
+
)
|
|
261
286
|
self._auth.mqttToken = mqtt_response["token"]
|
|
262
287
|
_logger.debug(f"MQTT token: {self._auth.mqttToken}")
|
|
263
288
|
|
|
264
|
-
@backoff.on_exception(
|
|
289
|
+
@backoff.on_exception(
|
|
290
|
+
backoff.expo, BaseException, jitter=None, max_time=600, logger=_logger
|
|
291
|
+
)
|
|
265
292
|
def connect(self) -> None:
|
|
293
|
+
self._config = self._get_config(self._country_code)
|
|
266
294
|
_logger.debug("Connect to API")
|
|
267
295
|
self._authorize()
|
|
268
296
|
if self._country_code == "gb":
|
|
269
297
|
self._obtain_mqtt_token_gb()
|
|
270
298
|
else:
|
|
271
299
|
self._obtain_mqtt_token()
|
|
272
|
-
self._mqttClient = LGHorizonMqttClient(
|
|
300
|
+
self._mqttClient = LGHorizonMqttClient(
|
|
301
|
+
self._auth,
|
|
302
|
+
self._config["mqttBroker"]["URL"],
|
|
303
|
+
self._on_mqtt_connected,
|
|
304
|
+
self._on_mqtt_message,
|
|
305
|
+
)
|
|
273
306
|
self._register_customer_and_boxes()
|
|
274
307
|
self._mqttClient.connect()
|
|
275
308
|
|
|
@@ -282,11 +315,11 @@ class LGHorizonApi:
|
|
|
282
315
|
|
|
283
316
|
def _on_mqtt_connected(self) -> None:
|
|
284
317
|
_logger.debug("Connected to MQTT server. Registering all boxes...")
|
|
285
|
-
box:LGHorizonBox
|
|
318
|
+
box: LGHorizonBox
|
|
286
319
|
for box in self.settop_boxes.values():
|
|
287
320
|
box.register_mqtt()
|
|
288
321
|
|
|
289
|
-
def _on_mqtt_message(self, message:str, topic:str)-> None:
|
|
322
|
+
def _on_mqtt_message(self, message: str, topic: str) -> None:
|
|
290
323
|
if "source" in message:
|
|
291
324
|
deviceId = message["source"]
|
|
292
325
|
if not deviceId in self.settop_boxes.keys():
|
|
@@ -296,13 +329,13 @@ class LGHorizonApi:
|
|
|
296
329
|
self.settop_boxes[deviceId].update_state(message)
|
|
297
330
|
if "status" in message:
|
|
298
331
|
self._handle_box_update(deviceId, message)
|
|
299
|
-
except Exception
|
|
332
|
+
except Exception:
|
|
300
333
|
_logger.exception("Could not handle status message")
|
|
301
334
|
_logger.warning(f"Full message: {str(message)}")
|
|
302
335
|
self.settop_boxes[deviceId].playing_info.reset()
|
|
303
336
|
self.settop_boxes[deviceId].playing_info.set_paused(False)
|
|
304
337
|
elif "CPE.capacity" in message:
|
|
305
|
-
splitted_topic = topic.split(
|
|
338
|
+
splitted_topic = topic.split("/")
|
|
306
339
|
if len(splitted_topic) != 4:
|
|
307
340
|
return
|
|
308
341
|
deviceId = splitted_topic[1]
|
|
@@ -310,7 +343,7 @@ class LGHorizonApi:
|
|
|
310
343
|
return
|
|
311
344
|
self.settop_boxes[deviceId].update_recording_capacity(message)
|
|
312
345
|
|
|
313
|
-
def _handle_box_update(self, deviceId:str, raw_message:Any) -> None:
|
|
346
|
+
def _handle_box_update(self, deviceId: str, raw_message: Any) -> None:
|
|
314
347
|
statusPayload = raw_message["status"]
|
|
315
348
|
if "uiStatus" not in statusPayload:
|
|
316
349
|
return
|
|
@@ -321,40 +354,62 @@ class LGHorizonApi:
|
|
|
321
354
|
return
|
|
322
355
|
source_type = playerState["sourceType"]
|
|
323
356
|
state_source = playerState["source"]
|
|
324
|
-
self.settop_boxes[deviceId].playing_info.set_paused(
|
|
357
|
+
self.settop_boxes[deviceId].playing_info.set_paused(
|
|
358
|
+
playerState["speed"] == 0
|
|
359
|
+
)
|
|
325
360
|
if source_type in (
|
|
326
361
|
BOX_PLAY_STATE_CHANNEL,
|
|
327
362
|
BOX_PLAY_STATE_BUFFER,
|
|
328
|
-
BOX_PLAY_STATE_REPLAY
|
|
329
|
-
|
|
363
|
+
BOX_PLAY_STATE_REPLAY,
|
|
364
|
+
):
|
|
330
365
|
eventId = state_source["eventId"]
|
|
331
|
-
raw_replay_event = self._do_api_call(
|
|
366
|
+
raw_replay_event = self._do_api_call(
|
|
367
|
+
f"{self._config['linearService']['URL']}/v2/replayEvent/{eventId}?returnLinearContent=true&language={self._country_settings['language']}"
|
|
368
|
+
)
|
|
332
369
|
replayEvent = LGHorizonReplayEvent(raw_replay_event)
|
|
333
370
|
channel = self._channels[replayEvent.channelId]
|
|
334
|
-
self.settop_boxes[deviceId].update_with_replay_event(
|
|
371
|
+
self.settop_boxes[deviceId].update_with_replay_event(
|
|
372
|
+
source_type, replayEvent, channel
|
|
373
|
+
)
|
|
335
374
|
elif source_type == BOX_PLAY_STATE_DVR:
|
|
336
375
|
recordingId = state_source["recordingId"]
|
|
337
376
|
session_start_time = state_source["sessionStartTime"]
|
|
338
377
|
session_end_time = state_source["sessionEndTime"]
|
|
339
378
|
last_speed_change_time = playerState["lastSpeedChangeTime"]
|
|
340
379
|
relative_position = playerState["relativePosition"]
|
|
341
|
-
raw_recording = self._do_api_call(
|
|
380
|
+
raw_recording = self._do_api_call(
|
|
381
|
+
f"{self._config['recordingService']['URL']}/customers/{self._auth.householdId}/details/single/{recordingId}?profileId=4504e28d-c1cb-4284-810b-f5eaab06f034&language={self._country_settings['language']}"
|
|
382
|
+
)
|
|
342
383
|
recording = LGHorizonRecordingSingle(raw_recording)
|
|
343
384
|
channel = self._channels[recording.channelId]
|
|
344
|
-
self.settop_boxes[deviceId].update_with_recording(
|
|
385
|
+
self.settop_boxes[deviceId].update_with_recording(
|
|
386
|
+
source_type,
|
|
387
|
+
recording,
|
|
388
|
+
channel,
|
|
389
|
+
session_start_time,
|
|
390
|
+
session_end_time,
|
|
391
|
+
last_speed_change_time,
|
|
392
|
+
relative_position,
|
|
393
|
+
)
|
|
345
394
|
elif source_type == BOX_PLAY_STATE_VOD:
|
|
346
395
|
titleId = state_source["titleId"]
|
|
347
396
|
last_speed_change_time = playerState["lastSpeedChangeTime"]
|
|
348
397
|
relative_position = playerState["relativePosition"]
|
|
349
|
-
raw_vod = self._do_api_call(
|
|
398
|
+
raw_vod = self._do_api_call(
|
|
399
|
+
f"{self._config['vodService']['URL']}/v2/detailscreen/{titleId}?language={self._country_settings['language']}&profileId=4504e28d-c1cb-4284-810b-f5eaab06f034&cityId={self._customer.cityId}"
|
|
400
|
+
)
|
|
350
401
|
vod = LGHorizonVod(raw_vod)
|
|
351
|
-
self.settop_boxes[deviceId].update_with_vod(
|
|
402
|
+
self.settop_boxes[deviceId].update_with_vod(
|
|
403
|
+
source_type, vod, last_speed_change_time, relative_position
|
|
404
|
+
)
|
|
352
405
|
elif uiStatus == "apps":
|
|
353
406
|
app = LGHorizonApp(statusPayload["appsState"])
|
|
354
|
-
self.settop_boxes[deviceId].update_with_app(
|
|
407
|
+
self.settop_boxes[deviceId].update_with_app("app", app)
|
|
355
408
|
|
|
356
|
-
@backoff.on_exception(
|
|
357
|
-
|
|
409
|
+
@backoff.on_exception(
|
|
410
|
+
backoff.expo, LGHorizonApiConnectionError, max_tries=3, logger=_logger
|
|
411
|
+
)
|
|
412
|
+
def _do_api_call(self, url: str, tries: int = 0) -> str:
|
|
358
413
|
_logger.info(f"Executing API call to {url}")
|
|
359
414
|
try:
|
|
360
415
|
api_response = self._session.get(url)
|
|
@@ -362,13 +417,17 @@ class LGHorizonApi:
|
|
|
362
417
|
json_response = api_response.json()
|
|
363
418
|
except request_exceptions.HTTPError as httpEx:
|
|
364
419
|
self._authorize()
|
|
365
|
-
raise LGHorizonApiConnectionError(
|
|
420
|
+
raise LGHorizonApiConnectionError(
|
|
421
|
+
f"Unable to call {url}. Error:{str(httpEx)}"
|
|
422
|
+
)
|
|
366
423
|
_logger.debug(f"Result API call: {json_response}")
|
|
367
424
|
return json_response
|
|
368
|
-
|
|
425
|
+
|
|
369
426
|
def _register_customer_and_boxes(self):
|
|
370
427
|
_logger.info("Get personalisation info...")
|
|
371
|
-
personalisation_result = self._do_api_call(
|
|
428
|
+
personalisation_result = self._do_api_call(
|
|
429
|
+
f"{self._config['personalizationService']['URL']}/v1/customer/{self._auth.householdId}?with=profiles%2Cdevices"
|
|
430
|
+
)
|
|
372
431
|
_logger.debug(f"Personalisation result: {personalisation_result}")
|
|
373
432
|
self._customer = LGHorizonCustomer(personalisation_result)
|
|
374
433
|
self._get_channels()
|
|
@@ -380,79 +439,105 @@ class LGHorizonApi:
|
|
|
380
439
|
platform_type = device["platformType"]
|
|
381
440
|
if not platform_type in _supported_platforms:
|
|
382
441
|
continue
|
|
383
|
-
if
|
|
442
|
+
if (
|
|
443
|
+
"platform_types" in self._country_settings
|
|
444
|
+
and platform_type in self._country_settings["platform_types"]
|
|
445
|
+
):
|
|
384
446
|
platformType = self._country_settings["platform_types"][platform_type]
|
|
385
447
|
else:
|
|
386
448
|
platformType = None
|
|
387
|
-
box = LGHorizonBox(
|
|
449
|
+
box = LGHorizonBox(
|
|
450
|
+
device, platformType, self._mqttClient, self._auth, self._channels
|
|
451
|
+
)
|
|
388
452
|
self.settop_boxes[box.deviceId] = box
|
|
389
453
|
_logger.info(f"Box {box.deviceId} registered...")
|
|
390
|
-
|
|
454
|
+
|
|
391
455
|
def _get_channels(self):
|
|
392
456
|
self._update_entitlements()
|
|
393
457
|
_logger.info("Retrieving channels...")
|
|
394
|
-
channels_result = self._do_api_call(
|
|
458
|
+
channels_result = self._do_api_call(
|
|
459
|
+
f"{self._config['linearService']['URL']}/v2/channels?cityId={self._customer.cityId}&language={self._country_settings['language']}&productClass=Orion-DASH"
|
|
460
|
+
)
|
|
395
461
|
for channel in channels_result:
|
|
396
462
|
if "isRadio" in channel and channel["isRadio"]:
|
|
397
463
|
continue
|
|
398
|
-
common_entitlements = list(
|
|
464
|
+
common_entitlements = list(
|
|
465
|
+
set(self._entitlements) & set(channel["linearProducts"])
|
|
466
|
+
)
|
|
399
467
|
if len(common_entitlements) == 0:
|
|
400
468
|
continue
|
|
401
469
|
channel_id = channel["id"]
|
|
402
470
|
self._channels[channel_id] = LGHorizonChannel(channel)
|
|
403
471
|
_logger.info(f"{len(self._channels)} retrieved.")
|
|
404
472
|
|
|
405
|
-
def _get_replay_event(self, listingId) -> Any:
|
|
473
|
+
def _get_replay_event(self, listingId) -> Any:
|
|
406
474
|
"""Get listing."""
|
|
407
475
|
_logger.info("Retrieving replay event details...")
|
|
408
|
-
response = self._do_api_call(
|
|
476
|
+
response = self._do_api_call(
|
|
477
|
+
f"{self._config['linearService']['URL']}/v2/replayEvent/{listingId}?returnLinearContent=true&language={self._country_settings['language']}"
|
|
478
|
+
)
|
|
409
479
|
_logger.info("Replay event details retrieved")
|
|
410
480
|
return response
|
|
411
481
|
|
|
412
|
-
def
|
|
482
|
+
def get_recording_capacity(self) -> int:
|
|
413
483
|
"""Returns remaining recording capacity"""
|
|
414
484
|
try:
|
|
415
485
|
_logger.info("Retrieving recordingcapacity...")
|
|
416
|
-
quota_content = self._do_api_call(
|
|
486
|
+
quota_content = self._do_api_call(
|
|
487
|
+
f"{self._config['recordingService']['URL']}/customers/{self._auth.householdId}/quota"
|
|
488
|
+
)
|
|
417
489
|
if not "quota" in quota_content and not "occupied" in quota_content:
|
|
418
490
|
_logger.error("Unable to fetch recording capacity...")
|
|
419
491
|
return None
|
|
420
|
-
capacity =
|
|
492
|
+
capacity = (quota_content["occupied"] / quota_content["quota"]) * 100
|
|
421
493
|
self.recording_capacity = round(capacity)
|
|
422
494
|
_logger.debug(f"Remaining recordingcapacity {self.recording_capacity}%")
|
|
423
495
|
return self.recording_capacity
|
|
424
496
|
except:
|
|
425
497
|
_logger.error("Unable to fetch recording capacity...")
|
|
426
498
|
return None
|
|
427
|
-
|
|
499
|
+
|
|
428
500
|
def get_recordings(self) -> List[LGHorizonBaseRecording]:
|
|
429
501
|
_logger.info("Retrieving recordings...")
|
|
430
|
-
recording_content = self._do_api_call(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
502
|
+
recording_content = self._do_api_call(
|
|
503
|
+
f"{self._config['recordingService']['URL']}/customers/{self._auth.householdId}/recordings?sort=time&sortOrder=desc&language={self._country_settings['language']}"
|
|
504
|
+
)
|
|
505
|
+
recordings = []
|
|
506
|
+
for recording_data_item in recording_content["data"]:
|
|
507
|
+
type = recording_data_item["type"]
|
|
434
508
|
if type == RECORDING_TYPE_SINGLE:
|
|
435
509
|
recordings.append(LGHorizonRecordingSingle(recording_data_item))
|
|
436
510
|
elif type in (RECORDING_TYPE_SEASON, RECORDING_TYPE_SHOW):
|
|
437
511
|
recordings.append(LGHorizonRecordingListSeasonShow(recording_data_item))
|
|
438
|
-
_logger.info(
|
|
512
|
+
_logger.info(f"{len(recordings)} recordings retrieved...")
|
|
439
513
|
return recordings
|
|
440
514
|
|
|
441
|
-
def get_recording_show(self, showId:str) -> list[LGHorizonRecordingSingle]:
|
|
515
|
+
def get_recording_show(self, showId: str) -> list[LGHorizonRecordingSingle]:
|
|
442
516
|
_logger.info("Retrieving show recordings...")
|
|
443
|
-
show_recording_content = self._do_api_call(
|
|
517
|
+
show_recording_content = self._do_api_call(
|
|
518
|
+
f"{self._config['recordingService']['URL']}/customers/{self._auth.householdId}/episodes/shows/{showId}?source=recording&language=nl&sort=time&sortOrder=asc"
|
|
519
|
+
)
|
|
444
520
|
recordings = []
|
|
445
521
|
for item in show_recording_content["data"]:
|
|
446
|
-
if item[
|
|
522
|
+
if item["source"] == "show":
|
|
447
523
|
recordings.append(LGHorizonRecordingShow(item))
|
|
448
524
|
else:
|
|
449
525
|
recordings.append(LGHorizonRecordingEpisode(item))
|
|
450
|
-
_logger.info(
|
|
526
|
+
_logger.info(f"{len(recordings)} showrecordings retrieved...")
|
|
451
527
|
return recordings
|
|
452
|
-
|
|
528
|
+
|
|
453
529
|
def _update_entitlements(self) -> None:
|
|
454
530
|
_logger.info("Retrieving entitlements...")
|
|
455
|
-
entitlements_json = self._do_api_call(
|
|
531
|
+
entitlements_json = self._do_api_call(
|
|
532
|
+
f"{self._config['purchaseService']['URL']}/v2/customers/{self._auth.householdId}/entitlements?enableDaypass=true"
|
|
533
|
+
)
|
|
456
534
|
self._entitlements.clear()
|
|
457
535
|
for entitlement in entitlements_json["entitlements"]:
|
|
458
536
|
self._entitlements.append(entitlement["id"])
|
|
537
|
+
|
|
538
|
+
def _get_config(self, country_code: str):
|
|
539
|
+
ctryCode = country_code[0:2]
|
|
540
|
+
config_url = f"{self._country_settings['api_url']}/{ctryCode}/en/config-service/conf/web/backoffice.json"
|
|
541
|
+
result = self._do_api_call(config_url)
|
|
542
|
+
_logger.debug(result)
|
|
543
|
+
return result
|
lghorizon/models.py
CHANGED
|
@@ -21,7 +21,7 @@ from .const import (
|
|
|
21
21
|
MEDIA_KEY_FAST_FORWARD,
|
|
22
22
|
MEDIA_KEY_RECORD,
|
|
23
23
|
RECORDING_TYPE_SEASON,
|
|
24
|
-
RECORDING_TYPE_SHOW
|
|
24
|
+
RECORDING_TYPE_SHOW,
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
import json
|
|
@@ -30,6 +30,7 @@ import logging
|
|
|
30
30
|
|
|
31
31
|
_logger = logging.getLogger(__name__)
|
|
32
32
|
|
|
33
|
+
|
|
33
34
|
class LGHorizonAuth:
|
|
34
35
|
householdId: str
|
|
35
36
|
accessToken: str
|
|
@@ -42,22 +43,26 @@ class LGHorizonAuth:
|
|
|
42
43
|
def __init__(self):
|
|
43
44
|
"""Initialize a session."""
|
|
44
45
|
pass
|
|
45
|
-
|
|
46
|
-
def fill(self, auth_json)-> None:
|
|
46
|
+
|
|
47
|
+
def fill(self, auth_json) -> None:
|
|
47
48
|
self.householdId = auth_json["householdId"]
|
|
48
49
|
self.accessToken = auth_json["accessToken"]
|
|
49
50
|
self.refreshToken = auth_json["refreshToken"]
|
|
50
51
|
self.username = auth_json["username"]
|
|
51
52
|
try:
|
|
52
|
-
self.refreshTokenExpiry = datetime.fromtimestamp(
|
|
53
|
+
self.refreshTokenExpiry = datetime.fromtimestamp(
|
|
54
|
+
auth_json["refreshTokenExpiry"]
|
|
55
|
+
)
|
|
53
56
|
except ValueError:
|
|
54
57
|
# VM uses milliseconds for the expiry time; if the year is too high to be valid, it assumes it's milliseconds and divides it
|
|
55
|
-
self.refreshTokenExpiry = datetime.fromtimestamp(
|
|
58
|
+
self.refreshTokenExpiry = datetime.fromtimestamp(
|
|
59
|
+
auth_json["refreshTokenExpiry"] // 1000
|
|
60
|
+
)
|
|
56
61
|
|
|
57
|
-
|
|
58
62
|
def is_expired(self) -> bool:
|
|
59
63
|
return self.refreshTokenExpiry
|
|
60
64
|
|
|
65
|
+
|
|
61
66
|
class LGHorizonPlayingInfo:
|
|
62
67
|
"""Represent current state of a box."""
|
|
63
68
|
|
|
@@ -98,19 +103,19 @@ class LGHorizonPlayingInfo:
|
|
|
98
103
|
def set_source_type(self, source_type):
|
|
99
104
|
"""Set source type."""
|
|
100
105
|
self.source_type = source_type
|
|
101
|
-
|
|
106
|
+
|
|
102
107
|
def set_duration(self, duration: float):
|
|
103
108
|
"""Set duration."""
|
|
104
109
|
self.duration = duration
|
|
105
|
-
|
|
110
|
+
|
|
106
111
|
def set_position(self, position: float):
|
|
107
112
|
"""Set position."""
|
|
108
113
|
self.position = position
|
|
109
114
|
|
|
110
|
-
def set_last_position_update(self, last_position_update:datetime):
|
|
115
|
+
def set_last_position_update(self, last_position_update: datetime):
|
|
111
116
|
"""Set last position update."""
|
|
112
117
|
self.last_position_update = last_position_update
|
|
113
|
-
|
|
118
|
+
|
|
114
119
|
def reset_progress(self):
|
|
115
120
|
self.last_position_update = None
|
|
116
121
|
self.duration = None
|
|
@@ -125,6 +130,7 @@ class LGHorizonPlayingInfo:
|
|
|
125
130
|
self.channel_title = None
|
|
126
131
|
self.reset_progress()
|
|
127
132
|
|
|
133
|
+
|
|
128
134
|
class LGHorizonChannel:
|
|
129
135
|
"""Represent a channel."""
|
|
130
136
|
|
|
@@ -142,10 +148,10 @@ class LGHorizonChannel:
|
|
|
142
148
|
if "logo" in channel_json and "focused" in channel_json["logo"]:
|
|
143
149
|
self.logo_image = channel_json["logo"]["focused"]
|
|
144
150
|
else:
|
|
145
|
-
|
|
151
|
+
self.logo_image = ""
|
|
146
152
|
self.channel_number = channel_json["logicalChannelNumber"]
|
|
147
|
-
|
|
148
|
-
def get_stream_image(self, channel_json)->str:
|
|
153
|
+
|
|
154
|
+
def get_stream_image(self, channel_json) -> str:
|
|
149
155
|
image_stream = channel_json["imageStream"]
|
|
150
156
|
if "full" in image_stream:
|
|
151
157
|
return image_stream["full"]
|
|
@@ -155,16 +161,16 @@ class LGHorizonChannel:
|
|
|
155
161
|
return channel_json["logo"]["focused"]
|
|
156
162
|
return ""
|
|
157
163
|
|
|
158
|
-
class LGHorizonReplayEvent:
|
|
159
164
|
|
|
165
|
+
class LGHorizonReplayEvent:
|
|
160
166
|
episodeNumber: int = None
|
|
161
167
|
channelId: str = None
|
|
162
168
|
eventId: str = None
|
|
163
|
-
seasonNumber:int = None
|
|
164
|
-
title:str = None
|
|
165
|
-
episodeName:str = None
|
|
169
|
+
seasonNumber: int = None
|
|
170
|
+
title: str = None
|
|
171
|
+
episodeName: str = None
|
|
166
172
|
|
|
167
|
-
def __init__(self, raw_json:str):
|
|
173
|
+
def __init__(self, raw_json: str):
|
|
168
174
|
self.channelId = raw_json["channelId"]
|
|
169
175
|
self.eventId = raw_json["eventId"]
|
|
170
176
|
self.title = raw_json["title"]
|
|
@@ -175,22 +181,27 @@ class LGHorizonReplayEvent:
|
|
|
175
181
|
if "seasonNumber" in raw_json:
|
|
176
182
|
self.seasonNumber = raw_json["seasonNumber"]
|
|
177
183
|
|
|
184
|
+
|
|
178
185
|
class LGHorizonBaseRecording:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
type:str = None
|
|
186
|
+
id: str = None
|
|
187
|
+
title: str = None
|
|
188
|
+
image: str = None
|
|
189
|
+
type: str = None
|
|
184
190
|
channelId: str = None
|
|
185
|
-
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self, id: str, title: str, image: str, channelId: str, type: str
|
|
194
|
+
) -> None:
|
|
186
195
|
self.id = id
|
|
187
196
|
self.title = title
|
|
188
197
|
self.image = image
|
|
189
198
|
self.channelId = channelId
|
|
190
199
|
self.type = type
|
|
191
200
|
|
|
201
|
+
|
|
192
202
|
class LGHorizonRecordingSingle(LGHorizonBaseRecording):
|
|
193
203
|
"""Represents a single recording."""
|
|
204
|
+
|
|
194
205
|
seasonNumber: int = None
|
|
195
206
|
episodeNumber: int = None
|
|
196
207
|
|
|
@@ -202,23 +213,24 @@ class LGHorizonRecordingSingle(LGHorizonBaseRecording):
|
|
|
202
213
|
recording_json["title"],
|
|
203
214
|
recording_json["poster"]["url"],
|
|
204
215
|
recording_json["channelId"],
|
|
205
|
-
recording_json["type"]
|
|
206
|
-
|
|
216
|
+
recording_json["type"],
|
|
217
|
+
)
|
|
207
218
|
if "seasonNumber" in recording_json:
|
|
208
219
|
self.seasonNumber = recording_json["seasonNumber"]
|
|
209
220
|
if "episodeNumber" in recording_json:
|
|
210
221
|
self.episodeNumber = recording_json["episodeNumber"]
|
|
211
222
|
|
|
223
|
+
|
|
212
224
|
class LGHorizonRecordingEpisode:
|
|
213
225
|
"""Represents a single recording."""
|
|
214
|
-
|
|
226
|
+
|
|
215
227
|
episodeId: str = None
|
|
216
|
-
episodeTitle:str = None
|
|
228
|
+
episodeTitle: str = None
|
|
217
229
|
seasonNumber: int = None
|
|
218
230
|
episodeNumber: int = None
|
|
219
|
-
showTitle:str = None
|
|
220
|
-
recordingState:str = None
|
|
221
|
-
image:str = None
|
|
231
|
+
showTitle: str = None
|
|
232
|
+
recordingState: str = None
|
|
233
|
+
image: str = None
|
|
222
234
|
|
|
223
235
|
def __init__(self, recording_json):
|
|
224
236
|
"""Init the single recording."""
|
|
@@ -233,15 +245,16 @@ class LGHorizonRecordingEpisode:
|
|
|
233
245
|
if "poster" in recording_json:
|
|
234
246
|
self.image = recording_json["poster"]["url"]
|
|
235
247
|
|
|
248
|
+
|
|
236
249
|
class LGHorizonRecordingShow:
|
|
237
250
|
"""Represents a single recording."""
|
|
238
|
-
|
|
251
|
+
|
|
239
252
|
episodeId: str = None
|
|
240
|
-
showTitle:str = None
|
|
253
|
+
showTitle: str = None
|
|
241
254
|
seasonNumber: int = None
|
|
242
255
|
episodeNumber: int = None
|
|
243
|
-
recordingState:str = None
|
|
244
|
-
image:str = None
|
|
256
|
+
recordingState: str = None
|
|
257
|
+
image: str = None
|
|
245
258
|
|
|
246
259
|
def __init__(self, recording_json):
|
|
247
260
|
"""Init the single recording."""
|
|
@@ -257,55 +270,67 @@ class LGHorizonRecordingShow:
|
|
|
257
270
|
|
|
258
271
|
|
|
259
272
|
class LGHorizonRecordingListSeasonShow(LGHorizonBaseRecording):
|
|
260
|
-
showId:str = None
|
|
273
|
+
showId: str = None
|
|
274
|
+
|
|
261
275
|
def __init__(self, recording_season_json):
|
|
262
276
|
"""Init the single recording."""
|
|
263
|
-
|
|
277
|
+
|
|
264
278
|
LGHorizonBaseRecording.__init__(
|
|
265
279
|
self,
|
|
266
280
|
recording_season_json["id"],
|
|
267
281
|
recording_season_json["title"],
|
|
268
282
|
recording_season_json["poster"]["url"],
|
|
269
283
|
recording_season_json["channelId"],
|
|
270
|
-
recording_season_json["type"]
|
|
271
|
-
|
|
284
|
+
recording_season_json["type"],
|
|
285
|
+
)
|
|
272
286
|
if self.type == RECORDING_TYPE_SEASON:
|
|
273
287
|
self.showId = recording_season_json["showId"]
|
|
274
288
|
else:
|
|
275
289
|
self.showId = recording_season_json["id"]
|
|
276
290
|
|
|
291
|
+
|
|
277
292
|
class LGHorizonVod:
|
|
278
|
-
title:str = None
|
|
293
|
+
title: str = None
|
|
279
294
|
image: str = None
|
|
280
295
|
duration: float = None
|
|
296
|
+
|
|
281
297
|
def __init__(self, vod_json) -> None:
|
|
282
|
-
self.title = vod_json[
|
|
283
|
-
self.duration = vod_json[
|
|
298
|
+
self.title = vod_json["title"]
|
|
299
|
+
self.duration = vod_json["duration"]
|
|
300
|
+
|
|
284
301
|
|
|
285
302
|
class LGHorizonApp:
|
|
286
|
-
title:str = None
|
|
287
|
-
image:str = None
|
|
288
|
-
|
|
303
|
+
title: str = None
|
|
304
|
+
image: str = None
|
|
305
|
+
|
|
306
|
+
def __init__(self, app_state_json: str) -> None:
|
|
289
307
|
self.title = app_state_json["appName"]
|
|
290
308
|
self.image = app_state_json["logoPath"]
|
|
291
309
|
if not self.image.startswith("http:"):
|
|
292
|
-
|
|
310
|
+
self.image = "https:" + self.image
|
|
311
|
+
|
|
293
312
|
|
|
294
313
|
class LGHorizonMqttClient:
|
|
295
|
-
_brokerUrl:str = None
|
|
296
|
-
_mqtt_client
|
|
314
|
+
_brokerUrl: str = None
|
|
315
|
+
_mqtt_client: mqtt.Client
|
|
297
316
|
_auth: LGHorizonAuth
|
|
298
317
|
clientId: str = None
|
|
299
318
|
_on_connected_callback: Callable = None
|
|
300
|
-
_on_message_callback: Callable[[str, str],None] = None
|
|
319
|
+
_on_message_callback: Callable[[str, str], None] = None
|
|
301
320
|
|
|
302
321
|
@property
|
|
303
322
|
def is_connected(self):
|
|
304
323
|
return self._mqtt_client.is_connected
|
|
305
324
|
|
|
306
|
-
def __init__(
|
|
325
|
+
def __init__(
|
|
326
|
+
self,
|
|
327
|
+
auth: LGHorizonAuth,
|
|
328
|
+
mqtt_broker_url: str,
|
|
329
|
+
on_connected_callback: Callable = None,
|
|
330
|
+
on_message_callback: Callable[[str], None] = None,
|
|
331
|
+
):
|
|
307
332
|
self._auth = auth
|
|
308
|
-
self._brokerUrl =
|
|
333
|
+
self._brokerUrl = mqtt_broker_url.replace("wss://", "").replace(":443/mqtt", "")
|
|
309
334
|
self.clientId = make_id()
|
|
310
335
|
self._mqtt_client = mqtt.Client(self.clientId, transport="websockets")
|
|
311
336
|
self._mqtt_client.username_pw_set(self._auth.householdId, self._auth.mqttToken)
|
|
@@ -314,7 +339,7 @@ class LGHorizonMqttClient:
|
|
|
314
339
|
self._mqtt_client.on_connect = self._on_mqtt_connect
|
|
315
340
|
self._on_connected_callback = on_connected_callback
|
|
316
341
|
self._on_message_callback = on_message_callback
|
|
317
|
-
|
|
342
|
+
|
|
318
343
|
def _on_mqtt_connect(self, client, userdata, flags, resultCode):
|
|
319
344
|
if resultCode == 0:
|
|
320
345
|
self._mqtt_client.on_message = self._on_client_message
|
|
@@ -323,26 +348,38 @@ class LGHorizonMqttClient:
|
|
|
323
348
|
self._mqtt_client.subscribe(self._auth.householdId + "/" + self.clientId)
|
|
324
349
|
self._mqtt_client.subscribe(self._auth.householdId + "/+/status")
|
|
325
350
|
self._mqtt_client.subscribe(self._auth.householdId + "/+/networkRecordings")
|
|
326
|
-
self._mqtt_client.subscribe(
|
|
351
|
+
self._mqtt_client.subscribe(
|
|
352
|
+
self._auth.householdId + "/+/networkRecordings/capacity"
|
|
353
|
+
)
|
|
327
354
|
self._mqtt_client.subscribe(self._auth.householdId + "/+/localRecordings")
|
|
328
|
-
self._mqtt_client.subscribe(
|
|
355
|
+
self._mqtt_client.subscribe(
|
|
356
|
+
self._auth.householdId + "/+/localRecordings/capacity"
|
|
357
|
+
)
|
|
329
358
|
self._mqtt_client.subscribe(self._auth.householdId + "/watchlistService")
|
|
330
359
|
self._mqtt_client.subscribe(self._auth.householdId + "/purchaseService")
|
|
331
|
-
self._mqtt_client.subscribe(
|
|
360
|
+
self._mqtt_client.subscribe(
|
|
361
|
+
self._auth.householdId + "/personalizationService"
|
|
362
|
+
)
|
|
332
363
|
self._mqtt_client.subscribe(self._auth.householdId + "/recordingStatus")
|
|
333
|
-
self._mqtt_client.subscribe(
|
|
364
|
+
self._mqtt_client.subscribe(
|
|
365
|
+
self._auth.householdId + "/recordingStatus/lastUserAction"
|
|
366
|
+
)
|
|
334
367
|
if self._on_connected_callback:
|
|
335
368
|
self._on_connected_callback()
|
|
336
369
|
elif resultCode == 5:
|
|
337
|
-
self._mqtt_client.username_pw_set(
|
|
370
|
+
self._mqtt_client.username_pw_set(
|
|
371
|
+
self._auth.householdId, self._auth.mqttToken
|
|
372
|
+
)
|
|
338
373
|
self.connect()
|
|
339
374
|
else:
|
|
340
|
-
_logger.error(
|
|
341
|
-
|
|
375
|
+
_logger.error(
|
|
376
|
+
f"Cannot connect to MQTT server with resultCode: {resultCode}"
|
|
377
|
+
)
|
|
378
|
+
|
|
342
379
|
def connect(self) -> None:
|
|
343
380
|
self._mqtt_client.connect(self._brokerUrl, 443)
|
|
344
381
|
self._mqtt_client.loop_start()
|
|
345
|
-
|
|
382
|
+
|
|
346
383
|
def _on_client_message(self, client, userdata, message):
|
|
347
384
|
"""Handle messages received by mqtt client."""
|
|
348
385
|
_logger.debug(f"Received MQTT message. Topic: {message.topic}")
|
|
@@ -351,31 +388,38 @@ class LGHorizonMqttClient:
|
|
|
351
388
|
if self._on_message_callback:
|
|
352
389
|
self._on_message_callback(jsonPayload, message.topic)
|
|
353
390
|
|
|
354
|
-
def publish_message(self, topic:str, json_payload:str) -> None:
|
|
355
|
-
self._mqtt_client.publish(topic, json_payload, qos
|
|
356
|
-
|
|
391
|
+
def publish_message(self, topic: str, json_payload: str) -> None:
|
|
392
|
+
self._mqtt_client.publish(topic, json_payload, qos=2)
|
|
393
|
+
|
|
357
394
|
def disconnect(self) -> None:
|
|
358
395
|
if self._mqtt_client.is_connected:
|
|
359
396
|
self._mqtt_client.disconnect()
|
|
360
397
|
|
|
361
|
-
class LGHorizonBox:
|
|
362
398
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
399
|
+
class LGHorizonBox:
|
|
400
|
+
deviceId: str = None
|
|
401
|
+
hashedCPEId: str = None
|
|
402
|
+
deviceFriendlyName: str = None
|
|
366
403
|
state: str = None
|
|
367
404
|
playing_info: LGHorizonPlayingInfo = None
|
|
368
|
-
manufacturer:str = None
|
|
405
|
+
manufacturer: str = None
|
|
369
406
|
model: str = None
|
|
370
407
|
recording_capacity: int = None
|
|
371
|
-
|
|
372
|
-
_mqtt_client:LGHorizonMqttClient
|
|
408
|
+
|
|
409
|
+
_mqtt_client: LGHorizonMqttClient
|
|
373
410
|
_change_callback: Callable = None
|
|
374
411
|
_auth: LGHorizonAuth = None
|
|
375
|
-
_channels:Dict[str, LGHorizonChannel] = None
|
|
412
|
+
_channels: Dict[str, LGHorizonChannel] = None
|
|
376
413
|
_message_stamp = None
|
|
377
|
-
|
|
378
|
-
def __init__(
|
|
414
|
+
|
|
415
|
+
def __init__(
|
|
416
|
+
self,
|
|
417
|
+
box_json: str,
|
|
418
|
+
platform_type: Dict[str, str],
|
|
419
|
+
mqtt_client: LGHorizonMqttClient,
|
|
420
|
+
auth: LGHorizonAuth,
|
|
421
|
+
channels: Dict[str, LGHorizonChannel],
|
|
422
|
+
):
|
|
379
423
|
self.deviceId = box_json["deviceId"]
|
|
380
424
|
self.hashedCPEId = box_json["hashedCPEId"]
|
|
381
425
|
self.deviceFriendlyName = box_json["settings"]["deviceFriendlyName"]
|
|
@@ -386,8 +430,8 @@ class LGHorizonBox:
|
|
|
386
430
|
if platform_type:
|
|
387
431
|
self.manufacturer = platform_type["manufacturer"]
|
|
388
432
|
self.model = platform_type["model"]
|
|
389
|
-
|
|
390
|
-
def register_mqtt(self)->None:
|
|
433
|
+
|
|
434
|
+
def register_mqtt(self) -> None:
|
|
391
435
|
if not self._mqtt_client.is_connected:
|
|
392
436
|
raise Exception("MQTT client not connected.")
|
|
393
437
|
topic = f"{self._auth.householdId}/{self._mqtt_client.clientId}/status"
|
|
@@ -395,10 +439,10 @@ class LGHorizonBox:
|
|
|
395
439
|
"source": self._mqtt_client.clientId,
|
|
396
440
|
"state": ONLINE_RUNNING,
|
|
397
441
|
"deviceType": "HGO",
|
|
398
|
-
}
|
|
442
|
+
}
|
|
399
443
|
self._mqtt_client.publish_message(topic, json.dumps(payload))
|
|
400
|
-
|
|
401
|
-
def set_callback(self, change_callback:Callable) -> None:
|
|
444
|
+
|
|
445
|
+
def set_callback(self, change_callback: Callable) -> None:
|
|
402
446
|
self._change_callback = change_callback
|
|
403
447
|
|
|
404
448
|
def update_state(self, payload):
|
|
@@ -419,8 +463,10 @@ class LGHorizonBox:
|
|
|
419
463
|
if not "CPE.capacity" in payload or not "used" in payload:
|
|
420
464
|
return
|
|
421
465
|
self.recording_capacity = payload["used"]
|
|
422
|
-
|
|
423
|
-
def update_with_replay_event(
|
|
466
|
+
|
|
467
|
+
def update_with_replay_event(
|
|
468
|
+
self, source_type: str, event: LGHorizonReplayEvent, channel: LGHorizonChannel
|
|
469
|
+
) -> None:
|
|
424
470
|
self.playing_info.set_source_type(source_type)
|
|
425
471
|
self.playing_info.set_channel(channel.id)
|
|
426
472
|
self.playing_info.set_channel_title(channel.title)
|
|
@@ -432,7 +478,16 @@ class LGHorizonBox:
|
|
|
432
478
|
self.playing_info.reset_progress()
|
|
433
479
|
self._trigger_callback()
|
|
434
480
|
|
|
435
|
-
def update_with_recording(
|
|
481
|
+
def update_with_recording(
|
|
482
|
+
self,
|
|
483
|
+
source_type: str,
|
|
484
|
+
recording: LGHorizonRecordingSingle,
|
|
485
|
+
channel: LGHorizonChannel,
|
|
486
|
+
start: float,
|
|
487
|
+
end: float,
|
|
488
|
+
last_speed_change: float,
|
|
489
|
+
relative_position: float,
|
|
490
|
+
) -> None:
|
|
436
491
|
self.playing_info.set_source_type(source_type)
|
|
437
492
|
self.playing_info.set_channel(channel.id)
|
|
438
493
|
self.playing_info.set_channel_title(channel.title)
|
|
@@ -446,20 +501,26 @@ class LGHorizonBox:
|
|
|
446
501
|
last_update_dt = datetime.fromtimestamp(last_speed_change / 1000.0)
|
|
447
502
|
self.playing_info.set_last_position_update(last_update_dt)
|
|
448
503
|
self._trigger_callback()
|
|
449
|
-
|
|
450
|
-
def update_with_vod(
|
|
504
|
+
|
|
505
|
+
def update_with_vod(
|
|
506
|
+
self,
|
|
507
|
+
source_type: str,
|
|
508
|
+
vod: LGHorizonVod,
|
|
509
|
+
last_speed_change: float,
|
|
510
|
+
relative_position: float,
|
|
511
|
+
) -> None:
|
|
451
512
|
self.playing_info.set_source_type(source_type)
|
|
452
513
|
self.playing_info.set_channel(None)
|
|
453
514
|
self.playing_info.set_channel_title(None)
|
|
454
515
|
self.playing_info.set_title(vod.title)
|
|
455
516
|
self.playing_info.set_image(None)
|
|
456
517
|
self.playing_info.set_duration(vod.duration)
|
|
457
|
-
self.playing_info.set_position(relative_position/ 1000.0)
|
|
518
|
+
self.playing_info.set_position(relative_position / 1000.0)
|
|
458
519
|
last_update_dt = datetime.fromtimestamp(last_speed_change / 1000.0)
|
|
459
520
|
self.playing_info.set_last_position_update(last_update_dt)
|
|
460
521
|
self._trigger_callback()
|
|
461
|
-
|
|
462
|
-
def update_with_app(self, source_type: str, app:LGHorizonApp) -> None:
|
|
522
|
+
|
|
523
|
+
def update_with_app(self, source_type: str, app: LGHorizonApp) -> None:
|
|
463
524
|
self.playing_info.set_source_type(source_type)
|
|
464
525
|
self.playing_info.set_channel(None)
|
|
465
526
|
self.playing_info.set_channel_title(app.title)
|
|
@@ -467,7 +528,7 @@ class LGHorizonBox:
|
|
|
467
528
|
self.playing_info.set_image(app.image)
|
|
468
529
|
self.playing_info.reset_progress()
|
|
469
530
|
self._trigger_callback()
|
|
470
|
-
|
|
531
|
+
|
|
471
532
|
def _trigger_callback(self):
|
|
472
533
|
if self._change_callback:
|
|
473
534
|
_logger.debug(f"Callback called from box {self.deviceId}")
|
|
@@ -475,7 +536,7 @@ class LGHorizonBox:
|
|
|
475
536
|
|
|
476
537
|
def turn_on(self) -> None:
|
|
477
538
|
"""Turn the settop box on."""
|
|
478
|
-
|
|
539
|
+
|
|
479
540
|
if self.state == ONLINE_STANDBY:
|
|
480
541
|
self.send_key_to_box(MEDIA_KEY_POWER)
|
|
481
542
|
|
|
@@ -489,7 +550,7 @@ class LGHorizonBox:
|
|
|
489
550
|
"""Pause the given settopbox."""
|
|
490
551
|
if self.state == ONLINE_RUNNING and not self.playing_info.paused:
|
|
491
552
|
self.send_key_to_box(MEDIA_KEY_PLAY_PAUSE)
|
|
492
|
-
|
|
553
|
+
|
|
493
554
|
def play(self) -> None:
|
|
494
555
|
"""Resume the settopbox."""
|
|
495
556
|
if self.state == ONLINE_RUNNING and self.playing_info.paused:
|
|
@@ -534,7 +595,7 @@ class LGHorizonBox:
|
|
|
534
595
|
"""Return the availability of the settop box."""
|
|
535
596
|
return self.state == ONLINE_RUNNING or self.state == ONLINE_STANDBY
|
|
536
597
|
|
|
537
|
-
def set_channel(self, source:str) -> None:
|
|
598
|
+
def set_channel(self, source: str) -> None:
|
|
538
599
|
"""Change te channel from the settopbox."""
|
|
539
600
|
channel = [src for src in self._channels.values() if src.title == source][0]
|
|
540
601
|
payload = (
|
|
@@ -548,7 +609,9 @@ class LGHorizonBox:
|
|
|
548
609
|
+ '"},"relativePosition":0,"speed":1}}'
|
|
549
610
|
)
|
|
550
611
|
|
|
551
|
-
self._mqtt_client.publish_message(
|
|
612
|
+
self._mqtt_client.publish_message(
|
|
613
|
+
f"{self._auth.householdId}/{self.deviceId}", payload
|
|
614
|
+
)
|
|
552
615
|
|
|
553
616
|
def play_recording(self, recordingId):
|
|
554
617
|
"""Play recording."""
|
|
@@ -562,7 +625,9 @@ class LGHorizonBox:
|
|
|
562
625
|
+ recordingId
|
|
563
626
|
+ '"},"relativePosition":0}}'
|
|
564
627
|
)
|
|
565
|
-
self._mqtt_client.publish_message(
|
|
628
|
+
self._mqtt_client.publish_message(
|
|
629
|
+
f"{self._auth.householdId}/{self.deviceId}", payload
|
|
630
|
+
)
|
|
566
631
|
|
|
567
632
|
def send_key_to_box(self, key: str) -> None:
|
|
568
633
|
"""Send emulated (remote) key press to settopbox."""
|
|
@@ -571,7 +636,9 @@ class LGHorizonBox:
|
|
|
571
636
|
+ key
|
|
572
637
|
+ '","eventType":"keyDownUp"}}'
|
|
573
638
|
)
|
|
574
|
-
self._mqtt_client.publish_message(
|
|
639
|
+
self._mqtt_client.publish_message(
|
|
640
|
+
f"{self._auth.householdId}/{self.deviceId}", payload
|
|
641
|
+
)
|
|
575
642
|
|
|
576
643
|
def _set_unknown_channel_info(self) -> None:
|
|
577
644
|
"""Set unknown channel info."""
|
|
@@ -604,8 +671,8 @@ class LGHorizonBox:
|
|
|
604
671
|
|
|
605
672
|
|
|
606
673
|
class LGHorizonCustomer:
|
|
607
|
-
customerId:str = None
|
|
608
|
-
hashedCustomerId:str = None
|
|
674
|
+
customerId: str = None
|
|
675
|
+
hashedCustomerId: str = None
|
|
609
676
|
countryId: str = None
|
|
610
677
|
cityId: int = 0
|
|
611
678
|
settop_boxes: Dict[str, LGHorizonBox] = None
|
|
@@ -617,5 +684,3 @@ class LGHorizonCustomer:
|
|
|
617
684
|
self.cityId = json_payload["cityId"]
|
|
618
685
|
if not "assignedDevices" in json_payload:
|
|
619
686
|
return
|
|
620
|
-
|
|
621
|
-
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
lghorizon/__init__.py,sha256=_VjVE44ErvJJMnF5QgXdlw_nQzbHZUhGWw5hF40PolQ,426
|
|
2
|
+
lghorizon/const.py,sha256=75Qqmf6XJcBYcOya18cdeRrWq2D_1Q45tz9MLlfQ8Ao,8407
|
|
3
|
+
lghorizon/exceptions.py,sha256=spEjRvbNdce2fauQiOFromAbV1QcfA0uMUt0nRVnnkM,318
|
|
4
|
+
lghorizon/helpers.py,sha256=ZWpi7B3hBvwGV02KWQQHVyj7FLLUDtIvKc-Iqsj5VHA,263
|
|
5
|
+
lghorizon/lghorizon_api.py,sha256=1lNnN6FaPo4oV4x_SLXBA5kLnIILiNz8w3CiRko26Ao,23597
|
|
6
|
+
lghorizon/models.py,sha256=SP12O8y3Bqp-rW47TZQxp25hyptPZAy41UzKnI9lw7E,23549
|
|
7
|
+
lghorizon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
lghorizon-0.6.6.dist-info/LICENSE,sha256=6Dh2tur1gMX3r3rITjVwUONBEJxyyPZDY8p6DZXtimE,1059
|
|
9
|
+
lghorizon-0.6.6.dist-info/METADATA,sha256=76jWhjcQk9sNb4nPcQu1PYAm4b2OiOkiCADqPAHZ6bo,1038
|
|
10
|
+
lghorizon-0.6.6.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
11
|
+
lghorizon-0.6.6.dist-info/top_level.txt,sha256=usii76_AxGfPI6gjrrh-NyZxcQQuF1B8_Q9kd7sID8Q,10
|
|
12
|
+
lghorizon-0.6.6.dist-info/RECORD,,
|
lghorizon-0.6.4.dist-info/RECORD
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
lghorizon/__init__.py,sha256=_VjVE44ErvJJMnF5QgXdlw_nQzbHZUhGWw5hF40PolQ,426
|
|
2
|
-
lghorizon/const.py,sha256=nHmQqbRnKegPXCI2E3gs5nKvbPR3Mp-ptIZYcSMjmoQ,9429
|
|
3
|
-
lghorizon/exceptions.py,sha256=spEjRvbNdce2fauQiOFromAbV1QcfA0uMUt0nRVnnkM,318
|
|
4
|
-
lghorizon/helpers.py,sha256=ZWpi7B3hBvwGV02KWQQHVyj7FLLUDtIvKc-Iqsj5VHA,263
|
|
5
|
-
lghorizon/lghorizon_api.py,sha256=o1EB22gYtnFrt5hTw3V5zIqOq7dyUHkg1MWjZO92Fcc,22402
|
|
6
|
-
lghorizon/models.py,sha256=dvlMrrUHoLiT6zAydYnPGNMapFiZaOgDBrZyocrd4II,23028
|
|
7
|
-
lghorizon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
lghorizon-0.6.4.dist-info/LICENSE,sha256=6Dh2tur1gMX3r3rITjVwUONBEJxyyPZDY8p6DZXtimE,1059
|
|
9
|
-
lghorizon-0.6.4.dist-info/METADATA,sha256=KFX8-j7IgKZTTQp4as72EjDNabnh3filMrJNi5CnEZE,1038
|
|
10
|
-
lghorizon-0.6.4.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
11
|
-
lghorizon-0.6.4.dist-info/top_level.txt,sha256=usii76_AxGfPI6gjrrh-NyZxcQQuF1B8_Q9kd7sID8Q,10
|
|
12
|
-
lghorizon-0.6.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|