lghorizon 0.9.0b0__py3-none-any.whl → 0.9.0.dev1__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.
@@ -1,1331 +0,0 @@
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