lghorizon 0.9.0.dev1__py3-none-any.whl → 0.9.0.dev3__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 +3 -34
- lghorizon/exceptions.py +17 -0
- lghorizon/{lghorizonapi.py → lghorizon_api.py} +15 -15
- lghorizon/lghorizon_device.py +336 -0
- lghorizon/{device_state_processor.py → lghorizon_device_state_processor.py} +9 -9
- lghorizon/{message_factory.py → lghorizon_message_factory.py} +1 -1
- lghorizon/lghorizon_models.py +1331 -0
- lghorizon/lghorizon_mqtt_client.py +123 -0
- lghorizon/{recording_factory.py → lghorizon_recording_factory.py} +1 -1
- {lghorizon-0.9.0.dev1.dist-info → lghorizon-0.9.0.dev3.dist-info}/METADATA +1 -1
- lghorizon-0.9.0.dev3.dist-info/RECORD +17 -0
- lghorizon-0.9.0.dev1.dist-info/RECORD +0 -13
- {lghorizon-0.9.0.dev1.dist-info → lghorizon-0.9.0.dev3.dist-info}/WHEEL +0 -0
- {lghorizon-0.9.0.dev1.dist-info → lghorizon-0.9.0.dev3.dist-info}/licenses/LICENSE +0 -0
- {lghorizon-0.9.0.dev1.dist-info → lghorizon-0.9.0.dev3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1331 @@
|
|
|
1
|
+
"""LG Horizon Model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import backoff
|
|
14
|
+
from aiohttp import ClientResponseError, ClientSession
|
|
15
|
+
|
|
16
|
+
from .const import (
|
|
17
|
+
COUNTRY_SETTINGS,
|
|
18
|
+
)
|
|
19
|
+
from .exceptions import LGHorizonApiConnectionError, LGHorizonApiUnauthorizedError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LGHorizonRunningState(Enum):
|
|
26
|
+
"""Running state of horizon box."""
|
|
27
|
+
|
|
28
|
+
UNKNOWN = "UNKNOWN"
|
|
29
|
+
ONLINE_RUNNING = "ONLINE_RUNNING"
|
|
30
|
+
ONLINE_STANDBY = "ONLINE_STANDBY"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LGHorizonMessageType(Enum):
|
|
34
|
+
"""Enumeration of LG Horizon message types."""
|
|
35
|
+
|
|
36
|
+
UNKNOWN = 0
|
|
37
|
+
STATUS = 1
|
|
38
|
+
UI_STATUS = 2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LGHorizonRecordingSource(Enum):
|
|
42
|
+
"""LGHorizon recording."""
|
|
43
|
+
|
|
44
|
+
SHOW = "show"
|
|
45
|
+
UNKNOWN = "unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LGHorizonRecordingState(Enum):
|
|
49
|
+
"""Enumeration of LG Horizon recording states."""
|
|
50
|
+
|
|
51
|
+
RECORDED = "recorded"
|
|
52
|
+
UNKNOWN = "unknown"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LGHorizonRecordingType(Enum):
|
|
56
|
+
"""Enumeration of LG Horizon recording states."""
|
|
57
|
+
|
|
58
|
+
SINGLE = "single"
|
|
59
|
+
SEASON = "season"
|
|
60
|
+
SHOW = "show"
|
|
61
|
+
UNKNOWN = "unknown"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LGHorizonUIStateType(Enum):
|
|
65
|
+
"""Enumeration of LG Horizon UI State types."""
|
|
66
|
+
|
|
67
|
+
MAINUI = "mainUI"
|
|
68
|
+
APPS = "apps"
|
|
69
|
+
UNKNOWN = "unknown"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LGHorizonMessage(ABC):
|
|
73
|
+
"""Abstract base class for LG Horizon messages."""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def topic(self) -> str:
|
|
77
|
+
"""Return the topic of the message."""
|
|
78
|
+
return self._topic
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def payload(self) -> dict:
|
|
82
|
+
"""Return the payload of the message."""
|
|
83
|
+
return self._payload
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def message_type(self) -> LGHorizonMessageType | None:
|
|
88
|
+
"""Return the message type."""
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def __init__(self, topic: str, payload: dict) -> None:
|
|
92
|
+
"""Abstract base class for LG Horizon messages."""
|
|
93
|
+
self._topic = topic
|
|
94
|
+
self._payload = payload
|
|
95
|
+
|
|
96
|
+
def __repr__(self) -> str:
|
|
97
|
+
"""Return a string representation of the message."""
|
|
98
|
+
return f"LGHorizonStatusMessage(topic='{self._topic}', payload={json.dumps(self._payload, indent=2)})"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class LGHorizonStatusMessage(LGHorizonMessage):
|
|
102
|
+
"""Represents an LG Horizon status message received via MQTT."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, payload: dict, topic: str) -> None:
|
|
105
|
+
"""Initialize an LG Horizon status message."""
|
|
106
|
+
super().__init__(topic, payload)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def message_type(self) -> LGHorizonMessageType:
|
|
110
|
+
"""Return the message type from the payload, if available."""
|
|
111
|
+
return LGHorizonMessageType.STATUS
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def source(self) -> str:
|
|
115
|
+
"""Return the device ID from the payload, if available."""
|
|
116
|
+
return self._payload.get("source", "unknown")
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def running_state(self) -> LGHorizonRunningState:
|
|
120
|
+
"""Return the device ID from the payload, if available."""
|
|
121
|
+
return LGHorizonRunningState[self._payload.get("state", "unknown").upper()]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class LGHorizonSourceType(Enum):
|
|
125
|
+
"""Enumeration of LG Horizon message types."""
|
|
126
|
+
|
|
127
|
+
LINEAR = "linear"
|
|
128
|
+
REVIEWBUFFER = "reviewBuffer"
|
|
129
|
+
NDVR = "nDVR"
|
|
130
|
+
REPLAY = "replay"
|
|
131
|
+
VOD = "VOD"
|
|
132
|
+
UNKNOWN = "unknown"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class LGHorizonSource(ABC):
|
|
136
|
+
"""Abstract base class for LG Horizon sources."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, raw_json: dict) -> None:
|
|
139
|
+
"""Initialize the LG Horizon source."""
|
|
140
|
+
self._raw_json = raw_json
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def source_type(self) -> LGHorizonSourceType:
|
|
145
|
+
"""Return the message type."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class LGHorizonLinearSource(LGHorizonSource):
|
|
149
|
+
"""Represent the Linear Source of an LG Horizon device."""
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def channel_id(self) -> str:
|
|
153
|
+
"""Return the source type."""
|
|
154
|
+
return self._raw_json.get("channelId", "")
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def event_id(self) -> str:
|
|
158
|
+
"""Return the event ID."""
|
|
159
|
+
return self._raw_json.get("eventId", "")
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def source_type(self) -> LGHorizonSourceType:
|
|
163
|
+
return LGHorizonSourceType.LINEAR
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class LGHorizonReviewBufferSource(LGHorizonSource):
|
|
167
|
+
"""Represent the ReviewBuffer Source of an LG Horizon device."""
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def channel_id(self) -> str:
|
|
171
|
+
"""Return the source type."""
|
|
172
|
+
return self._raw_json.get("channelId", "")
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def event_id(self) -> str:
|
|
176
|
+
"""Return the event ID."""
|
|
177
|
+
return self._raw_json.get("eventId", "")
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def source_type(self) -> LGHorizonSourceType:
|
|
181
|
+
return LGHorizonSourceType.REVIEWBUFFER
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class LGHorizonNDVRSource(LGHorizonSource):
|
|
185
|
+
"""Represent the ReviewBuffer Source of an LG Horizon device."""
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def recording_id(self) -> str:
|
|
189
|
+
"""Return the recording ID."""
|
|
190
|
+
return self._raw_json.get("recordingId", "")
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def channel_id(self) -> str:
|
|
194
|
+
"""Return the channel ID."""
|
|
195
|
+
return self._raw_json.get("channelId", "")
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def source_type(self) -> LGHorizonSourceType:
|
|
199
|
+
return LGHorizonSourceType.NDVR
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class LGHorizonVODSource(LGHorizonSource):
|
|
203
|
+
"""Represent the VOD Source of an LG Horizon device."""
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def title_id(self) -> str:
|
|
207
|
+
"""Return the title ID."""
|
|
208
|
+
return self._raw_json.get("titleId", "")
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def start_intro_time(self) -> int:
|
|
212
|
+
"""Return the start intro time."""
|
|
213
|
+
return self._raw_json.get("startIntroTime", 0)
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def end_intro_time(self) -> int:
|
|
217
|
+
"""Return the end intro time."""
|
|
218
|
+
return self._raw_json.get("endIntroTime", 0)
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def source_type(self) -> LGHorizonSourceType:
|
|
222
|
+
return LGHorizonSourceType.VOD
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class LGHorizonReplaySource(LGHorizonSource):
|
|
226
|
+
"""Represent the VOD Source of an LG Horizon device."""
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def event_id(self) -> str:
|
|
230
|
+
"""Return the title ID."""
|
|
231
|
+
return self._raw_json.get("eventId", "")
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def source_type(self) -> LGHorizonSourceType:
|
|
235
|
+
"""Return the source type."""
|
|
236
|
+
return LGHorizonSourceType.REPLAY
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class LGHorizonUnknownSource(LGHorizonSource):
|
|
240
|
+
"""Represent the Linear Source of an LG Horizon device."""
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def source_type(self) -> LGHorizonSourceType:
|
|
244
|
+
return LGHorizonSourceType.UNKNOWN
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class LGHorizonPlayerState:
|
|
248
|
+
"""Represent the Player State of an LG Horizon device."""
|
|
249
|
+
|
|
250
|
+
def __init__(self, raw_json: dict) -> None:
|
|
251
|
+
"""Initialize the Player State."""
|
|
252
|
+
self._raw_json = raw_json
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def source_type(self) -> LGHorizonSourceType:
|
|
256
|
+
"""Return the source type."""
|
|
257
|
+
return LGHorizonSourceType[self._raw_json.get("sourceType", "unknown").upper()]
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def speed(self) -> int:
|
|
261
|
+
"""Return the Player State dictionary."""
|
|
262
|
+
return self._raw_json.get("speed", 0)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def last_speed_change_time(
|
|
266
|
+
self,
|
|
267
|
+
) -> int:
|
|
268
|
+
"""Return the last speed change time."""
|
|
269
|
+
return self._raw_json.get("lastSpeedChangeTime", 0.0)
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def source(self) -> LGHorizonSource | None: # Added None to the return type
|
|
273
|
+
"""Return the last speed change time."""
|
|
274
|
+
if "source" in self._raw_json:
|
|
275
|
+
match self.source_type:
|
|
276
|
+
case LGHorizonSourceType.LINEAR:
|
|
277
|
+
return LGHorizonLinearSource(self._raw_json["source"])
|
|
278
|
+
case LGHorizonSourceType.VOD:
|
|
279
|
+
return LGHorizonVODSource(self._raw_json["source"])
|
|
280
|
+
case LGHorizonSourceType.REPLAY:
|
|
281
|
+
return LGHorizonReplaySource(self._raw_json["source"])
|
|
282
|
+
case LGHorizonSourceType.NDVR:
|
|
283
|
+
return LGHorizonNDVRSource(self._raw_json["source"])
|
|
284
|
+
case LGHorizonSourceType.REVIEWBUFFER:
|
|
285
|
+
return LGHorizonReviewBufferSource(self._raw_json["source"])
|
|
286
|
+
|
|
287
|
+
return LGHorizonUnknownSource(self._raw_json["source"])
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class LGHorizonAppsState:
|
|
291
|
+
"""Represent the State of an LG Horizon device."""
|
|
292
|
+
|
|
293
|
+
def __init__(self, raw_json: dict) -> None:
|
|
294
|
+
"""Initialize the Apps state."""
|
|
295
|
+
self._raw_json = raw_json
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def id(self) -> str:
|
|
299
|
+
"""Return the id."""
|
|
300
|
+
return self._raw_json.get("id", "")
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def app_name(self) -> str:
|
|
304
|
+
"""Return the app name."""
|
|
305
|
+
return self._raw_json.get("appName", "")
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def logo_path(self) -> str:
|
|
309
|
+
"""Return the logo path."""
|
|
310
|
+
return self._raw_json.get("logoPath", "")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class LGHorizonUIState:
|
|
314
|
+
"""Represent the State of an LG Horizon device."""
|
|
315
|
+
|
|
316
|
+
_player_state: LGHorizonPlayerState | None = None
|
|
317
|
+
_apps_state: LGHorizonAppsState | None = None
|
|
318
|
+
|
|
319
|
+
def __init__(self, raw_json: dict) -> None:
|
|
320
|
+
"""Initialize the State."""
|
|
321
|
+
self._raw_json = raw_json
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def ui_status(self) -> LGHorizonUIStateType:
|
|
325
|
+
"""Return the UI status dictionary."""
|
|
326
|
+
return LGHorizonUIStateType[self._raw_json.get("uiStatus", "unknown").upper()]
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def player_state(
|
|
330
|
+
self,
|
|
331
|
+
) -> LGHorizonPlayerState | None: # Added None to the return type
|
|
332
|
+
"""Return the UI status dictionary."""
|
|
333
|
+
# Check if _player_state is None and if "playerState" key exists in raw_json
|
|
334
|
+
if self._player_state is None and "playerState" in self._raw_json:
|
|
335
|
+
self._player_state = LGHorizonPlayerState(
|
|
336
|
+
self._raw_json["playerState"]
|
|
337
|
+
) # Access directly as existence is checked
|
|
338
|
+
return self._player_state
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def apps_state(
|
|
342
|
+
self,
|
|
343
|
+
) -> LGHorizonAppsState | None: # Added None to the return type
|
|
344
|
+
"""Return the UI status dictionary."""
|
|
345
|
+
# Check if _player_state is None and if "playerState" key exists in raw_json
|
|
346
|
+
if self._apps_state is None and "appsState" in self._raw_json:
|
|
347
|
+
self._apps_state = LGHorizonAppsState(
|
|
348
|
+
self._raw_json["appsState"]
|
|
349
|
+
) # Access directly as existence is checked
|
|
350
|
+
return self._apps_state
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class LGHorizonUIStatusMessage(LGHorizonMessage):
|
|
354
|
+
"""Represents an LG Horizon UI status message received via MQTT."""
|
|
355
|
+
|
|
356
|
+
_status: LGHorizonUIState | None = None
|
|
357
|
+
|
|
358
|
+
def __init__(self, payload: dict, topic: str) -> None:
|
|
359
|
+
"""Initialize an LG Horizon UI status message."""
|
|
360
|
+
super().__init__(topic, payload)
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def message_type(self) -> LGHorizonMessageType:
|
|
364
|
+
"""Return the message type from the payload, if available."""
|
|
365
|
+
return LGHorizonMessageType.UI_STATUS
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def source(self) -> str:
|
|
369
|
+
"""Return the device ID from the payload, if available."""
|
|
370
|
+
return self._payload.get("source", "unknown")
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def message_timestamp(self) -> int:
|
|
374
|
+
"""Return the device ID from the payload, if available."""
|
|
375
|
+
return self._payload.get("messageTimeStamp", 0)
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def ui_state(self) -> LGHorizonUIState | None:
|
|
379
|
+
"""Return the device ID from the payload, if available."""
|
|
380
|
+
if not self._status and "status" in self._payload:
|
|
381
|
+
self._status = LGHorizonUIState(self._payload["status"])
|
|
382
|
+
return self._status
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class LGHorizonUnknownMessage(LGHorizonMessage):
|
|
386
|
+
"""Represents an unknown LG Horizon message received via MQTT."""
|
|
387
|
+
|
|
388
|
+
def __init__(self, payload: dict, topic: str) -> None:
|
|
389
|
+
"""Initialize an LG Horizon unknown message."""
|
|
390
|
+
super().__init__(topic, payload)
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def message_type(self) -> LGHorizonMessageType:
|
|
394
|
+
"""Return the message type from the payload, if available."""
|
|
395
|
+
return LGHorizonMessageType.UNKNOWN
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class LGHorizonProfileOptions:
|
|
399
|
+
"""LGHorizon profile options."""
|
|
400
|
+
|
|
401
|
+
def __init__(self, options_payload: dict):
|
|
402
|
+
"""Initialize a profile options."""
|
|
403
|
+
self._options_payload = options_payload
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def lang(self) -> str:
|
|
407
|
+
"""Return the language."""
|
|
408
|
+
return self._options_payload["lang"]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class LGHorizonProfile:
|
|
412
|
+
"""LGHorizon profile."""
|
|
413
|
+
|
|
414
|
+
_options: LGHorizonProfileOptions
|
|
415
|
+
_profile_payload: dict
|
|
416
|
+
|
|
417
|
+
def __init__(self, profile_payload: dict):
|
|
418
|
+
"""Initialize a profile."""
|
|
419
|
+
self._profile_payload = profile_payload
|
|
420
|
+
self._options = LGHorizonProfileOptions(self._profile_payload["options"])
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def id(self) -> str:
|
|
424
|
+
"""Return the profile id."""
|
|
425
|
+
return self._profile_payload["profileId"]
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def name(self) -> str:
|
|
429
|
+
"""Return the profile name."""
|
|
430
|
+
return self._profile_payload["name"]
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def favorite_channels(self) -> list[str]:
|
|
434
|
+
"""Return the favorite channels."""
|
|
435
|
+
return self._profile_payload.get("favoriteChannels", [])
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def options(self) -> LGHorizonProfileOptions:
|
|
439
|
+
"""Return the profile options."""
|
|
440
|
+
return self._options
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class LGHorizonAuth:
|
|
444
|
+
"""Class to make authenticated requests."""
|
|
445
|
+
|
|
446
|
+
_websession: ClientSession
|
|
447
|
+
_refresh_token: str
|
|
448
|
+
_access_token: Optional[str]
|
|
449
|
+
_username: str
|
|
450
|
+
_password: str
|
|
451
|
+
_household_id: str
|
|
452
|
+
_token_expiry: Optional[int]
|
|
453
|
+
_country_code: str
|
|
454
|
+
_host: str
|
|
455
|
+
_use_refresh_token: bool
|
|
456
|
+
|
|
457
|
+
def __init__(
|
|
458
|
+
self,
|
|
459
|
+
websession: ClientSession,
|
|
460
|
+
country_code: str,
|
|
461
|
+
refresh_token: str = "",
|
|
462
|
+
username: str = "",
|
|
463
|
+
password: str = "",
|
|
464
|
+
) -> None:
|
|
465
|
+
"""Initialize the auth with refresh token."""
|
|
466
|
+
self._websession = websession
|
|
467
|
+
self._refresh_token = refresh_token
|
|
468
|
+
self._access_token = None
|
|
469
|
+
self._username = username
|
|
470
|
+
self._password = password
|
|
471
|
+
self._household_id = ""
|
|
472
|
+
self._token_expiry = None
|
|
473
|
+
self._country_code = country_code
|
|
474
|
+
self._host = COUNTRY_SETTINGS[country_code]["api_url"]
|
|
475
|
+
self._use_refresh_token = COUNTRY_SETTINGS[country_code]["use_refreshtoken"]
|
|
476
|
+
self._service_config = None
|
|
477
|
+
|
|
478
|
+
@property
|
|
479
|
+
def websession(self) -> ClientSession:
|
|
480
|
+
"""Return the aiohttp client session."""
|
|
481
|
+
return self._websession
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def refresh_token(self) -> str:
|
|
485
|
+
"""Return the refresh token."""
|
|
486
|
+
return self._refresh_token
|
|
487
|
+
|
|
488
|
+
@refresh_token.setter
|
|
489
|
+
def refresh_token(self, value: str) -> None:
|
|
490
|
+
"""Set the refresh token."""
|
|
491
|
+
self._refresh_token = value
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def access_token(self) -> Optional[str]:
|
|
495
|
+
"""Return the access token."""
|
|
496
|
+
return self._access_token
|
|
497
|
+
|
|
498
|
+
@access_token.setter
|
|
499
|
+
def access_token(self, value: Optional[str]) -> None:
|
|
500
|
+
"""Set the access token."""
|
|
501
|
+
self._access_token = value
|
|
502
|
+
|
|
503
|
+
@property
|
|
504
|
+
def username(self) -> str:
|
|
505
|
+
"""Return the username."""
|
|
506
|
+
return self._username
|
|
507
|
+
|
|
508
|
+
@username.setter
|
|
509
|
+
def username(self, value: str) -> None:
|
|
510
|
+
"""Set the username."""
|
|
511
|
+
self._username = value
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
def password(self) -> str:
|
|
515
|
+
"""Return the password."""
|
|
516
|
+
return self._password
|
|
517
|
+
|
|
518
|
+
@password.setter
|
|
519
|
+
def password(self, value: str) -> None:
|
|
520
|
+
"""Set the password."""
|
|
521
|
+
self._password = value
|
|
522
|
+
|
|
523
|
+
@property
|
|
524
|
+
def household_id(self) -> str:
|
|
525
|
+
"""Return the household ID."""
|
|
526
|
+
return self._household_id
|
|
527
|
+
|
|
528
|
+
@household_id.setter
|
|
529
|
+
def household_id(self, value: str) -> None:
|
|
530
|
+
"""Set the household ID."""
|
|
531
|
+
self._household_id = value
|
|
532
|
+
|
|
533
|
+
@property
|
|
534
|
+
def token_expiry(self) -> Optional[int]:
|
|
535
|
+
"""Return the token expiry timestamp."""
|
|
536
|
+
return self._token_expiry
|
|
537
|
+
|
|
538
|
+
@token_expiry.setter
|
|
539
|
+
def token_expiry(self, value: Optional[int]) -> None:
|
|
540
|
+
"""Set the token expiry timestamp."""
|
|
541
|
+
self._token_expiry = value
|
|
542
|
+
|
|
543
|
+
@property
|
|
544
|
+
def country_code(self) -> str:
|
|
545
|
+
"""Return the country code."""
|
|
546
|
+
return self._country_code
|
|
547
|
+
|
|
548
|
+
async def is_token_expiring(self) -> bool:
|
|
549
|
+
"""Check if the token is expiring within one day."""
|
|
550
|
+
if not self.access_token or not self.token_expiry:
|
|
551
|
+
return True
|
|
552
|
+
current_unix_time = int(time.time())
|
|
553
|
+
return current_unix_time >= (self.token_expiry - 86400)
|
|
554
|
+
|
|
555
|
+
async def fetch_access_token(self) -> None:
|
|
556
|
+
"""Fetch the access token."""
|
|
557
|
+
_LOGGER.debug("Fetching access token")
|
|
558
|
+
headers = dict()
|
|
559
|
+
headers["content-type"] = "application/json"
|
|
560
|
+
headers["charset"] = "utf-8"
|
|
561
|
+
|
|
562
|
+
if not self._use_refresh_token and self.access_token is None:
|
|
563
|
+
payload = {"password": self.password, "username": self.username}
|
|
564
|
+
headers["x-device-code"] = "web"
|
|
565
|
+
auth_url_path = "/auth-service/v1/authorization"
|
|
566
|
+
else:
|
|
567
|
+
payload = {"refreshToken": self.refresh_token}
|
|
568
|
+
auth_url_path = "/auth-service/v1/authorization/refresh"
|
|
569
|
+
try: # Use properties and backing fields
|
|
570
|
+
auth_response = await self.websession.post(
|
|
571
|
+
f"{self._host}{auth_url_path}",
|
|
572
|
+
json=payload,
|
|
573
|
+
headers=headers,
|
|
574
|
+
)
|
|
575
|
+
except Exception as ex:
|
|
576
|
+
raise LGHorizonApiConnectionError from ex
|
|
577
|
+
auth_json = await auth_response.json()
|
|
578
|
+
if not auth_response.ok:
|
|
579
|
+
error = None
|
|
580
|
+
if "error" in auth_json:
|
|
581
|
+
error = auth_json["error"]
|
|
582
|
+
if error and error["statusCode"] == 97401:
|
|
583
|
+
raise LGHorizonApiUnauthorizedError("Invalid credentials")
|
|
584
|
+
elif error:
|
|
585
|
+
raise LGHorizonApiConnectionError(error["message"])
|
|
586
|
+
else:
|
|
587
|
+
raise LGHorizonApiConnectionError("Unknown connection error")
|
|
588
|
+
|
|
589
|
+
self.household_id = auth_json["householdId"]
|
|
590
|
+
self.access_token = auth_json["accessToken"]
|
|
591
|
+
self.refresh_token = auth_json["refreshToken"]
|
|
592
|
+
self.username = auth_json["username"]
|
|
593
|
+
self.token_expiry = auth_json["refreshTokenExpiry"]
|
|
594
|
+
|
|
595
|
+
@backoff.on_exception(backoff.expo, LGHorizonApiConnectionError, max_tries=3)
|
|
596
|
+
async def request(self, host: str, path: str, params=None, **kwargs) -> Any:
|
|
597
|
+
"""Make a request."""
|
|
598
|
+
if headers := kwargs.pop("headers", {}):
|
|
599
|
+
headers = dict(headers)
|
|
600
|
+
request_url = f"{host}{path}"
|
|
601
|
+
if await self.is_token_expiring(): # Use property
|
|
602
|
+
_LOGGER.debug("Access token is expiring, fetching a new one")
|
|
603
|
+
await self.fetch_access_token()
|
|
604
|
+
try:
|
|
605
|
+
web_response = await self.websession.request(
|
|
606
|
+
"GET", request_url, **kwargs, headers=headers, params=params
|
|
607
|
+
)
|
|
608
|
+
web_response.raise_for_status()
|
|
609
|
+
json_response = await web_response.json()
|
|
610
|
+
_LOGGER.debug(
|
|
611
|
+
"Response from %s:\n %s",
|
|
612
|
+
request_url,
|
|
613
|
+
json.dumps(json_response, indent=2),
|
|
614
|
+
)
|
|
615
|
+
return json_response
|
|
616
|
+
except ClientResponseError as cre:
|
|
617
|
+
_LOGGER.error("Error response from %s: %s", request_url, str(cre))
|
|
618
|
+
if cre.status == 401:
|
|
619
|
+
await self.fetch_access_token()
|
|
620
|
+
raise LGHorizonApiConnectionError(
|
|
621
|
+
f"Unable to call {request_url}. Error:{str(cre)}"
|
|
622
|
+
) from cre
|
|
623
|
+
|
|
624
|
+
except Exception as ex:
|
|
625
|
+
_LOGGER.error("Error calling %s: %s", request_url, str(ex))
|
|
626
|
+
raise LGHorizonApiConnectionError(
|
|
627
|
+
f"Unable to call {request_url}. Error:{str(ex)}"
|
|
628
|
+
) from ex
|
|
629
|
+
|
|
630
|
+
async def get_mqtt_token(self) -> Any:
|
|
631
|
+
"""Get the MQTT token."""
|
|
632
|
+
_LOGGER.debug("Fetching MQTT token")
|
|
633
|
+
config = await self.get_service_config()
|
|
634
|
+
service_url = await config.get_service_url("authorizationService")
|
|
635
|
+
result = await self.request(
|
|
636
|
+
service_url,
|
|
637
|
+
"/v1/mqtt/token",
|
|
638
|
+
)
|
|
639
|
+
return result["token"]
|
|
640
|
+
|
|
641
|
+
async def get_service_config(self):
|
|
642
|
+
"""Get the service configuration."""
|
|
643
|
+
_LOGGER.debug("Fetching service configuration")
|
|
644
|
+
if self._service_config is None: # Use property and backing field
|
|
645
|
+
base_country_code = self.country_code[0:2]
|
|
646
|
+
result = await self.request(
|
|
647
|
+
self._host,
|
|
648
|
+
f"/{base_country_code}/en/config-service/conf/web/backoffice.json",
|
|
649
|
+
)
|
|
650
|
+
self._service_config = LGHorizonServicesConfig(result)
|
|
651
|
+
|
|
652
|
+
return self._service_config
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class LGHorizonChannel:
|
|
656
|
+
"""Class to represent a channel."""
|
|
657
|
+
|
|
658
|
+
def __init__(self, channel_json):
|
|
659
|
+
"""Initialize a channel."""
|
|
660
|
+
self.channel_json = channel_json
|
|
661
|
+
|
|
662
|
+
@property
|
|
663
|
+
def id(self) -> str:
|
|
664
|
+
"""Returns the id."""
|
|
665
|
+
return self.channel_json["id"]
|
|
666
|
+
|
|
667
|
+
@property
|
|
668
|
+
def channel_number(self) -> str:
|
|
669
|
+
"""Returns the channel number."""
|
|
670
|
+
return self.channel_json["logicalChannelNumber"]
|
|
671
|
+
|
|
672
|
+
@property
|
|
673
|
+
def is_radio(self) -> bool:
|
|
674
|
+
"""Returns if the channel is a radio channel."""
|
|
675
|
+
return self.channel_json.get("isRadio", False)
|
|
676
|
+
|
|
677
|
+
@property
|
|
678
|
+
def title(self) -> str:
|
|
679
|
+
"""Returns the title."""
|
|
680
|
+
return self.channel_json["name"]
|
|
681
|
+
|
|
682
|
+
@property
|
|
683
|
+
def logo_image(self) -> str:
|
|
684
|
+
"""Returns the logo image."""
|
|
685
|
+
if "logo" in self.channel_json and "focused" in self.channel_json["logo"]:
|
|
686
|
+
return self.channel_json["logo"]["focused"]
|
|
687
|
+
return ""
|
|
688
|
+
|
|
689
|
+
@property
|
|
690
|
+
def linear_products(self) -> list[str]:
|
|
691
|
+
"""Returns the linear products."""
|
|
692
|
+
return self.channel_json.get("linearProducts", [])
|
|
693
|
+
|
|
694
|
+
@property
|
|
695
|
+
def stream_image(self) -> str:
|
|
696
|
+
"""Returns the stream image."""
|
|
697
|
+
image_stream = self.channel_json["imageStream"]
|
|
698
|
+
if "full" in image_stream:
|
|
699
|
+
return image_stream["full"]
|
|
700
|
+
if "small" in image_stream:
|
|
701
|
+
return image_stream["small"]
|
|
702
|
+
if "logo" in self.channel_json and "focused" in self.channel_json["logo"]:
|
|
703
|
+
return self.channel_json["logo"]["focused"]
|
|
704
|
+
return ""
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class LGHorizonServicesConfig:
|
|
708
|
+
"""Handle LG Horizon configuration and service URLs."""
|
|
709
|
+
|
|
710
|
+
def __init__(self, config_data: dict[str, Any]) -> None:
|
|
711
|
+
"""Initialize LG Horizon config.
|
|
712
|
+
|
|
713
|
+
Args:
|
|
714
|
+
config_data: Configuration dictionary with service endpoints
|
|
715
|
+
"""
|
|
716
|
+
self._config = config_data
|
|
717
|
+
|
|
718
|
+
async def get_service_url(self, service_name: str) -> str:
|
|
719
|
+
"""Get the URL for a specific service.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
service_name: Name of the service (e.g., 'authService', 'recordingService')
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
URL for the service
|
|
726
|
+
|
|
727
|
+
Raises:
|
|
728
|
+
ValueError: If the service or its URL is not found
|
|
729
|
+
"""
|
|
730
|
+
if service_name in self._config and "URL" in self._config[service_name]:
|
|
731
|
+
return self._config[service_name]["URL"]
|
|
732
|
+
raise ValueError(f"Service URL for '{service_name}' not found in configuration")
|
|
733
|
+
|
|
734
|
+
async def get_all_services(self) -> dict[str, str]:
|
|
735
|
+
"""Get all available services and their URLs.
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
Dictionary mapping service names to URLs
|
|
739
|
+
"""
|
|
740
|
+
return {
|
|
741
|
+
name: url
|
|
742
|
+
for name, service in self._config.items()
|
|
743
|
+
if isinstance(service, dict) and (url := service.get("URL"))
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async def __getattr__(self, name: str) -> Optional[str]:
|
|
747
|
+
"""Access service URLs as attributes.
|
|
748
|
+
|
|
749
|
+
Example: config.authService returns the auth service URL
|
|
750
|
+
|
|
751
|
+
Args:
|
|
752
|
+
name: Service name
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
URL for the service or None if not found
|
|
756
|
+
"""
|
|
757
|
+
if name.startswith("_"):
|
|
758
|
+
raise AttributeError(
|
|
759
|
+
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|
760
|
+
)
|
|
761
|
+
return await self.get_service_url(name)
|
|
762
|
+
|
|
763
|
+
def __repr__(self) -> str:
|
|
764
|
+
"""Return string representation."""
|
|
765
|
+
services = list(self._config.keys())
|
|
766
|
+
return f"LGHorizonConfig({len(services)} services)"
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
class LGHorizonCustomer:
|
|
770
|
+
"""LGHorizon customer."""
|
|
771
|
+
|
|
772
|
+
_profiles: Dict[str, LGHorizonProfile] = {}
|
|
773
|
+
|
|
774
|
+
def __init__(self, json_payload: dict):
|
|
775
|
+
"""Initialize a customer."""
|
|
776
|
+
self._json_payload = json_payload
|
|
777
|
+
|
|
778
|
+
@property
|
|
779
|
+
def customer_id(self) -> str:
|
|
780
|
+
"""Return the customer id."""
|
|
781
|
+
return self._json_payload["customerId"]
|
|
782
|
+
|
|
783
|
+
@property
|
|
784
|
+
def hashed_customer_id(self) -> str:
|
|
785
|
+
"""Return the hashed customer id."""
|
|
786
|
+
return self._json_payload["hashedCustomerId"]
|
|
787
|
+
|
|
788
|
+
@property
|
|
789
|
+
def country_id(self) -> str:
|
|
790
|
+
"""Return the country id."""
|
|
791
|
+
return self._json_payload["countryId"]
|
|
792
|
+
|
|
793
|
+
@property
|
|
794
|
+
def city_id(self) -> int:
|
|
795
|
+
"""Return the city id."""
|
|
796
|
+
return self._json_payload["cityId"]
|
|
797
|
+
|
|
798
|
+
@property
|
|
799
|
+
def assigned_devices(self) -> list[str]:
|
|
800
|
+
"""Return the assigned set-top boxes."""
|
|
801
|
+
return self._json_payload.get("assignedDevices", [])
|
|
802
|
+
|
|
803
|
+
@property
|
|
804
|
+
def profiles(self) -> Dict[str, LGHorizonProfile]:
|
|
805
|
+
"""Return the profiles."""
|
|
806
|
+
if not self._profiles or self._profiles == {}:
|
|
807
|
+
self._profiles = {
|
|
808
|
+
p["profileId"]: LGHorizonProfile(p)
|
|
809
|
+
for p in self._json_payload.get("profiles", [])
|
|
810
|
+
}
|
|
811
|
+
return self._profiles
|
|
812
|
+
|
|
813
|
+
async def get_profile_lang(self, profile_id: str) -> str:
|
|
814
|
+
"""Return the profile language."""
|
|
815
|
+
if profile_id not in self.profiles:
|
|
816
|
+
return "nl"
|
|
817
|
+
return self.profiles[profile_id].options.lang
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
class LGHorizonDeviceState:
|
|
821
|
+
"""Represent current state of a box."""
|
|
822
|
+
|
|
823
|
+
_channel_id: Optional[str]
|
|
824
|
+
_channel_name: Optional[str]
|
|
825
|
+
_title: Optional[str]
|
|
826
|
+
_image: Optional[str]
|
|
827
|
+
_source_type: LGHorizonSourceType
|
|
828
|
+
_paused: bool
|
|
829
|
+
_sub_title: Optional[str]
|
|
830
|
+
_duration: Optional[float]
|
|
831
|
+
_position: Optional[float]
|
|
832
|
+
_last_position_update: Optional[datetime]
|
|
833
|
+
_state: LGHorizonRunningState
|
|
834
|
+
_speed: Optional[int]
|
|
835
|
+
|
|
836
|
+
def __init__(self) -> None:
|
|
837
|
+
"""Initialize the playing info."""
|
|
838
|
+
self._channel_id = None
|
|
839
|
+
self._title = None
|
|
840
|
+
self._image = None
|
|
841
|
+
self._source_type = LGHorizonSourceType.UNKNOWN
|
|
842
|
+
self._paused = False
|
|
843
|
+
self.sub_title = None
|
|
844
|
+
self._duration = None
|
|
845
|
+
self._position = None
|
|
846
|
+
self._last_position_update = None
|
|
847
|
+
self._state = LGHorizonRunningState.UNKNOWN
|
|
848
|
+
self._speed = None
|
|
849
|
+
self._channel_name = None
|
|
850
|
+
|
|
851
|
+
@property
|
|
852
|
+
def state(self) -> LGHorizonRunningState:
|
|
853
|
+
"""Return the channel ID."""
|
|
854
|
+
return self._state
|
|
855
|
+
|
|
856
|
+
@state.setter
|
|
857
|
+
def state(self, value: LGHorizonRunningState) -> None:
|
|
858
|
+
"""Set the channel ID."""
|
|
859
|
+
self._state = value
|
|
860
|
+
|
|
861
|
+
@property
|
|
862
|
+
def channel_id(self) -> Optional[str]:
|
|
863
|
+
"""Return the channel ID."""
|
|
864
|
+
return self._channel_id
|
|
865
|
+
|
|
866
|
+
@channel_id.setter
|
|
867
|
+
def channel_id(self, value: Optional[str]) -> None:
|
|
868
|
+
"""Set the channel ID."""
|
|
869
|
+
self._channel_id = value
|
|
870
|
+
|
|
871
|
+
@property
|
|
872
|
+
def channel_name(self) -> Optional[str]:
|
|
873
|
+
"""Return the channel ID."""
|
|
874
|
+
return self._channel_name
|
|
875
|
+
|
|
876
|
+
@channel_name.setter
|
|
877
|
+
def channel_name(self, value: Optional[str]) -> None:
|
|
878
|
+
"""Set the channel ID."""
|
|
879
|
+
self._channel_name = value
|
|
880
|
+
|
|
881
|
+
@property
|
|
882
|
+
def title(self) -> Optional[str]:
|
|
883
|
+
"""Return the title."""
|
|
884
|
+
return self._title
|
|
885
|
+
|
|
886
|
+
@title.setter
|
|
887
|
+
def title(self, value: Optional[str]) -> None:
|
|
888
|
+
"""Set the title."""
|
|
889
|
+
self._title = value
|
|
890
|
+
|
|
891
|
+
@property
|
|
892
|
+
def image(self) -> Optional[str]:
|
|
893
|
+
"""Return the image URL."""
|
|
894
|
+
return self._image
|
|
895
|
+
|
|
896
|
+
@image.setter
|
|
897
|
+
def image(self, value: Optional[str]) -> None:
|
|
898
|
+
"""Set the image URL."""
|
|
899
|
+
self._image = value
|
|
900
|
+
|
|
901
|
+
@property
|
|
902
|
+
def source_type(self) -> LGHorizonSourceType:
|
|
903
|
+
"""Return the source type."""
|
|
904
|
+
return self._source_type
|
|
905
|
+
|
|
906
|
+
@source_type.setter
|
|
907
|
+
def source_type(self, value: LGHorizonSourceType) -> None:
|
|
908
|
+
"""Set the source type."""
|
|
909
|
+
self._source_type = value
|
|
910
|
+
|
|
911
|
+
@property
|
|
912
|
+
def paused(self) -> bool:
|
|
913
|
+
"""Return if the media is paused."""
|
|
914
|
+
if self.speed is None:
|
|
915
|
+
return False
|
|
916
|
+
return self.speed == 0
|
|
917
|
+
|
|
918
|
+
@property
|
|
919
|
+
def sub_title(self) -> Optional[str]:
|
|
920
|
+
"""Return the channel title."""
|
|
921
|
+
return self._sub_title
|
|
922
|
+
|
|
923
|
+
@sub_title.setter
|
|
924
|
+
def sub_title(self, value: Optional[str]) -> None:
|
|
925
|
+
"""Set the channel title."""
|
|
926
|
+
self._sub_title = value
|
|
927
|
+
|
|
928
|
+
@property
|
|
929
|
+
def duration(self) -> Optional[float]:
|
|
930
|
+
"""Return the duration of the media."""
|
|
931
|
+
return self._duration
|
|
932
|
+
|
|
933
|
+
@duration.setter
|
|
934
|
+
def duration(self, value: Optional[float]) -> None:
|
|
935
|
+
"""Set the duration of the media."""
|
|
936
|
+
self._duration = value
|
|
937
|
+
|
|
938
|
+
@property
|
|
939
|
+
def position(self) -> Optional[float]:
|
|
940
|
+
"""Return the current position in the media."""
|
|
941
|
+
return self._position
|
|
942
|
+
|
|
943
|
+
@position.setter
|
|
944
|
+
def position(self, value: Optional[float]) -> None:
|
|
945
|
+
"""Set the current position in the media."""
|
|
946
|
+
self._position = value
|
|
947
|
+
|
|
948
|
+
@property
|
|
949
|
+
def last_position_update(self) -> Optional[datetime]:
|
|
950
|
+
"""Return the last time the position was updated."""
|
|
951
|
+
return self._last_position_update
|
|
952
|
+
|
|
953
|
+
@last_position_update.setter
|
|
954
|
+
def last_position_update(self, value: Optional[datetime]) -> None:
|
|
955
|
+
"""Set the last position update time."""
|
|
956
|
+
self._last_position_update = value
|
|
957
|
+
|
|
958
|
+
async def reset_progress(self) -> None:
|
|
959
|
+
"""Reset the progress-related attributes."""
|
|
960
|
+
self.last_position_update = None
|
|
961
|
+
self.duration = None
|
|
962
|
+
self.position = None
|
|
963
|
+
|
|
964
|
+
@property
|
|
965
|
+
def speed(self) -> Optional[int]:
|
|
966
|
+
"""Return the speed."""
|
|
967
|
+
return self._speed
|
|
968
|
+
|
|
969
|
+
@speed.setter
|
|
970
|
+
def speed(self, value: int | None) -> None:
|
|
971
|
+
"""Set the channel ID."""
|
|
972
|
+
self._speed = value
|
|
973
|
+
|
|
974
|
+
async def reset(self) -> None:
|
|
975
|
+
"""Reset all playing information."""
|
|
976
|
+
self.channel_id = None
|
|
977
|
+
self.title = None
|
|
978
|
+
self.sub_title = None
|
|
979
|
+
self.image = None
|
|
980
|
+
self.source_type = LGHorizonSourceType.UNKNOWN
|
|
981
|
+
self.speed = None
|
|
982
|
+
self.channel_name = None
|
|
983
|
+
await self.reset_progress()
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
class LGHorizonEntitlements:
|
|
987
|
+
"""Class to represent entitlements."""
|
|
988
|
+
|
|
989
|
+
def __init__(self, entitlements_json):
|
|
990
|
+
"""Initialize entitlements."""
|
|
991
|
+
self.entitlements_json = entitlements_json
|
|
992
|
+
|
|
993
|
+
@property
|
|
994
|
+
def entitlements(self):
|
|
995
|
+
"""Returns the entitlements."""
|
|
996
|
+
return self.entitlements_json.get("entitlements", [])
|
|
997
|
+
|
|
998
|
+
@property
|
|
999
|
+
def entitlement_ids(self) -> list[str]:
|
|
1000
|
+
"""Returns a list of entitlement IDs."""
|
|
1001
|
+
return [e["id"] for e in self.entitlements if "id" in e]
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
class LGHorizonReplayEvent:
|
|
1005
|
+
"""LGhorizon replay event."""
|
|
1006
|
+
|
|
1007
|
+
def __init__(self, raw_json: dict):
|
|
1008
|
+
"""Initialize an LG Horizon replay event."""
|
|
1009
|
+
self._raw_json = raw_json
|
|
1010
|
+
|
|
1011
|
+
@property
|
|
1012
|
+
def episode_number(self) -> Optional[int]:
|
|
1013
|
+
"""Return the episode number."""
|
|
1014
|
+
return self._raw_json.get("episodeNumber")
|
|
1015
|
+
|
|
1016
|
+
@property
|
|
1017
|
+
def channel_id(self) -> str:
|
|
1018
|
+
"""Return the channel ID."""
|
|
1019
|
+
return self._raw_json["channelId"]
|
|
1020
|
+
|
|
1021
|
+
@property
|
|
1022
|
+
def event_id(self) -> str:
|
|
1023
|
+
"""Return the event ID."""
|
|
1024
|
+
return self._raw_json["eventId"]
|
|
1025
|
+
|
|
1026
|
+
@property
|
|
1027
|
+
def season_number(self) -> Optional[int]:
|
|
1028
|
+
"""Return the season number."""
|
|
1029
|
+
return self._raw_json.get("seasonNumber")
|
|
1030
|
+
|
|
1031
|
+
@property
|
|
1032
|
+
def title(self) -> str:
|
|
1033
|
+
"""Return the title of the event."""
|
|
1034
|
+
return self._raw_json["title"]
|
|
1035
|
+
|
|
1036
|
+
@property
|
|
1037
|
+
def episode_name(self) -> Optional[str]:
|
|
1038
|
+
"""Return the episode name."""
|
|
1039
|
+
return self._raw_json.get("episodeName", None)
|
|
1040
|
+
|
|
1041
|
+
@property
|
|
1042
|
+
def full_episode_title(self) -> Optional[str]:
|
|
1043
|
+
"""Return the full episode title."""
|
|
1044
|
+
|
|
1045
|
+
if not self.season_number and not self.episode_number:
|
|
1046
|
+
return None
|
|
1047
|
+
full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}"""
|
|
1048
|
+
if self.episode_name:
|
|
1049
|
+
full_title += f": {self.episode_name}"
|
|
1050
|
+
return full_title
|
|
1051
|
+
|
|
1052
|
+
def __repr__(self) -> str:
|
|
1053
|
+
"""Return a string representation of the replay event."""
|
|
1054
|
+
return f"LGHorizonReplayEvent(title='{self.title}', channel_id='{self.channel_id}', event_id='{self.event_id}')"
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
class LGHorizonVODType(Enum):
|
|
1058
|
+
"""Enumeration of LG Horizon VOD types."""
|
|
1059
|
+
|
|
1060
|
+
ASSET = "ASSET"
|
|
1061
|
+
EPISODE = "EPISODE"
|
|
1062
|
+
UNKNOWN = "UNKNOWN"
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
class LGHorizonVOD:
|
|
1066
|
+
"""LGHorizon video on demand."""
|
|
1067
|
+
|
|
1068
|
+
def __init__(self, vod_json) -> None:
|
|
1069
|
+
self._vod_json = vod_json
|
|
1070
|
+
|
|
1071
|
+
@property
|
|
1072
|
+
def vod_type(self) -> LGHorizonVODType:
|
|
1073
|
+
"""Return the ID of the VOD."""
|
|
1074
|
+
return LGHorizonVODType[self._vod_json.get("type", "unknown").upper()]
|
|
1075
|
+
|
|
1076
|
+
@property
|
|
1077
|
+
def id(self) -> str:
|
|
1078
|
+
"""Return the ID of the VOD."""
|
|
1079
|
+
return self._vod_json["id"]
|
|
1080
|
+
|
|
1081
|
+
@property
|
|
1082
|
+
def season_number(self) -> Optional[int]:
|
|
1083
|
+
"""Return the season number of the recording."""
|
|
1084
|
+
return self._vod_json.get("seasonNumber", None)
|
|
1085
|
+
|
|
1086
|
+
@property
|
|
1087
|
+
def episode_number(self) -> Optional[int]:
|
|
1088
|
+
"""Return the episode number of the recording."""
|
|
1089
|
+
return self._vod_json.get("episodeNumber", None)
|
|
1090
|
+
|
|
1091
|
+
@property
|
|
1092
|
+
def full_episode_title(self) -> Optional[str]:
|
|
1093
|
+
"""Return the ID of the VOD."""
|
|
1094
|
+
if self.vod_type != LGHorizonVODType.EPISODE:
|
|
1095
|
+
return None
|
|
1096
|
+
if not self.season_number and not self.episode_number:
|
|
1097
|
+
return None
|
|
1098
|
+
full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}"""
|
|
1099
|
+
if self.title:
|
|
1100
|
+
full_title += f": {self.title}"
|
|
1101
|
+
return full_title
|
|
1102
|
+
|
|
1103
|
+
@property
|
|
1104
|
+
def title(self) -> str:
|
|
1105
|
+
"""Return the ID of the VOD."""
|
|
1106
|
+
return self._vod_json["title"]
|
|
1107
|
+
|
|
1108
|
+
@property
|
|
1109
|
+
def series_title(self) -> Optional[str]:
|
|
1110
|
+
"""Return the series title of the VOD."""
|
|
1111
|
+
return self._vod_json.get("seriesTitle", None)
|
|
1112
|
+
|
|
1113
|
+
@property
|
|
1114
|
+
def duration(self) -> float:
|
|
1115
|
+
"""Return the duration of the VOD."""
|
|
1116
|
+
return self._vod_json["duration"]
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
class LGHOrizonRelevantEpisode:
|
|
1120
|
+
"""LGHorizon recording."""
|
|
1121
|
+
|
|
1122
|
+
def __init__(self, episode_json: dict) -> None:
|
|
1123
|
+
"""Abstract base class for LG Horizon recordings."""
|
|
1124
|
+
self._episode_json = episode_json
|
|
1125
|
+
|
|
1126
|
+
@property
|
|
1127
|
+
def recording_state(self) -> LGHorizonRecordingState:
|
|
1128
|
+
"""Return the recording state."""
|
|
1129
|
+
return LGHorizonRecordingState[
|
|
1130
|
+
self._episode_json.get("recordingState", "unknown").upper()
|
|
1131
|
+
]
|
|
1132
|
+
|
|
1133
|
+
@property
|
|
1134
|
+
def season_number(self) -> Optional[int]:
|
|
1135
|
+
"""Return the season number of the recording."""
|
|
1136
|
+
return self._episode_json.get("seasonNumber", None)
|
|
1137
|
+
|
|
1138
|
+
@property
|
|
1139
|
+
def episode_number(self) -> Optional[int]:
|
|
1140
|
+
"""Return the episode number of the recording."""
|
|
1141
|
+
return self._episode_json.get("episodeNumber", None)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
class LGHorizonRecording(ABC):
|
|
1145
|
+
"""Abstract base class for LG Horizon recordings."""
|
|
1146
|
+
|
|
1147
|
+
@property
|
|
1148
|
+
def recording_payload(self) -> dict:
|
|
1149
|
+
"""Return the payload of the message."""
|
|
1150
|
+
return self._recording_payload
|
|
1151
|
+
|
|
1152
|
+
@property
|
|
1153
|
+
def recording_state(self) -> LGHorizonRecordingState:
|
|
1154
|
+
"""Return the recording state."""
|
|
1155
|
+
return LGHorizonRecordingState[
|
|
1156
|
+
self._recording_payload.get("recordingState", "unknown").upper()
|
|
1157
|
+
]
|
|
1158
|
+
|
|
1159
|
+
@property
|
|
1160
|
+
def source(self) -> LGHorizonRecordingSource:
|
|
1161
|
+
"""Return the recording source."""
|
|
1162
|
+
return LGHorizonRecordingSource[
|
|
1163
|
+
self._recording_payload.get("source", "unknown").upper()
|
|
1164
|
+
]
|
|
1165
|
+
|
|
1166
|
+
@property
|
|
1167
|
+
def type(self) -> LGHorizonRecordingType:
|
|
1168
|
+
"""Return the recording source."""
|
|
1169
|
+
return LGHorizonRecordingType[
|
|
1170
|
+
self._recording_payload.get("type", "unknown").upper()
|
|
1171
|
+
]
|
|
1172
|
+
|
|
1173
|
+
@property
|
|
1174
|
+
def id(self) -> str:
|
|
1175
|
+
"""Return the ID of the recording."""
|
|
1176
|
+
return self._recording_payload["id"]
|
|
1177
|
+
|
|
1178
|
+
@property
|
|
1179
|
+
def title(self) -> str:
|
|
1180
|
+
"""Return the title of the recording."""
|
|
1181
|
+
return self._recording_payload["title"]
|
|
1182
|
+
|
|
1183
|
+
@property
|
|
1184
|
+
def channel_id(self) -> str:
|
|
1185
|
+
"""Return the channel ID of the recording."""
|
|
1186
|
+
return self._recording_payload["channelId"]
|
|
1187
|
+
|
|
1188
|
+
@property
|
|
1189
|
+
def poster_url(self) -> Optional[str]:
|
|
1190
|
+
"""Return the title of the recording."""
|
|
1191
|
+
poster = self._recording_payload.get("poster")
|
|
1192
|
+
if poster:
|
|
1193
|
+
return poster.get("url")
|
|
1194
|
+
return None
|
|
1195
|
+
|
|
1196
|
+
def __init__(self, recording_payload: dict) -> None:
|
|
1197
|
+
"""Abstract base class for LG Horizon recordings."""
|
|
1198
|
+
self._recording_payload = recording_payload
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
class LGHorizonRecordingSingle(LGHorizonRecording):
|
|
1202
|
+
"""LGHorizon recording."""
|
|
1203
|
+
|
|
1204
|
+
@property
|
|
1205
|
+
def episode_title(self) -> Optional[str]:
|
|
1206
|
+
"""Return the episode title of the recording."""
|
|
1207
|
+
return self._recording_payload.get("episodeTitle", None)
|
|
1208
|
+
|
|
1209
|
+
@property
|
|
1210
|
+
def season_number(self) -> Optional[int]:
|
|
1211
|
+
"""Return the season number of the recording."""
|
|
1212
|
+
return self._recording_payload.get("seasonNumber", None)
|
|
1213
|
+
|
|
1214
|
+
@property
|
|
1215
|
+
def episode_number(self) -> Optional[int]:
|
|
1216
|
+
"""Return the episode number of the recording."""
|
|
1217
|
+
return self._recording_payload.get("episodeNumber", None)
|
|
1218
|
+
|
|
1219
|
+
@property
|
|
1220
|
+
def show_id(self) -> Optional[str]:
|
|
1221
|
+
"""Return the show ID of the recording."""
|
|
1222
|
+
return self._recording_payload.get("showId", None)
|
|
1223
|
+
|
|
1224
|
+
@property
|
|
1225
|
+
def season_id(self) -> Optional[str]:
|
|
1226
|
+
"""Return the season ID of the recording."""
|
|
1227
|
+
return self._recording_payload.get("seasonId", None)
|
|
1228
|
+
|
|
1229
|
+
@property
|
|
1230
|
+
def full_episode_title(self) -> Optional[str]:
|
|
1231
|
+
"""Return the full episode title of the recording."""
|
|
1232
|
+
if not self.season_number and not self.episode_number:
|
|
1233
|
+
return None
|
|
1234
|
+
full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}"""
|
|
1235
|
+
if self.episode_title:
|
|
1236
|
+
full_title += f": {self.episode_title}"
|
|
1237
|
+
return full_title
|
|
1238
|
+
|
|
1239
|
+
@property
|
|
1240
|
+
def channel_id(self) -> Optional[str]:
|
|
1241
|
+
"""Return the channel ID of the recording."""
|
|
1242
|
+
return self._recording_payload.get("channelId", None)
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
class LGHorizonRecordingSeason(LGHorizonRecording):
|
|
1246
|
+
"""LGHorizon recording."""
|
|
1247
|
+
|
|
1248
|
+
_most_relevant_epsode: Optional[LGHOrizonRelevantEpisode]
|
|
1249
|
+
|
|
1250
|
+
def __init__(self, payload: dict) -> None:
|
|
1251
|
+
"""Abstract base class for LG Horizon recordings."""
|
|
1252
|
+
super().__init__(payload)
|
|
1253
|
+
episode_payload = payload.get("mostRelevantEpisode")
|
|
1254
|
+
if episode_payload:
|
|
1255
|
+
self._most_relevant_epsode = LGHOrizonRelevantEpisode(episode_payload)
|
|
1256
|
+
|
|
1257
|
+
@property
|
|
1258
|
+
def no_of_episodes(self) -> int:
|
|
1259
|
+
"""Return the number of episodes in the season."""
|
|
1260
|
+
return self._recording_payload.get("noOfEpisodes", 0)
|
|
1261
|
+
|
|
1262
|
+
@property
|
|
1263
|
+
def season_title(self) -> str:
|
|
1264
|
+
"""Return the season title of the recording."""
|
|
1265
|
+
return self._recording_payload.get("seasonTitle", "")
|
|
1266
|
+
|
|
1267
|
+
@property
|
|
1268
|
+
def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]:
|
|
1269
|
+
"""Return the most relevant episode of the season."""
|
|
1270
|
+
return self._most_relevant_epsode
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
class LGHorizonRecordingShow(LGHorizonRecording):
|
|
1274
|
+
"""LGHorizon recording."""
|
|
1275
|
+
|
|
1276
|
+
_most_relevant_epsode: Optional[LGHOrizonRelevantEpisode]
|
|
1277
|
+
|
|
1278
|
+
def __init__(self, payload: dict) -> None:
|
|
1279
|
+
"""Abstract base class for LG Horizon recordings."""
|
|
1280
|
+
super().__init__(payload)
|
|
1281
|
+
episode_payload = payload.get("mostRelevantEpisode")
|
|
1282
|
+
if episode_payload:
|
|
1283
|
+
self._most_relevant_epsode = LGHOrizonRelevantEpisode(episode_payload)
|
|
1284
|
+
|
|
1285
|
+
@property
|
|
1286
|
+
def no_of_episodes(self) -> int:
|
|
1287
|
+
"""Return the number of episodes in the season."""
|
|
1288
|
+
return self._recording_payload.get("noOfEpisodes", 0)
|
|
1289
|
+
|
|
1290
|
+
@property
|
|
1291
|
+
def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]:
|
|
1292
|
+
"""Return the most relevant episode of the season."""
|
|
1293
|
+
return self._most_relevant_epsode
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
class LGHorizonRecordingList:
|
|
1297
|
+
"""LGHorizon recording."""
|
|
1298
|
+
|
|
1299
|
+
@property
|
|
1300
|
+
def total(self) -> int:
|
|
1301
|
+
"""Return the total number of recordings."""
|
|
1302
|
+
return len(self._recordings)
|
|
1303
|
+
|
|
1304
|
+
def __init__(self, recordings: List[LGHorizonRecording]) -> None:
|
|
1305
|
+
"""Abstract base class for LG Horizon recordings."""
|
|
1306
|
+
self._recordings = recordings
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
class LGHorizonRecordingQuota:
|
|
1310
|
+
"""LGHorizon recording quota."""
|
|
1311
|
+
|
|
1312
|
+
def __init__(self, quota_json: dict) -> None:
|
|
1313
|
+
"""Initialize the recording quota."""
|
|
1314
|
+
self._quota_json = quota_json
|
|
1315
|
+
|
|
1316
|
+
@property
|
|
1317
|
+
def quota(self) -> int:
|
|
1318
|
+
"""Return the total space in MB."""
|
|
1319
|
+
return self._quota_json.get("quota", 0)
|
|
1320
|
+
|
|
1321
|
+
@property
|
|
1322
|
+
def occupied(self) -> int:
|
|
1323
|
+
"""Return the used space in MB."""
|
|
1324
|
+
return self._quota_json.get("occupied", 0)
|
|
1325
|
+
|
|
1326
|
+
@property
|
|
1327
|
+
def percentage_used(self) -> float:
|
|
1328
|
+
"""Return the percentage of space used."""
|
|
1329
|
+
if self.quota == 0:
|
|
1330
|
+
return 0.0
|
|
1331
|
+
return (self.occupied / self.quota) * 100
|