diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d969f96 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.testing.pytestArgs": ["tests"], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} diff --git a/lghorizon/lghorizon_models.py b/lghorizon/lghorizon_models.py index 0105d24..0ba5484 100644 --- a/lghorizon/lghorizon_models.py +++ b/lghorizon/lghorizon_models.py @@ -28,7 +28,14 @@ def _redact_sensitive(data): if not isinstance(data, dict): return data redacted = dict(data) - for key in ("accessToken", "access_token", "refreshToken", "refresh_token", "token", "password"): + for key in ( + "accessToken", + "access_token", + "refreshToken", + "refresh_token", + "token", + "password", + ): if key in redacted: redacted[key] = "***REDACTED***" return redacted @@ -264,13 +271,15 @@ def ad_manifest(self) -> List[LGHorizonAdBreak]: raw_manifest = self._raw_json.get("adManifest", []) breaks = [] for entry in raw_manifest: - breaks.append(LGHorizonAdBreak( - start_ms=entry.get("dStart", 0), - end_ms=entry.get("dEnd", 0), - ad_type=entry.get("adType", "UNKNOWN"), - is_skippable=entry.get("isSkippable", False), - has_counter=entry.get("adCounter", False), - )) + breaks.append( + LGHorizonAdBreak( + start_ms=entry.get("dStart", 0), + end_ms=entry.get("dEnd", 0), + ad_type=entry.get("adType", "UNKNOWN"), + is_skippable=entry.get("isSkippable", False), + has_counter=entry.get("adCounter", False), + ) + ) return breaks @property @@ -574,7 +583,9 @@ def __init__( self._token_expiry = None self._country_code = country_code self._host = COUNTRY_SETTINGS[country_code]["api_url"] - self._use_refresh_token = COUNTRY_SETTINGS[country_code]["use_refreshtoken"] or bool(refresh_token) + self._use_refresh_token = COUNTRY_SETTINGS[country_code][ + "use_refreshtoken" + ] or bool(refresh_token) self._service_config = None self._token_refresh_callback = token_refresh_callback @@ -726,7 +737,9 @@ async def request(self, host: str, path: str, params=None, **kwargs) -> Any: return json_response except ClientResponseError as cre: if cre.status == 401: - _LOGGER.debug("Got 401 from %s, refreshing token and retrying", request_url) + _LOGGER.debug( + "Got 401 from %s, refreshing token and retrying", request_url + ) await self.fetch_access_token() try: web_response = await self.websession.request( @@ -741,7 +754,9 @@ async def request(self, host: str, path: str, params=None, **kwargs) -> Any: ) return json_response except ClientResponseError as retry_cre: - _LOGGER.error("Retry failed for %s: %s", request_url, str(retry_cre)) + _LOGGER.error( + "Retry failed for %s: %s", request_url, str(retry_cre) + ) raise LGHorizonApiConnectionError( f"Unable to call {request_url}. Error:{str(retry_cre)}" ) from retry_cre @@ -867,7 +882,16 @@ def get_service_url(self, service_name: str) -> str: ValueError: If the service or its URL is not found """ if service_name in self._config and "URL" in self._config[service_name]: - return self._config[service_name]["URL"] + url = self._config[service_name]["URL"] + + # Temporary override for broken Ziggo NL EPG server + if "static.spark.ziggogo.tv" in url: + url = url.replace( + "static.spark.ziggogo.tv", "staticqbr-prod-nl.gnp.cloud.ziggogo.tv" + ) + + return url + raise ValueError(f"Service URL for '{service_name}' not found in configuration") def get_all_services(self) -> dict[str, str]: @@ -924,7 +948,9 @@ def recording_retention_period(self) -> Optional[int]: @property def has_cloud_recording(self) -> bool: """Return whether the customer has cloud recording.""" - return bool(self.recording_retention_period and self.recording_retention_period > 0) + return bool( + self.recording_retention_period and self.recording_retention_period > 0 + ) @property def assigned_devices(self) -> list[str]: @@ -948,15 +974,22 @@ def get_profile_lang(self, profile_id: str) -> str: return self.profiles[profile_id].options.lang - @dataclass class LGHorizonDeviceState: """Represent current state of a box.""" - state: LGHorizonRunningState = field(default_factory=lambda: LGHorizonRunningState.UNKNOWN) - source_type: LGHorizonSourceType = field(default_factory=lambda: LGHorizonSourceType.UNKNOWN) - ui_state_type: LGHorizonUIStateType = field(default_factory=lambda: LGHorizonUIStateType.UNKNOWN) - media_type: LGHorizonMediaType = field(default_factory=lambda: LGHorizonMediaType.UNKNOWN) + state: LGHorizonRunningState = field( + default_factory=lambda: LGHorizonRunningState.UNKNOWN + ) + source_type: LGHorizonSourceType = field( + default_factory=lambda: LGHorizonSourceType.UNKNOWN + ) + ui_state_type: LGHorizonUIStateType = field( + default_factory=lambda: LGHorizonUIStateType.UNKNOWN + ) + media_type: LGHorizonMediaType = field( + default_factory=lambda: LGHorizonMediaType.UNKNOWN + ) id: Optional[str] = None channel_id: Optional[str] = None channel_name: Optional[str] = None @@ -1027,7 +1060,6 @@ def reset(self) -> None: self.ad_breaks = [] self.reset_progress() - def cache_linear_metadata(self) -> None: """Cache current linear metadata for fallback when app overlays appear.""" if not self.channel_name or not self.show_title: @@ -1064,9 +1096,15 @@ def restore_linear_metadata(self) -> bool: self.end_time = self._last_good_linear_metadata.get("end_time") self.duration = self._last_good_linear_metadata.get("duration") self.position = self._last_good_linear_metadata.get("position") - self.last_position_update = self._last_good_linear_metadata.get("last_position_update") - self.source_type = self._last_good_linear_metadata.get("source_type", LGHorizonSourceType.LINEAR) - self.media_type = self._last_good_linear_metadata.get("media_type", LGHorizonMediaType.CHANNEL) + self.last_position_update = self._last_good_linear_metadata.get( + "last_position_update" + ) + self.source_type = self._last_good_linear_metadata.get( + "source_type", LGHorizonSourceType.LINEAR + ) + self.media_type = self._last_good_linear_metadata.get( + "media_type", LGHorizonMediaType.CHANNEL + ) return True def clear_linear_metadata_cache(self) -> None: @@ -1620,8 +1658,7 @@ def __init__(self, entry_json: dict) -> None: self._entry_json = entry_json channel_id = entry_json.get("channelId", "") self._events = [ - LGHorizonEpgEvent(ev, channel_id) - for ev in entry_json.get("events", []) + LGHorizonEpgEvent(ev, channel_id) for ev in entry_json.get("events", []) ] @property @@ -1911,12 +1948,16 @@ def duration(self) -> Optional[int]: @property def start_time(self) -> Optional[str]: """Return the display start time (ISO 8601).""" - return self._recording_json.get("displayStartTime") or self._recording_json.get("startTime") + return self._recording_json.get("displayStartTime") or self._recording_json.get( + "startTime" + ) @property def end_time(self) -> Optional[str]: """Return the display end time (ISO 8601).""" - return self._recording_json.get("displayEndTime") or self._recording_json.get("endTime") + return self._recording_json.get("displayEndTime") or self._recording_json.get( + "endTime" + ) @property def rec_start_time(self) -> Optional[str]: @@ -1987,8 +2028,7 @@ def __init__(self, response_json: dict) -> None: self._limit = response_json.get("limit", 0) self._offset = response_json.get("offset", 0) self._recordings = [ - LGHorizonManagedRecording(item) - for item in response_json.get("data", []) + LGHorizonManagedRecording(item) for item in response_json.get("data", []) ] @property diff --git a/pytest.ini b/pytest.ini index 78c5011..b8ffd06 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,4 @@ [pytest] asyncio_mode = auto testpaths = tests +asyncio_default_fixture_loop_scope = function diff --git a/tests/test_mqtt_client.py b/tests/test_mqtt_client.py index d8e4afe..3f457cb 100644 --- a/tests/test_mqtt_client.py +++ b/tests/test_mqtt_client.py @@ -57,9 +57,14 @@ async def _create_client(paho_instance=None): if paho_instance is None: paho_instance = _make_paho_mock() - with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_instance): + with patch( + "lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_instance + ): # tls_set is called via run_in_executor; patch executor to call it synchronously - with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)): + with patch( + "asyncio.AbstractEventLoop.run_in_executor", + new=AsyncMock(return_value=None), + ): client = await LGHorizonMqttClient.create(auth, on_connected, on_message) return client, auth, on_connected, on_message, paho_instance @@ -68,11 +73,21 @@ async def _create_client(paho_instance=None): def _make_direct_client(loop=None): """Directly instantiate LGHorizonMqttClient (bypasses create()).""" if loop is None: - loop = asyncio.get_event_loop() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + # Als er geen loop draait (zoals in jouw synchrone test), maak er een aan + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) auth = _make_mock_auth() on_connected = AsyncMock() on_message = AsyncMock() - return LGHorizonMqttClient(auth, on_connected, on_message, loop), auth, on_connected, on_message + return ( + LGHorizonMqttClient(auth, on_connected, on_message, loop), + auth, + on_connected, + on_message, + ) # =========================================================================== @@ -83,36 +98,62 @@ def _make_direct_client(loop=None): class TestCreate: async def test_returns_instance(self): paho_mock = _make_paho_mock() - with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock): - with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)): + with patch( + "lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock + ): + with patch( + "asyncio.AbstractEventLoop.run_in_executor", + new=AsyncMock(return_value=None), + ): auth = _make_mock_auth() - client = await LGHorizonMqttClient.create(auth, AsyncMock(), AsyncMock()) + client = await LGHorizonMqttClient.create( + auth, AsyncMock(), AsyncMock() + ) assert isinstance(client, LGHorizonMqttClient) async def test_client_id_is_non_empty_string(self): paho_mock = _make_paho_mock() - with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock): - with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)): + with patch( + "lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock + ): + with patch( + "asyncio.AbstractEventLoop.run_in_executor", + new=AsyncMock(return_value=None), + ): auth = _make_mock_auth() - client = await LGHorizonMqttClient.create(auth, AsyncMock(), AsyncMock()) + client = await LGHorizonMqttClient.create( + auth, AsyncMock(), AsyncMock() + ) assert isinstance(client.client_id, str) assert len(client.client_id) > 0 async def test_broker_url_strips_wss_and_port(self): paho_mock = _make_paho_mock() - with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock): - with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)): + with patch( + "lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock + ): + with patch( + "asyncio.AbstractEventLoop.run_in_executor", + new=AsyncMock(return_value=None), + ): auth = _make_mock_auth() - client = await LGHorizonMqttClient.create(auth, AsyncMock(), AsyncMock()) + client = await LGHorizonMqttClient.create( + auth, AsyncMock(), AsyncMock() + ) assert client._mqtt_broker_url == BROKER_URL_STRIPPED async def test_username_password_set_from_auth(self): paho_mock = _make_paho_mock() - with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock): - with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)): + with patch( + "lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock + ): + with patch( + "asyncio.AbstractEventLoop.run_in_executor", + new=AsyncMock(return_value=None), + ): auth = _make_mock_auth() await LGHorizonMqttClient.create(auth, AsyncMock(), AsyncMock()) @@ -120,10 +161,17 @@ async def test_username_password_set_from_auth(self): async def test_paho_client_constructed_with_client_id(self): paho_mock = _make_paho_mock() - with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock) as mock_cls: - with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)): + with patch( + "lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock + ) as mock_cls: + with patch( + "asyncio.AbstractEventLoop.run_in_executor", + new=AsyncMock(return_value=None), + ): auth = _make_mock_auth() - client = await LGHorizonMqttClient.create(auth, AsyncMock(), AsyncMock()) + client = await LGHorizonMqttClient.create( + auth, AsyncMock(), AsyncMock() + ) call_kwargs = mock_cls.call_args # client_id should be set and match the instance's client_id @@ -151,7 +199,9 @@ async def test_calls_connect_with_broker_url_and_port_443(self): client._mqtt_client = paho_mock client._mqtt_broker_url = BROKER_URL_STRIPPED - with patch.object(loop, "run_in_executor", new=AsyncMock(return_value=None)) as mock_exec: + with patch.object( + loop, "run_in_executor", new=AsyncMock(return_value=None) + ) as mock_exec: await client.connect() # run_in_executor should have been called with connect, broker url, port 443 @@ -199,11 +249,17 @@ async def test_skips_if_already_connected(self): client._mqtt_client = paho_mock client._mqtt_broker_url = BROKER_URL_STRIPPED - with patch.object(loop, "run_in_executor", new=AsyncMock(return_value=None)) as mock_exec: + with patch.object( + loop, "run_in_executor", new=AsyncMock(return_value=None) + ) as mock_exec: await client.connect() # connect should NOT have been called via executor - connect_calls = [c for c in mock_exec.call_args_list if len(c.args) > 1 and c.args[1] == paho_mock.connect] + connect_calls = [ + c + for c in mock_exec.call_args_list + if len(c.args) > 1 and c.args[1] == paho_mock.connect + ] assert len(connect_calls) == 0