lghorizon 0.9.0.dev2__py3-none-any.whl → 0.9.0.dev4__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/__init__.py +39 -1
- lghorizon/const.py +4 -11
- lghorizon/helpers.py +1 -1
- lghorizon/lghorizon_api.py +506 -237
- lghorizon/models.py +768 -0
- {lghorizon-0.9.0.dev2.dist-info → lghorizon-0.9.0.dev4.dist-info}/METADATA +1 -1
- lghorizon-0.9.0.dev4.dist-info/RECORD +12 -0
- lghorizon/lghorizon_device.py +0 -336
- lghorizon/lghorizon_device_state_processor.py +0 -301
- lghorizon/lghorizon_message_factory.py +0 -39
- lghorizon/lghorizon_models.py +0 -1331
- lghorizon/lghorizon_mqtt_client.py +0 -123
- lghorizon/lghorizon_recording_factory.py +0 -41
- lghorizon-0.9.0.dev2.dist-info/RECORD +0 -17
- {lghorizon-0.9.0.dev2.dist-info → lghorizon-0.9.0.dev4.dist-info}/WHEEL +0 -0
- {lghorizon-0.9.0.dev2.dist-info → lghorizon-0.9.0.dev4.dist-info}/licenses/LICENSE +0 -0
- {lghorizon-0.9.0.dev2.dist-info → lghorizon-0.9.0.dev4.dist-info}/top_level.txt +0 -0
lghorizon/lghorizon_api.py
CHANGED
|
@@ -1,270 +1,539 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Python client for LGHorizon."""
|
|
2
|
+
# pylint: disable=broad-exception-caught
|
|
3
|
+
# pylint: disable=line-too-long
|
|
2
4
|
|
|
3
5
|
import logging
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
from
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
from
|
|
11
|
-
|
|
12
|
-
from .
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
from .
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from typing import Any, Callable, Dict, List
|
|
10
|
+
import backoff
|
|
11
|
+
|
|
12
|
+
from requests import Session, exceptions as request_exceptions
|
|
13
|
+
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
LGHorizonApiUnauthorizedError,
|
|
16
|
+
LGHorizonApiConnectionError,
|
|
17
|
+
LGHorizonApiLockedError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .models import (
|
|
21
|
+
LGHorizonAuth,
|
|
22
|
+
LGHorizonBox,
|
|
23
|
+
LGHorizonMqttClient,
|
|
24
|
+
LGHorizonCustomer,
|
|
25
|
+
LGHorizonChannel,
|
|
26
|
+
LGHorizonReplayEvent,
|
|
27
|
+
LGHorizonRecordingSingle,
|
|
28
|
+
LGHorizonVod,
|
|
29
|
+
LGHorizonApp,
|
|
30
|
+
LGHorizonBaseRecording,
|
|
31
|
+
LGHorizonRecordingListSeasonShow,
|
|
32
|
+
LGHorizonRecordingEpisode,
|
|
33
|
+
LGHorizonRecordingShow,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from .const import (
|
|
37
|
+
COUNTRY_SETTINGS,
|
|
38
|
+
BOX_PLAY_STATE_BUFFER,
|
|
39
|
+
BOX_PLAY_STATE_CHANNEL,
|
|
40
|
+
BOX_PLAY_STATE_DVR,
|
|
41
|
+
BOX_PLAY_STATE_REPLAY,
|
|
42
|
+
BOX_PLAY_STATE_VOD,
|
|
43
|
+
RECORDING_TYPE_SINGLE,
|
|
44
|
+
RECORDING_TYPE_SEASON,
|
|
45
|
+
RECORDING_TYPE_SHOW,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_logger = logging.getLogger(__name__)
|
|
50
|
+
_supported_platforms = ["EOS", "EOS2", "HORIZON", "APOLLO"]
|
|
24
51
|
|
|
25
52
|
|
|
26
53
|
class LGHorizonApi:
|
|
27
|
-
"""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
self
|
|
54
|
+
"""Main class for handling connections with LGHorizon Settop boxes."""
|
|
55
|
+
|
|
56
|
+
_auth: LGHorizonAuth = None
|
|
57
|
+
_session: Session = None
|
|
58
|
+
settop_boxes: Dict[str, LGHorizonBox] = None
|
|
59
|
+
customer: LGHorizonCustomer = None
|
|
60
|
+
_mqtt_client: LGHorizonMqttClient = None
|
|
61
|
+
_channels: Dict[str, LGHorizonChannel] = None
|
|
62
|
+
_country_settings = None
|
|
63
|
+
_country_code: str = None
|
|
64
|
+
recording_capacity: int = None
|
|
65
|
+
_entitlements: List[str] = None
|
|
66
|
+
_identifier: str = None
|
|
67
|
+
_config: str = None
|
|
68
|
+
_refresh_callback: Callable = None
|
|
69
|
+
_profile_id: str = None
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
username: str,
|
|
74
|
+
password: str,
|
|
75
|
+
country_code: str = "nl",
|
|
76
|
+
identifier: str = None,
|
|
77
|
+
refresh_token=None,
|
|
78
|
+
profile_id=None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Create LGHorizon API."""
|
|
81
|
+
self.username = username
|
|
82
|
+
self.password = password
|
|
83
|
+
self.refresh_token = refresh_token
|
|
84
|
+
self._session = Session()
|
|
85
|
+
self._country_settings = COUNTRY_SETTINGS[country_code]
|
|
86
|
+
self._country_code = country_code
|
|
87
|
+
self._auth = LGHorizonAuth()
|
|
88
|
+
self.settop_boxes = {}
|
|
46
89
|
self._channels = {}
|
|
47
|
-
self.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if
|
|
54
|
-
self.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
90
|
+
self._entitlements = []
|
|
91
|
+
self._identifier = identifier
|
|
92
|
+
self._profile_id = profile_id
|
|
93
|
+
|
|
94
|
+
def _authorize(self) -> None:
|
|
95
|
+
ctry_code = self._country_code[0:2]
|
|
96
|
+
if ctry_code in ("gb", "ch", "be"):
|
|
97
|
+
self._authorize_with_refresh_token()
|
|
98
|
+
else:
|
|
99
|
+
self._authorize_default()
|
|
100
|
+
|
|
101
|
+
def _authorize_default(self) -> None:
|
|
102
|
+
_logger.debug("Authorizing")
|
|
103
|
+
auth_url = f"{self._country_settings['api_url']}/auth-service/v1/authorization"
|
|
104
|
+
auth_headers = {"x-device-code": "web"}
|
|
105
|
+
auth_payload = {"password": self.password, "username": self.username}
|
|
106
|
+
try:
|
|
107
|
+
auth_response = self._session.post(
|
|
108
|
+
auth_url, headers=auth_headers, json=auth_payload
|
|
109
|
+
)
|
|
110
|
+
except Exception as ex:
|
|
111
|
+
raise LGHorizonApiConnectionError("Unknown connection failure") from ex
|
|
112
|
+
|
|
113
|
+
if not auth_response.ok:
|
|
114
|
+
error_json = auth_response.json()
|
|
115
|
+
error = error_json["error"]
|
|
116
|
+
if error and error["statusCode"] == 97401:
|
|
117
|
+
raise LGHorizonApiUnauthorizedError("Invalid credentials")
|
|
118
|
+
elif error and error["statusCode"] == 97117:
|
|
119
|
+
raise LGHorizonApiLockedError("Account locked")
|
|
120
|
+
elif error:
|
|
121
|
+
raise LGHorizonApiConnectionError(error["message"])
|
|
122
|
+
else:
|
|
123
|
+
raise LGHorizonApiConnectionError("Unknown connection error")
|
|
124
|
+
|
|
125
|
+
self._auth.fill(auth_response.json())
|
|
126
|
+
_logger.debug("Authorization succeeded")
|
|
127
|
+
|
|
128
|
+
def _authorize_with_refresh_token(self) -> None:
|
|
129
|
+
"""Handle authorizzationg using request token."""
|
|
130
|
+
_logger.debug("Authorizing via refresh")
|
|
131
|
+
refresh_url = (
|
|
132
|
+
f"{self._country_settings['api_url']}/auth-service/v1/authorization/refresh"
|
|
62
133
|
)
|
|
63
|
-
|
|
134
|
+
headers = {"content-type": "application/json", "charset": "utf-8"}
|
|
135
|
+
payload = '{"refreshToken":"' + self.refresh_token + '"}'
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
auth_response = self._session.post(
|
|
139
|
+
refresh_url, headers=headers, data=payload
|
|
140
|
+
)
|
|
141
|
+
except Exception as ex:
|
|
142
|
+
raise LGHorizonApiConnectionError("Unknown connection failure") from ex
|
|
143
|
+
|
|
144
|
+
if not auth_response.ok:
|
|
145
|
+
_logger.debug("response %s", auth_response)
|
|
146
|
+
error_json = auth_response.json()
|
|
147
|
+
error = None
|
|
148
|
+
if "error" in error_json:
|
|
149
|
+
error = error_json["error"]
|
|
150
|
+
if error and error["statusCode"] == 97401:
|
|
151
|
+
raise LGHorizonApiUnauthorizedError("Invalid credentials")
|
|
152
|
+
elif error:
|
|
153
|
+
raise LGHorizonApiConnectionError(error["message"])
|
|
154
|
+
else:
|
|
155
|
+
raise LGHorizonApiConnectionError("Unknown connection error")
|
|
156
|
+
|
|
157
|
+
self._auth.fill(auth_response.json())
|
|
158
|
+
self.refresh_token = self._auth.refresh_token
|
|
159
|
+
self._session.cookies["ACCESSTOKEN"] = self._auth.access_token
|
|
160
|
+
|
|
161
|
+
if self._refresh_callback:
|
|
162
|
+
self._refresh_callback()
|
|
163
|
+
|
|
164
|
+
_logger.debug("Authorization succeeded")
|
|
165
|
+
|
|
166
|
+
def set_callback(self, refresh_callback: Callable) -> None:
|
|
167
|
+
"""Set the refresh callback."""
|
|
168
|
+
self._refresh_callback = refresh_callback
|
|
169
|
+
|
|
170
|
+
def _authorize_telenet(self):
|
|
171
|
+
"""Authorize telenet users."""
|
|
172
|
+
try:
|
|
173
|
+
login_session = Session()
|
|
174
|
+
# Step 1 - Get Authorization data
|
|
175
|
+
_logger.debug("Step 1 - Get Authorization data")
|
|
176
|
+
auth_url = (
|
|
177
|
+
f"{self._country_settings['api_url']}/auth-service/v1/sso/authorization"
|
|
178
|
+
)
|
|
179
|
+
auth_response = login_session.get(auth_url)
|
|
180
|
+
if not auth_response.ok:
|
|
181
|
+
raise LGHorizonApiConnectionError("Can't connect to authorization URL")
|
|
182
|
+
auth_response_json = auth_response.json()
|
|
183
|
+
authorization_uri = auth_response_json["authorizationUri"]
|
|
184
|
+
authorization_validity_token = auth_response_json["validityToken"]
|
|
64
185
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if not self._initialized:
|
|
68
|
-
raise RuntimeError("LGHorizonApi not initialized")
|
|
186
|
+
# Step 2 - Get Authorization cookie
|
|
187
|
+
_logger.debug("Step 2 - Get Authorization cookie")
|
|
69
188
|
|
|
70
|
-
|
|
189
|
+
auth_cookie_response = login_session.get(authorization_uri)
|
|
190
|
+
if not auth_cookie_response.ok:
|
|
191
|
+
raise LGHorizonApiConnectionError("Can't connect to authorization URL")
|
|
71
192
|
|
|
72
|
-
|
|
73
|
-
"""Get profile IDs."""
|
|
74
|
-
if not self._initialized:
|
|
75
|
-
raise RuntimeError("LGHorizonApi not initialized")
|
|
193
|
+
_logger.debug("Step 3 - Login")
|
|
76
194
|
|
|
77
|
-
|
|
195
|
+
username_fieldname = self._country_settings["oauth_username_fieldname"]
|
|
196
|
+
pasword_fieldname = self._country_settings["oauth_password_fieldname"]
|
|
78
197
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
# Attempt to retrieve the profile by the given profile_id
|
|
84
|
-
profile = self._customer.profiles.get(profile_id)
|
|
85
|
-
|
|
86
|
-
# If the specified profile is not found, and there are other profiles available,
|
|
87
|
-
# default to the first profile in the customer's list.
|
|
88
|
-
if not profile and self._customer.profiles:
|
|
89
|
-
_LOGGER.debug(
|
|
90
|
-
"Profile with ID '%s' not found. Defaulting to first available profile.",
|
|
91
|
-
profile_id,
|
|
92
|
-
)
|
|
93
|
-
profile = list(self._customer.profiles.values())[0]
|
|
94
|
-
|
|
95
|
-
# If a profile is found and it has favorite channels, filter the main channels list.
|
|
96
|
-
if profile and profile.favorite_channels:
|
|
97
|
-
_LOGGER.debug("Returning favorite channels for profile '%s'.", profile.name)
|
|
98
|
-
# Use a set for faster lookup of favorite channel IDs
|
|
99
|
-
profile_channel_ids = set(profile.favorite_channels)
|
|
100
|
-
return {
|
|
101
|
-
channel.id: channel
|
|
102
|
-
for channel in self._channels.values()
|
|
103
|
-
if channel.id in profile_channel_ids
|
|
198
|
+
payload = {
|
|
199
|
+
username_fieldname: self.username,
|
|
200
|
+
pasword_fieldname: self.password,
|
|
201
|
+
"rememberme": "true",
|
|
104
202
|
}
|
|
105
203
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
_LOGGER.debug("No specific profile channels found, returning all channels.")
|
|
109
|
-
return self._channels
|
|
110
|
-
|
|
111
|
-
async def _register_devices(self) -> None:
|
|
112
|
-
"""Register devices."""
|
|
113
|
-
_LOGGER.debug("Registering devices...")
|
|
114
|
-
self._devices = {}
|
|
115
|
-
channels = await self.get_profile_channels(self._profile_id)
|
|
116
|
-
for raw_box in self._customer.assigned_devices:
|
|
117
|
-
_LOGGER.debug("Creating box for device: %s", raw_box)
|
|
118
|
-
if self._device_state_processor is None:
|
|
119
|
-
self._device_state_processor = LGHorizonDeviceStateProcessor(
|
|
120
|
-
self.auth, self._channels, self._customer, self._profile_id
|
|
121
|
-
)
|
|
122
|
-
device = LGHorizonDevice(
|
|
123
|
-
raw_box,
|
|
124
|
-
self._mqtt_client,
|
|
125
|
-
self._device_state_processor,
|
|
126
|
-
self.auth,
|
|
127
|
-
channels,
|
|
204
|
+
login_response = login_session.post(
|
|
205
|
+
self._country_settings["oauth_url"], payload, allow_redirects=False
|
|
128
206
|
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
207
|
+
if not login_response.ok:
|
|
208
|
+
raise LGHorizonApiConnectionError("Can't connect to authorization URL")
|
|
209
|
+
redirect_url = login_response.headers[
|
|
210
|
+
self._country_settings["oauth_redirect_header"]
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
if self._identifier is not None:
|
|
214
|
+
redirect_url += f"&dtv_identifier={self._identifier}"
|
|
215
|
+
redirect_response = login_session.get(redirect_url, allow_redirects=False)
|
|
216
|
+
success_url = redirect_response.headers[
|
|
217
|
+
self._country_settings["oauth_redirect_header"]
|
|
218
|
+
]
|
|
219
|
+
code_matches = re.findall(r"code=(.*)&", success_url)
|
|
220
|
+
|
|
221
|
+
authorization_code = code_matches[0]
|
|
222
|
+
|
|
223
|
+
new_payload = {
|
|
224
|
+
"authorizationGrant": {
|
|
225
|
+
"authorizationCode": authorization_code,
|
|
226
|
+
"validityToken": authorization_validity_token,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
headers = {
|
|
230
|
+
"content-type": "application/json",
|
|
231
|
+
}
|
|
232
|
+
post_result = login_session.post(
|
|
233
|
+
auth_url, json.dumps(new_payload), headers=headers
|
|
234
|
+
)
|
|
235
|
+
self._auth.fill(post_result.json())
|
|
236
|
+
self._session.cookies["ACCESSTOKEN"] = self._auth.access_token
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
def _obtain_mqtt_token(self):
|
|
241
|
+
_logger.debug("Obtain mqtt token...")
|
|
242
|
+
mqtt_auth_url = self._config["authorizationService"]["URL"]
|
|
243
|
+
mqtt_response = self._do_api_call(f"{mqtt_auth_url}/v1/mqtt/token")
|
|
244
|
+
self._auth.mqttToken = mqtt_response["token"]
|
|
245
|
+
_logger.debug("MQTT token: %s", self._auth.mqttToken)
|
|
246
|
+
|
|
247
|
+
@backoff.on_exception(
|
|
248
|
+
backoff.expo,
|
|
249
|
+
BaseException,
|
|
250
|
+
jitter=None,
|
|
251
|
+
max_tries=3,
|
|
252
|
+
logger=_logger,
|
|
253
|
+
giveup=lambda e: isinstance(
|
|
254
|
+
e, (LGHorizonApiLockedError, LGHorizonApiUnauthorizedError)
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
def connect(self) -> None:
|
|
258
|
+
"""Start connection process."""
|
|
259
|
+
self._config = self._get_config(self._country_code)
|
|
260
|
+
_logger.debug("Connect to API")
|
|
261
|
+
self._authorize()
|
|
262
|
+
self._obtain_mqtt_token()
|
|
263
|
+
self._mqtt_client = LGHorizonMqttClient(
|
|
264
|
+
self._auth,
|
|
265
|
+
self._config["mqttBroker"]["URL"],
|
|
140
266
|
self._on_mqtt_connected,
|
|
141
267
|
self._on_mqtt_message,
|
|
142
268
|
)
|
|
143
|
-
return mqtt_client
|
|
144
|
-
|
|
145
|
-
async def _on_mqtt_connected(self):
|
|
146
|
-
"""MQTT connected callback."""
|
|
147
|
-
await self._mqtt_client.subscribe(self.auth.household_id)
|
|
148
|
-
# await self._mqtt_client.subscribe(self.auth.household_id + "/#")
|
|
149
|
-
await self._mqtt_client.subscribe(
|
|
150
|
-
self.auth.household_id + "/" + self._mqtt_client.client_id
|
|
151
|
-
)
|
|
152
|
-
await self._mqtt_client.subscribe(self.auth.household_id + "/+/status")
|
|
153
|
-
await self._mqtt_client.subscribe(
|
|
154
|
-
self.auth.household_id + "/+/networkRecordings"
|
|
155
|
-
)
|
|
156
|
-
await self._mqtt_client.subscribe(
|
|
157
|
-
self.auth.household_id + "/+/networkRecordings/capacity"
|
|
158
|
-
)
|
|
159
|
-
await self._mqtt_client.subscribe(self.auth.household_id + "/+/localRecordings")
|
|
160
|
-
await self._mqtt_client.subscribe(
|
|
161
|
-
self.auth.household_id + "/+/localRecordings/capacity"
|
|
162
|
-
)
|
|
163
|
-
await self._mqtt_client.subscribe(self.auth.household_id + "/watchlistService")
|
|
164
|
-
await self._mqtt_client.subscribe(self.auth.household_id + "/purchaseService")
|
|
165
|
-
await self._mqtt_client.subscribe(
|
|
166
|
-
self.auth.household_id + "/personalizationService"
|
|
167
|
-
)
|
|
168
|
-
await self._mqtt_client.subscribe(self.auth.household_id + "/recordingStatus")
|
|
169
|
-
await self._mqtt_client.subscribe(
|
|
170
|
-
self.auth.household_id + "/recordingStatus/lastUserAction"
|
|
171
|
-
)
|
|
172
269
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
270
|
+
self._register_customer_and_boxes()
|
|
271
|
+
self._mqtt_client.connect()
|
|
272
|
+
|
|
273
|
+
def disconnect(self):
|
|
274
|
+
"""Disconnect."""
|
|
275
|
+
_logger.debug("Disconnect from API")
|
|
276
|
+
if not self._mqtt_client or not self._mqtt_client.is_connected:
|
|
277
|
+
return
|
|
278
|
+
self._mqtt_client.disconnect()
|
|
279
|
+
|
|
280
|
+
def _on_mqtt_connected(self) -> None:
|
|
281
|
+
_logger.debug("Connected to MQTT server. Registering all boxes...")
|
|
282
|
+
box: LGHorizonBox
|
|
283
|
+
for box in self.settop_boxes.values():
|
|
284
|
+
box.register_mqtt()
|
|
285
|
+
|
|
286
|
+
def _on_mqtt_message(self, message: str, topic: str) -> None:
|
|
287
|
+
if "action" in message and message["action"] == "OPS.getProfilesUpdate":
|
|
288
|
+
self._update_customer()
|
|
289
|
+
elif "source" in message:
|
|
290
|
+
device_id = message["source"]
|
|
291
|
+
if not isinstance(device_id, str):
|
|
292
|
+
_logger.debug("ignoring message - not a string")
|
|
293
|
+
return
|
|
294
|
+
if device_id not in self.settop_boxes:
|
|
295
|
+
return
|
|
296
|
+
try:
|
|
297
|
+
if "deviceType" in message and message["deviceType"] == "STB":
|
|
298
|
+
self.settop_boxes[device_id].update_state(message)
|
|
299
|
+
if "status" in message:
|
|
300
|
+
self._handle_box_update(device_id, message)
|
|
301
|
+
|
|
302
|
+
except Exception:
|
|
303
|
+
_logger.exception("Could not handle status message")
|
|
304
|
+
_logger.warning("Full message: %s", str(message))
|
|
305
|
+
self.settop_boxes[device_id].playing_info.reset()
|
|
306
|
+
self.settop_boxes[device_id].playing_info.set_paused(False)
|
|
307
|
+
elif "CPE.capacity" in message:
|
|
308
|
+
splitted_topic = topic.split("/")
|
|
309
|
+
if len(splitted_topic) != 4:
|
|
310
|
+
return
|
|
311
|
+
device_id = splitted_topic[1]
|
|
312
|
+
if device_id not in self.settop_boxes:
|
|
313
|
+
return
|
|
314
|
+
self.settop_boxes[device_id].update_recording_capacity(message)
|
|
315
|
+
|
|
316
|
+
def _handle_box_update(self, device_id: str, raw_message: Any) -> None:
|
|
317
|
+
status_payload = raw_message["status"]
|
|
318
|
+
if "uiStatus" not in status_payload:
|
|
319
|
+
return
|
|
320
|
+
ui_status = status_payload["uiStatus"]
|
|
321
|
+
if ui_status == "mainUI":
|
|
322
|
+
player_state = status_payload["playerState"]
|
|
323
|
+
if "sourceType" not in player_state or "source" not in player_state:
|
|
324
|
+
return
|
|
325
|
+
source_type = player_state["sourceType"]
|
|
326
|
+
state_source = player_state["source"]
|
|
327
|
+
self.settop_boxes[device_id].playing_info.set_paused(
|
|
328
|
+
player_state["speed"] == 0
|
|
329
|
+
)
|
|
330
|
+
if (
|
|
331
|
+
source_type
|
|
332
|
+
in (
|
|
333
|
+
BOX_PLAY_STATE_CHANNEL,
|
|
334
|
+
BOX_PLAY_STATE_BUFFER,
|
|
335
|
+
BOX_PLAY_STATE_REPLAY,
|
|
336
|
+
)
|
|
337
|
+
and "eventId" in state_source
|
|
338
|
+
):
|
|
339
|
+
event_id = state_source["eventId"]
|
|
340
|
+
raw_replay_event = self._do_api_call(
|
|
341
|
+
f"{self._config['linearService']['URL']}/v2/replayEvent/{event_id}?returnLinearContent=true&language={self._country_settings['language']}"
|
|
342
|
+
)
|
|
343
|
+
replay_event = LGHorizonReplayEvent(raw_replay_event)
|
|
344
|
+
channel = self._channels[replay_event.channel_id]
|
|
345
|
+
self.settop_boxes[device_id].update_with_replay_event(
|
|
346
|
+
source_type, replay_event, channel
|
|
347
|
+
)
|
|
348
|
+
elif source_type == BOX_PLAY_STATE_DVR:
|
|
349
|
+
recording_id = state_source["recordingId"]
|
|
350
|
+
session_start_time = state_source["sessionStartTime"]
|
|
351
|
+
session_end_time = state_source["sessionEndTime"]
|
|
352
|
+
last_speed_change_time = player_state["lastSpeedChangeTime"]
|
|
353
|
+
relative_position = player_state["relativePosition"]
|
|
354
|
+
raw_recording = self._do_api_call(
|
|
355
|
+
f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/details/single/{recording_id}?profileId=4504e28d-c1cb-4284-810b-f5eaab06f034&language={self._country_settings['language']}"
|
|
356
|
+
)
|
|
357
|
+
recording = LGHorizonRecordingSingle(raw_recording)
|
|
358
|
+
channel = self._channels[recording.channel_id]
|
|
359
|
+
self.settop_boxes[device_id].update_with_recording(
|
|
360
|
+
source_type,
|
|
361
|
+
recording,
|
|
362
|
+
channel,
|
|
363
|
+
session_start_time,
|
|
364
|
+
session_end_time,
|
|
365
|
+
last_speed_change_time,
|
|
366
|
+
relative_position,
|
|
367
|
+
)
|
|
368
|
+
elif source_type == BOX_PLAY_STATE_VOD:
|
|
369
|
+
title_id = state_source["titleId"]
|
|
370
|
+
last_speed_change_time = player_state["lastSpeedChangeTime"]
|
|
371
|
+
relative_position = player_state["relativePosition"]
|
|
372
|
+
raw_vod = self._do_api_call(
|
|
373
|
+
f"{self._config['vodService']['URL']}/v2/detailscreen/{title_id}?language={self._country_settings['language']}&profileId=4504e28d-c1cb-4284-810b-f5eaab06f034&cityId={self.customer.city_id}"
|
|
374
|
+
)
|
|
375
|
+
vod = LGHorizonVod(raw_vod)
|
|
376
|
+
self.settop_boxes[device_id].update_with_vod(
|
|
377
|
+
source_type, vod, last_speed_change_time, relative_position
|
|
378
|
+
)
|
|
379
|
+
elif ui_status == "apps":
|
|
380
|
+
app = LGHorizonApp(status_payload["appsState"])
|
|
381
|
+
self.settop_boxes[device_id].update_with_app("app", app)
|
|
382
|
+
|
|
383
|
+
@backoff.on_exception(
|
|
384
|
+
backoff.expo, LGHorizonApiConnectionError, max_tries=3, logger=_logger
|
|
385
|
+
)
|
|
386
|
+
def _do_api_call(self, url: str) -> str:
|
|
387
|
+
_logger.info("Executing API call to %s", url)
|
|
388
|
+
try:
|
|
389
|
+
api_response = self._session.get(url)
|
|
390
|
+
api_response.raise_for_status()
|
|
391
|
+
json_response = api_response.json()
|
|
392
|
+
except request_exceptions.HTTPError as http_ex:
|
|
393
|
+
self._authorize()
|
|
394
|
+
raise LGHorizonApiConnectionError(
|
|
395
|
+
f"Unable to call {url}. Error:{str(http_ex)}"
|
|
396
|
+
) from http_ex
|
|
397
|
+
_logger.debug("Result API call: %s", json_response)
|
|
398
|
+
return json_response
|
|
399
|
+
|
|
400
|
+
def _register_customer_and_boxes(self):
|
|
401
|
+
self._update_customer()
|
|
402
|
+
self._get_channels()
|
|
403
|
+
if len(self.customer.settop_boxes) == 0:
|
|
404
|
+
_logger.warning("No boxes found.")
|
|
405
|
+
return
|
|
406
|
+
_logger.info("Registering boxes")
|
|
407
|
+
for device in self.customer.settop_boxes:
|
|
408
|
+
platform_type = device["platformType"]
|
|
409
|
+
if platform_type not in _supported_platforms:
|
|
410
|
+
continue
|
|
411
|
+
if (
|
|
412
|
+
"platform_types" in self._country_settings
|
|
413
|
+
and platform_type in self._country_settings["platform_types"]
|
|
414
|
+
):
|
|
415
|
+
platform_type = self._country_settings["platform_types"][platform_type]
|
|
416
|
+
else:
|
|
417
|
+
platform_type = None
|
|
418
|
+
box = LGHorizonBox(
|
|
419
|
+
device, platform_type, self._mqtt_client, self._auth, self._channels
|
|
420
|
+
)
|
|
421
|
+
self.settop_boxes[box.device_id] = box
|
|
422
|
+
_logger.info("Box %s registered...", box.device_id)
|
|
423
|
+
|
|
424
|
+
def _update_customer(self):
|
|
425
|
+
_logger.info("Get customer data")
|
|
426
|
+
personalisation_result = self._do_api_call(
|
|
427
|
+
f"{self._config['personalizationService']['URL']}/v1/customer/{self._auth.household_id}?with=profiles%2Cdevices"
|
|
210
428
|
)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
service_url,
|
|
220
|
-
f"/v2/channels?cityId={self._customer.city_id}&language={lang}&productClass=Orion-DASH",
|
|
429
|
+
_logger.debug("Personalisation result: %s ", personalisation_result)
|
|
430
|
+
self.customer = LGHorizonCustomer(personalisation_result)
|
|
431
|
+
|
|
432
|
+
def _get_channels(self):
|
|
433
|
+
self._update_entitlements()
|
|
434
|
+
_logger.info("Retrieving channels...")
|
|
435
|
+
channels_result = self._do_api_call(
|
|
436
|
+
f"{self._config['linearService']['URL']}/v2/channels?cityId={self.customer.city_id}&language={self._country_settings['language']}&productClass=Orion-DASH"
|
|
221
437
|
)
|
|
222
|
-
for
|
|
223
|
-
channel
|
|
438
|
+
for channel in channels_result:
|
|
439
|
+
if "isRadio" in channel and channel["isRadio"]:
|
|
440
|
+
continue
|
|
224
441
|
common_entitlements = list(
|
|
225
|
-
set(self._entitlements
|
|
442
|
+
set(self._entitlements) & set(channel["linearProducts"])
|
|
226
443
|
)
|
|
227
|
-
|
|
228
444
|
if len(common_entitlements) == 0:
|
|
229
445
|
continue
|
|
446
|
+
channel_id = channel["id"]
|
|
447
|
+
self._channels[channel_id] = LGHorizonChannel(channel)
|
|
448
|
+
_logger.info("%s retrieved.", len(self._channels))
|
|
230
449
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
450
|
+
def get_display_channels(self):
|
|
451
|
+
"""Returns channels to display baed on profile."""
|
|
452
|
+
all_channels = self._channels.values()
|
|
453
|
+
if not self._profile_id or self._profile_id not in self.customer.profiles:
|
|
454
|
+
return all_channels
|
|
455
|
+
profile_channel_ids = self.customer.profiles[self._profile_id].favorite_channels
|
|
456
|
+
if len(profile_channel_ids) == 0:
|
|
457
|
+
return all_channels
|
|
458
|
+
|
|
459
|
+
return [
|
|
460
|
+
channel for channel in all_channels if channel.id in profile_channel_ids
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
def _get_replay_event(self, listing_id) -> Any:
|
|
464
|
+
"""Get listing."""
|
|
465
|
+
_logger.info("Retrieving replay event details...")
|
|
466
|
+
response = self._do_api_call(
|
|
467
|
+
f"{self._config['linearService']['URL']}/v2/replayEvent/{listing_id}?returnLinearContent=true&language={self._country_settings['language']}"
|
|
468
|
+
)
|
|
469
|
+
_logger.info("Replay event details retrieved")
|
|
470
|
+
return response
|
|
471
|
+
|
|
472
|
+
def get_recording_capacity(self) -> int:
|
|
473
|
+
"""Returns remaining recording capacity"""
|
|
474
|
+
ctry_code = self._country_code[0:2]
|
|
475
|
+
if ctry_code == "gb":
|
|
476
|
+
_logger.debug("GB: not supported")
|
|
477
|
+
return None
|
|
478
|
+
try:
|
|
479
|
+
_logger.info("Retrieving recordingcapacity...")
|
|
480
|
+
quota_content = self._do_api_call(
|
|
481
|
+
f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/quota"
|
|
482
|
+
)
|
|
483
|
+
if "quota" not in quota_content and "occupied" not in quota_content:
|
|
484
|
+
_logger.error("Unable to fetch recording capacity...")
|
|
485
|
+
return None
|
|
486
|
+
capacity = (quota_content["occupied"] / quota_content["quota"]) * 100
|
|
487
|
+
self.recording_capacity = round(capacity)
|
|
488
|
+
_logger.debug("Remaining recordingcapacity %s %%", self.recording_capacity)
|
|
489
|
+
return self.recording_capacity
|
|
490
|
+
except Exception:
|
|
491
|
+
_logger.error("Unable to fetch recording capacity...")
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
def get_recordings(self) -> List[LGHorizonBaseRecording]:
|
|
495
|
+
"""Returns recordings."""
|
|
496
|
+
_logger.info("Retrieving recordings...")
|
|
497
|
+
recording_content = self._do_api_call(
|
|
498
|
+
f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/recordings?sort=time&sortOrder=desc&language={self._country_settings['language']}"
|
|
241
499
|
)
|
|
242
|
-
recordings =
|
|
500
|
+
recordings = []
|
|
501
|
+
for recording_data_item in recording_content["data"]:
|
|
502
|
+
recording_type = recording_data_item["type"]
|
|
503
|
+
if recording_type == RECORDING_TYPE_SINGLE:
|
|
504
|
+
recordings.append(LGHorizonRecordingSingle(recording_data_item))
|
|
505
|
+
elif recording_type in (RECORDING_TYPE_SEASON, RECORDING_TYPE_SHOW):
|
|
506
|
+
recordings.append(LGHorizonRecordingListSeasonShow(recording_data_item))
|
|
507
|
+
_logger.info("%s recordings retrieved...", len(recordings))
|
|
243
508
|
return recordings
|
|
244
509
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
service_url = await self._service_config.get_service_url("recordingService")
|
|
251
|
-
lang = await self._customer.get_profile_lang(self._profile_id)
|
|
252
|
-
episodes_json = await self.auth.request(
|
|
253
|
-
service_url,
|
|
254
|
-
f"/customers/8436830_nl/episodes/shows/{show_id}?source=recording&isAdult=false&offset=0&limit=100&profileId={self._profile_id}&language={lang}&channelId={channel_id}&sort=time&sortOrder=asc",
|
|
510
|
+
def get_recording_show(self, show_id: str) -> list[LGHorizonRecordingSingle]:
|
|
511
|
+
"""Returns show recording"""
|
|
512
|
+
_logger.info("Retrieving show recordings...")
|
|
513
|
+
show_recording_content = self._do_api_call(
|
|
514
|
+
f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/episodes/shows/{show_id}?source=recording&language=nl&sort=time&sortOrder=asc"
|
|
255
515
|
)
|
|
256
|
-
recordings =
|
|
516
|
+
recordings = []
|
|
517
|
+
for item in show_recording_content["data"]:
|
|
518
|
+
if item["source"] == "show":
|
|
519
|
+
recordings.append(LGHorizonRecordingShow(item))
|
|
520
|
+
else:
|
|
521
|
+
recordings.append(LGHorizonRecordingEpisode(item))
|
|
522
|
+
_logger.info("%s showrecordings retrieved...", len(recordings))
|
|
257
523
|
return recordings
|
|
258
524
|
|
|
259
|
-
|
|
260
|
-
"
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
quota_json = await self.auth.request(
|
|
264
|
-
service_url,
|
|
265
|
-
f"/customers/{self.auth.household_id}/quota",
|
|
525
|
+
def _update_entitlements(self) -> None:
|
|
526
|
+
_logger.info("Retrieving entitlements...")
|
|
527
|
+
entitlements_json = self._do_api_call(
|
|
528
|
+
f"{self._config['purchaseService']['URL']}/v2/customers/{self._auth.household_id}/entitlements?enableDaypass=true"
|
|
266
529
|
)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
530
|
+
self._entitlements.clear()
|
|
531
|
+
for entitlement in entitlements_json["entitlements"]:
|
|
532
|
+
self._entitlements.append(entitlement["id"])
|
|
533
|
+
|
|
534
|
+
def _get_config(self, country_code: str):
|
|
535
|
+
base_country_code = country_code[0:2]
|
|
536
|
+
config_url = f"{self._country_settings['api_url']}/{base_country_code}/en/config-service/conf/web/backoffice.json"
|
|
537
|
+
result = self._do_api_call(config_url)
|
|
538
|
+
_logger.debug(result)
|
|
539
|
+
return result
|