lghorizon 0.6.13__tar.gz → 0.9.0b0__tar.gz

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.
Files changed (42) hide show
  1. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/.github/workflows/build-on-pr.yml +2 -2
  2. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/.github/workflows/publish-to-pypi.yml +4 -4
  3. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/.gitignore +1 -0
  4. lghorizon-0.9.0b0/.vscode/launch.json +15 -0
  5. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/PKG-INFO +15 -3
  6. lghorizon-0.9.0b0/lghorizon/__init__.py +6 -0
  7. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/lghorizon/const.py +20 -8
  8. lghorizon-0.9.0b0/lghorizon/exceptions.py +17 -0
  9. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/lghorizon/helpers.py +3 -2
  10. lghorizon-0.9.0b0/lghorizon/legacy/lghorizon_api.py +469 -0
  11. {lghorizon-0.6.13/lghorizon → lghorizon-0.9.0b0/lghorizon/legacy}/models.py +228 -153
  12. lghorizon-0.9.0b0/lghorizon/lghorizon_api.py +270 -0
  13. lghorizon-0.9.0b0/lghorizon/lghorizon_device.py +336 -0
  14. lghorizon-0.9.0b0/lghorizon/lghorizon_device_state_processor.py +301 -0
  15. lghorizon-0.9.0b0/lghorizon/lghorizon_message_factory.py +39 -0
  16. lghorizon-0.9.0b0/lghorizon/lghorizon_models.py +1331 -0
  17. lghorizon-0.9.0b0/lghorizon/lghorizon_mqtt_client.py +123 -0
  18. lghorizon-0.9.0b0/lghorizon/lghorizon_recording_factory.py +41 -0
  19. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/lghorizon.egg-info/PKG-INFO +15 -3
  20. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/lghorizon.egg-info/SOURCES.txt +11 -3
  21. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/lghorizon.egg-info/requires.txt +1 -1
  22. lghorizon-0.9.0b0/main.py +94 -0
  23. lghorizon-0.9.0b0/secrets_stub.json +7 -0
  24. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/setup.py +1 -1
  25. lghorizon-0.6.13/lghorizon/__init__.py +0 -5
  26. lghorizon-0.6.13/lghorizon/exceptions.py +0 -11
  27. lghorizon-0.6.13/lghorizon/lghorizon_api.py +0 -548
  28. lghorizon-0.6.13/secrets_stub.json +0 -5
  29. lghorizon-0.6.13/test.py +0 -68
  30. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/.coverage +0 -0
  31. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/.flake8 +0 -0
  32. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/LICENSE +0 -0
  33. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/README.md +0 -0
  34. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/instructions.txt +0 -0
  35. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/lghorizon/py.typed +0 -0
  36. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/lghorizon.egg-info/dependency_links.txt +0 -0
  37. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/lghorizon.egg-info/not-zip-safe +0 -0
  38. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/lghorizon.egg-info/top_level.txt +0 -0
  39. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/lib64 +0 -0
  40. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/pyvenv.cfg +0 -0
  41. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/renovate.json +0 -0
  42. {lghorizon-0.6.13 → lghorizon-0.9.0b0}/setup.cfg +0 -0
@@ -7,13 +7,13 @@ on:
7
7
  jobs:
8
8
  build:
9
9
  name: Build Python 🐍 distribution 📦
10
- runs-on: ubuntu-22.04
10
+ runs-on: ubuntu-24.04
11
11
  steps:
12
12
  - uses: actions/checkout@master
13
13
  - name: Set up Python 3.10
14
14
  uses: actions/setup-python@v5
15
15
  with:
16
- python-version: '3.10'
16
+ python-version: '3.13'
17
17
  - name: Install pypa/build
18
18
  run: >-
19
19
  python -m
@@ -6,13 +6,15 @@ on:
6
6
  jobs:
7
7
  build-n-publish:
8
8
  name: Publish Python 🐍 distribution 📦 to Pypi
9
- runs-on: ubuntu-22.04
9
+ runs-on: ubuntu-24.04
10
+ permissions:
11
+ id-token: write
10
12
  steps:
11
13
  - uses: actions/checkout@master
12
14
  - name: Set up Python 3.10
13
15
  uses: actions/setup-python@v5
14
16
  with:
