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