15
- python-version: '3.10'
17
+ python-version: '3.13'
16
18
 
17
19
  - name: Install pypa/build
18
20
  run: >-
@@ -29,5 +31,3 @@ jobs:
29
31
  --outdir dist/
30
32
  - name: Publish distribution 📦 to PyPI
31
33
  uses: pypa/gh-action-pypi-publish@release/v1
32
- with:
33
- password: ${{ secrets.PYPI_API_TOKEN }}
@@ -24,3 +24,4 @@ __pycache__/*
24
24
  test/nl.py
25
25
  secrets.json
26
26
  logfile.log
27
+ lghorizon.log
@@ -0,0 +1,15 @@
1
+ {
2
+ // Use IntelliSense to learn about possible attributes.
3
+ // Hover to view descriptions of existing attributes.
4
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5
+ "version": "0.2.0",
6
+ "configurations": [
7
+ {
8
+ "name": "Python Debugger: Debug LGHorizon",
9
+ "type": "debugpy",
10
+ "request": "launch",
11
+ "program": "main.py",
12
+ "console": "integratedTerminal"
13
+ }
14
+ ]
15
+ }
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: lghorizon
3
- Version: 0.6.13
3
+ Version: 0.9.0b0
4
4
  Summary: Python client for Liberty Global Horizon settop boxes
5
5
  Home-page: https://github.com/sholofly/LGHorizon-python
6
6
  Author: Rudolf Offereins
@@ -20,9 +20,21 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Requires-Python: >=3.9
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: paho-mqtt<2.0.0
23
+ Requires-Dist: paho-mqtt
24
24
  Requires-Dist: requests>=2.22.0
25
25
  Requires-Dist: backoff>=1.9.0
26
+ Dynamic: author
27
+ Dynamic: author-email
28
+ Dynamic: classifier
29
+ Dynamic: description
30
+ Dynamic: description-content-type
31
+ Dynamic: home-page
32
+ Dynamic: keywords
33
+ Dynamic: license
34
+ Dynamic: license-file
35
+ Dynamic: requires-dist
36
+ Dynamic: requires-python
37
+ Dynamic: summary
26
38
 
27
39
  # LG Horizon Api
28
40
 
@@ -0,0 +1,6 @@
1
+ """Python client for LG Horizon."""
2
+
3
+ from .lghorizon_api import LGHorizonApi
4
+ from .lghorizon_device import LGHorizonDevice
5
+ from .lghorizon_models import *
6
+ from .exceptions import *
@@ -38,11 +38,18 @@ RECORDING_TYPE_SEASON = "season"
38
38
 
39
39
  BE_AUTH_URL = "https://login.prd.telenet.be/openid/login.do"
40
40
 
41
+ PLATFORM_TYPES = {
42
+ "EOS": {"manufacturer": "Arris", "model": "DCX960"},
43
+ "EOS2": {"manufacturer": "HUMAX", "model": "2008C-STB-TN"},
44
+ "HORIZON": {"manufacturer": "Arris", "model": "DCX960"},
45
+ "APOLLO": {"manufacturer": "Arris", "model": "VIP5002W"},
46
+ }
47
+
41
48
  COUNTRY_SETTINGS = {
42
49
  "nl": {
43
50
  "api_url": "https://spark-prod-nl.gnp.cloud.ziggogo.tv",
44
51
  "mqtt_url": "obomsg.prod.nl.horizon.tv",
45
- "use_oauth": False,
52
+ "use_refreshtoken": False,
46
53
  "channels": [
47
54
  {
48
55
  "channelId": "NL_000073_019506",
@@ -73,13 +80,20 @@ COUNTRY_SETTINGS = {
73
80
  },
74
81
  "ch": {
75
82
  "api_url": "https://spark-prod-ch.gnp.cloud.sunrisetv.ch",
76
- "use_oauth": False,
77
83
  "channels": [],
78
84
  "language": "de",
79
85
  },
86
+ "be-basetv": {
87
+ "api_url": "https://spark-prod-be.gnp.cloud.base.tv",
88
+ "channels": [],
89
+ "language": "nl",
90
+ "platform_types": {
91
+ "EOS": {"manufacturer": "Arris", "model": "DCX960"},
92
+ "HORIZON": {"manufacturer": "Arris", "model": "VIP5002W"},
93
+ },
94
+ },
80
95
  "be-nl": {
81
96
  "api_url": "https://spark-prod-be.gnp.cloud.telenet.tv",
82
- "use_oauth": True,
83
97
  "oauth_username_fieldname": "j_username",
84
98
  "oauth_password_fieldname": "j_password",
85
99
  "oauth_add_accept_header": False,
@@ -99,7 +113,7 @@ COUNTRY_SETTINGS = {
99
113
  },
100
114
  "be-nl-preprod": {
101
115
  "api_url": "https://spark-preprod-be.gnp.cloud.telenet.tv",
102
- "use_oauth": True,
116
+ "use_refreshtoken": True,
103
117
  "oauth_username_fieldname": "j_username",
104
118
  "oauth_password_fieldname": "j_password",
105
119
  "oauth_add_accept_header": False,
@@ -119,20 +133,18 @@ COUNTRY_SETTINGS = {
119
133
  },
120
134
  "gb": {
121
135
  "api_url": "https://spark-prod-gb.gnp.cloud.virgintvgo.virginmedia.com",
122
- "oauth_url": "https://id.virginmedia.com/rest/v40/session/start?protocol=oidc&rememberMe=true",
123
136
  "channels": [],
124
- "oesp_url": "https://prod.oesp.virginmedia.com/oesp/v4/GB/eng/web",
125
137
  "language": "en",
126
138
  },
127
139
  "ie": {
128
140
  "api_url": "https://spark-prod-ie.gnp.cloud.virginmediatv.ie",
129
- "use_oauth": False,
141
+ "use_refreshtoken": False,
130
142
  "channels": [],
131
143
  "language": "en",
132
144
  },
133
145
  "pl": {
134
146
  "api_url": "https://spark-prod-pl.gnp.cloud.upctv.pl",
135
- "use_oauth": False,
147
+ "use_refreshtoken": False,
136
148
  "channels": [],
137
149
  "language": "pl",
138
150
  "platform_types": {
@@ -0,0 +1,17 @@
1
+ """Exceptions for the LGHorizon API."""
2
+
3
+
4
+ class LGHorizonApiError(Exception):
5
+ """Generic LGHorizon exception."""
6
+
7
+
8
+ class LGHorizonApiConnectionError(LGHorizonApiError):
9
+ """Generic LGHorizon exception."""
10
+
11
+
12
+ class LGHorizonApiUnauthorizedError(Exception):
13
+ """Generic LGHorizon exception."""
14
+
15
+
16
+ class LGHorizonApiLockedError(LGHorizonApiUnauthorizedError):
17
+ """Generic LGHorizon exception."""
@@ -1,8 +1,9 @@
1
1
  """Helper functions."""
2
+
2
3
  import random
3
4
 
4
5
 
5
- def make_id(stringLength=10):
6
+ async def make_id(string_length=10):
6
7
  """Create an id with given length."""
7
8
  letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
8
- return "".join(random.choice(letters) for i in range(stringLength))
9
+ return "".join(random.choice(letters) for i in range(string_length))
@@ -0,0 +1,469 @@
1
+ """Python client for LGHorizon."""
2
+ # pylint: disable=broad-exception-caught
3
+ # pylint: disable=line-too-long
4
+
5
+ import logging
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"]
51
+
52
+
53
+ class LGHorizonApi:
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 = {}
89
+ self._channels = {}
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"
133
+ )
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 _obtain_mqtt_token(self):
171
+ _logger.debug("Obtain mqtt token...")
172
+ mqtt_auth_url = self._config["authorizationService"]["URL"]
173
+ mqtt_response = self._do_api_call(f"{mqtt_auth_url}/v1/mqtt/token")
174
+ self._auth.mqttToken = mqtt_response["token"]
175
+ _logger.debug("MQTT token: %s", self._auth.mqttToken)
176
+
177
+ @backoff.on_exception(
178
+ backoff.expo,
179
+ BaseException,
180
+ jitter=None,
181
+ max_tries=3,
182
+ logger=_logger,
183
+ giveup=lambda e: isinstance(
184
+ e, (LGHorizonApiLockedError, LGHorizonApiUnauthorizedError)
185
+ ),
186
+ )
187
+ def connect(self) -> None:
188
+ """Start connection process."""
189
+ self._config = self._get_config(self._country_code)
190
+ _logger.debug("Connect to API")
191
+ self._authorize()
192
+ self._obtain_mqtt_token()
193
+ self._mqtt_client = LGHorizonMqttClient(
194
+ self._auth,
195
+ self._config["mqttBroker"]["URL"],
196
+ self._on_mqtt_connected,
197
+ self._on_mqtt_message,
198
+ )
199
+
200
+ self._register_customer_and_boxes()
201
+ self._mqtt_client.connect()
202
+
203
+ def disconnect(self):
204
+ """Disconnect."""
205
+ _logger.debug("Disconnect from API")
206
+ if not self._mqtt_client or not self._mqtt_client.is_connected:
207
+ return
208
+ self._mqtt_client.disconnect()
209
+
210
+ def _on_mqtt_connected(self) -> None:
211
+ _logger.debug("Connected to MQTT server. Registering all boxes...")
212
+ box: LGHorizonBox
213
+ for box in self.settop_boxes.values():
214
+ box.register_mqtt()
215
+
216
+ def _on_mqtt_message(self, message: str, topic: str) -> None:
217
+ if "action" in message and message["action"] == "OPS.getProfilesUpdate":
218
+ self._update_customer()
219
+ elif "source" in message:
220
+ device_id = message["source"]
221
+ if not isinstance(device_id, str):
222
+ _logger.debug("ignoring message - not a string")
223
+ return
224
+ if device_id not in self.settop_boxes:
225
+ return
226
+ try:
227
+ if "deviceType" in message and message["deviceType"] == "STB":
228
+ self.settop_boxes[device_id].update_state(message)
229
+ if "status" in message:
230
+ self._handle_box_update(device_id, message)
231
+
232
+ except Exception:
233
+ _logger.exception("Could not handle status message")
234
+ _logger.warning("Full message: %s", str(message))
235
+ self.settop_boxes[device_id].playing_info.reset()
236
+ self.settop_boxes[device_id].playing_info.set_paused(False)
237
+ elif "CPE.capacity" in message:
238
+ splitted_topic = topic.split("/")
239
+ if len(splitted_topic) != 4:
240
+ return
241
+ device_id = splitted_topic[1]
242
+ if device_id not in self.settop_boxes:
243
+ return
244
+ self.settop_boxes[device_id].update_recording_capacity(message)
245
+
246
+ def _handle_box_update(self, device_id: str, raw_message: Any) -> None:
247
+ status_payload = raw_message["status"]
248
+ if "uiStatus" not in status_payload:
249
+ return
250
+ ui_status = status_payload["uiStatus"]
251
+ if ui_status == "mainUI":
252
+ player_state = status_payload["playerState"]
253
+ if "sourceType" not in player_state or "source" not in player_state:
254
+ return
255
+ source_type = player_state["sourceType"]
256
+ state_source = player_state["source"]
257
+ self.settop_boxes[device_id].playing_info.set_paused(
258
+ player_state["speed"] == 0
259
+ )
260
+ if (
261
+ source_type
262
+ in (
263
+ BOX_PLAY_STATE_CHANNEL,
264
+ BOX_PLAY_STATE_BUFFER,
265
+ BOX_PLAY_STATE_REPLAY,
266
+ )
267
+ and "eventId" in state_source
268
+ ):
269
+ event_id = state_source["eventId"]
270
+ raw_replay_event = self._do_api_call(
271
+ f"{self._config['linearService']['URL']}/v2/replayEvent/{event_id}?returnLinearContent=true&language={self._country_settings['language']}"
272
+ )
273
+ replay_event = LGHorizonReplayEvent(raw_replay_event)
274
+ channel = self._channels[replay_event.channel_id]
275
+ self.settop_boxes[device_id].update_with_replay_event(
276
+ source_type, replay_event, channel
277
+ )
278
+ elif source_type == BOX_PLAY_STATE_DVR:
279
+ recording_id = state_source["recordingId"]
280
+ session_start_time = state_source["sessionStartTime"]
281
+ session_end_time = state_source["sessionEndTime"]
282
+ last_speed_change_time = player_state["lastSpeedChangeTime"]
283
+ relative_position = player_state["relativePosition"]
284
+ raw_recording = self._do_api_call(
285
+ 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']}"
286
+ )
287
+ recording = LGHorizonRecordingSingle(raw_recording)
288
+ channel = self._channels[recording.channel_id]
289
+ self.settop_boxes[device_id].update_with_recording(
290
+ source_type,
291
+ recording,
292
+ channel,
293
+ session_start_time,
294
+ session_end_time,
295
+ last_speed_change_time,
296
+ relative_position,
297
+ )
298
+ elif source_type == BOX_PLAY_STATE_VOD:
299
+ title_id = state_source["titleId"]
300
+ last_speed_change_time = player_state["lastSpeedChangeTime"]
301
+ relative_position = player_state["relativePosition"]
302
+ raw_vod = self._do_api_call(
303
+ 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}"
304
+ )
305
+ vod = LGHorizonVod(raw_vod)
306
+ self.settop_boxes[device_id].update_with_vod(
307
+ source_type, vod, last_speed_change_time, relative_position
308
+ )
309
+ elif ui_status == "apps":
310
+ app = LGHorizonApp(status_payload["appsState"])
311
+ self.settop_boxes[device_id].update_with_app("app", app)
312
+
313
+ @backoff.on_exception(
314
+ backoff.expo, LGHorizonApiConnectionError, max_tries=3, logger=_logger
315
+ )
316
+ def _do_api_call(self, url: str) -> str:
317
+ _logger.info("Executing API call to %s", url)
318
+ try:
319
+ api_response = self._session.get(url)
320
+ api_response.raise_for_status()
321
+ json_response = api_response.json()
322
+ except request_exceptions.HTTPError as http_ex:
323
+ self._authorize()
324
+ raise LGHorizonApiConnectionError(
325
+ f"Unable to call {url}. Error:{str(http_ex)}"
326
+ ) from http_ex
327
+ _logger.debug("Result API call: %s", json_response)
328
+ return json_response
329
+
330
+ def _register_customer_and_boxes(self):
331
+ self._update_customer()
332
+ self._get_channels()
333
+ if len(self.customer.settop_boxes) == 0:
334
+ _logger.warning("No boxes found.")
335
+ return
336
+ _logger.info("Registering boxes")
337
+ for device in self.customer.settop_boxes:
338
+ platform_type = device["platformType"]
339
+ if platform_type not in _supported_platforms:
340
+ continue
341
+ if (
342
+ "platform_types" in self._country_settings
343
+ and platform_type in self._country_settings["platform_types"]
344
+ ):
345
+ platform_type = self._country_settings["platform_types"][platform_type]
346
+ else:
347
+ platform_type = None
348
+ box = LGHorizonBox(
349
+ device, platform_type, self._mqtt_client, self._auth, self._channels
350
+ )
351
+ self.settop_boxes[box.device_id] = box
352
+ _logger.info("Box %s registered...", box.device_id)
353
+
354
+ def _update_customer(self):
355
+ _logger.info("Get customer data")
356
+ personalisation_result = self._do_api_call(
357
+ f"{self._config['personalizationService']['URL']}/v1/customer/{self._auth.household_id}?with=profiles%2Cdevices"
358
+ )
359
+ _logger.debug("Personalisation result: %s ", personalisation_result)
360
+ self.customer = LGHorizonCustomer(personalisation_result)
361
+
362
+ def _get_channels(self):
363
+ self._update_entitlements()
364
+ _logger.info("Retrieving channels...")
365
+ channels_result = self._do_api_call(
366
+ f"{self._config['linearService']['URL']}/v2/channels?cityId={self.customer.city_id}&language={self._country_settings['language']}&productClass=Orion-DASH"
367
+ )
368
+ for channel in channels_result:
369
+ if "isRadio" in channel and channel["isRadio"]:
370
+ continue
371
+ common_entitlements = list(
372
+ set(self._entitlements) & set(channel["linearProducts"])
373
+ )
374
+ if len(common_entitlements) == 0:
375
+ continue
376
+ channel_id = channel["id"]
377
+ self._channels[channel_id] = LGHorizonChannel(channel)
378
+ _logger.info("%s retrieved.", len(self._channels))
379
+
380
+ def get_display_channels(self):
381
+ """Returns channels to display baed on profile."""
382
+ all_channels = self._channels.values()
383
+ if not self._profile_id or self._profile_id not in self.customer.profiles:
384
+ return all_channels
385
+ profile_channel_ids = self.customer.profiles[self._profile_id].favorite_channels
386
+ if len(profile_channel_ids) == 0:
387
+ return all_channels
388
+
389
+ return [
390
+ channel for channel in all_channels if channel.id in profile_channel_ids
391
+ ]
392
+
393
+ def _get_replay_event(self, listing_id) -> Any:
394
+ """Get listing."""
395
+ _logger.info("Retrieving replay event details...")
396
+ response = self._do_api_call(
397
+ f"{self._config['linearService']['URL']}/v2/replayEvent/{listing_id}?returnLinearContent=true&language={self._country_settings['language']}"
398
+ )
399
+ _logger.info("Replay event details retrieved")
400
+ return response
401
+
402
+ def get_recording_capacity(self) -> int:
403
+ """Returns remaining recording capacity"""
404
+ ctry_code = self._country_code[0:2]
405
+ if ctry_code == "gb":
406
+ _logger.debug("GB: not supported")
407
+ return None
408
+ try:
409
+ _logger.info("Retrieving recordingcapacity...")
410
+ quota_content = self._do_api_call(
411
+ f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/quota"
412
+ )
413
+ if "quota" not in quota_content and "occupied" not in quota_content:
414
+ _logger.error("Unable to fetch recording capacity...")
415
+ return None
416
+ capacity = (quota_content["occupied"] / quota_content["quota"]) * 100
417
+ self.recording_capacity = round(capacity)
418
+ _logger.debug("Remaining recordingcapacity %s %%", self.recording_capacity)
419
+ return self.recording_capacity
420
+ except Exception:
421
+ _logger.error("Unable to fetch recording capacity...")
422
+ return None
423
+
424
+ def get_recordings(self) -> List[LGHorizonBaseRecording]:
425
+ """Returns recordings."""
426
+ _logger.info("Retrieving recordings...")
427
+ recording_content = self._do_api_call(
428
+ f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/recordings?sort=time&sortOrder=desc&language={self._country_settings['language']}"
429
+ )
430
+ recordings = []
431
+ for recording_data_item in recording_content["data"]:
432
+ recording_type = recording_data_item["type"]
433
+ if recording_type == RECORDING_TYPE_SINGLE:
434
+ recordings.append(LGHorizonRecordingSingle(recording_data_item))
435
+ elif recording_type in (RECORDING_TYPE_SEASON, RECORDING_TYPE_SHOW):
436
+ recordings.append(LGHorizonRecordingListSeasonShow(recording_data_item))
437
+ _logger.info("%s recordings retrieved...", len(recordings))
438
+ return recordings
439
+
440
+ def get_recording_show(self, show_id: str) -> list[LGHorizonRecordingSingle]:
441
+ """Returns show recording"""
442
+ _logger.info("Retrieving show recordings...")
443
+ show_recording_content = self._do_api_call(
444
+ f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/episodes/shows/{show_id}?source=recording&language=nl&sort=time&sortOrder=asc"
445
+ )
446
+ recordings = []
447
+ for item in show_recording_content["data"]:
448
+ if item["source"] == "show":
449
+ recordings.append(LGHorizonRecordingShow(item))
450
+ else:
451
+ recordings.append(LGHorizonRecordingEpisode(item))
452
+ _logger.info("%s showrecordings retrieved...", len(recordings))
453
+ return recordings
454
+
455
+ def _update_entitlements(self) -> None:
456
+ _logger.info("Retrieving entitlements...")
457
+ entitlements_json = self._do_api_call(
458
+ f"{self._config['purchaseService']['URL']}/v2/customers/{self._auth.household_id}/entitlements?enableDaypass=true"
459
+ )
460
+ self._entitlements.clear()
461
+ for entitlement in entitlements_json["entitlements"]:
462
+ self._entitlements.append(entitlement["id"])
463
+
464
+ def _get_config(self, country_code: str):
465
+ base_country_code = country_code[0:2]
466
+ config_url = f"{self._country_settings['api_url']}/{base_country_code}/en/config-service/conf/web/backoffice.json"
467
+ result = self._do_api_call(config_url)
468
+ _logger.debug(result)
469
+ return result