diff --git a/.gitignore b/.gitignore index af381e8..5d63c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ test/nl.py secrets.json logfile.log lghorizon.log +lghorizon_web.log +check_adbreaks.py +mqtt_capture.json diff --git a/lghorizon/__init__.py b/lghorizon/__init__.py index 3bafef1..3b0a982 100644 --- a/lghorizon/__init__.py +++ b/lghorizon/__init__.py @@ -8,6 +8,7 @@ LGHorizonChannel, LGHorizonCustomer, LGHorizonDeviceState, + LGHorizonEntitlements, LGHorizonProfile, LGHorizonRecording, LGHorizonRecordingList, @@ -102,6 +103,7 @@ "LGHorizonChannel", "LGHorizonCustomer", "LGHorizonDeviceState", + "LGHorizonEntitlements", "LGHorizonProfile", "LGHorizonApiError", "LGHorizonApiConnectionError", diff --git a/lghorizon/lghorizon_api.py b/lghorizon/lghorizon_api.py index fd7467e..91e50cc 100644 --- a/lghorizon/lghorizon_api.py +++ b/lghorizon/lghorizon_api.py @@ -100,12 +100,36 @@ async def get_profiles(self) -> dict[str, LGHorizonProfile]: @property def has_cloud_recording(self) -> bool: - """Get profile IDs.""" + """Return whether the account supports cloud recording.""" if not self._initialized: raise RuntimeError("LGHorizonApi not initialized") return self._customer.has_cloud_recording + @property + def has_pvr(self) -> bool: + """Return whether the account has PVR (cloud recording) entitlement.""" + if not self._initialized: + raise RuntimeError("LGHorizonApi not initialized") + + return self._entitlements.has_pvr + + @property + def has_local_dvr(self) -> bool: + """Return whether the account has local DVR entitlement.""" + if not self._initialized: + raise RuntimeError("LGHorizonApi not initialized") + + return self._entitlements.has_local_dvr + + @property + def has_recording(self) -> bool: + """Return whether the account supports any recording (cloud or local).""" + if not self._initialized: + raise RuntimeError("LGHorizonApi not initialized") + + return self._entitlements.has_recording + async def get_profile_channels( self, profile_id: Optional[str] = None ) -> Dict[str, LGHorizonChannel]: @@ -227,6 +251,15 @@ async def _on_mqtt_connected(self): async def _on_mqtt_message(self, mqtt_message: dict, mqtt_topic: str): """MQTT message callback.""" + # Route capacity responses directly to the device + if mqtt_message.get("type") == "CPE.capacity": + source = mqtt_message.get("source") + if source: + device = self._devices.get(source, None) + if device: + await device.update_local_recording_capacity(mqtt_message) + return + message = await self._message_factory.create_message(mqtt_topic, mqtt_message) match message.message_type: case LGHorizonMessageType.STATUS: @@ -289,7 +322,7 @@ async def _refresh_channels(self): async def get_all_recordings(self) -> LGHorizonRecordingList: """Retrieve all recordings.""" - if not self._customer.has_cloud_recording: + if not self._entitlements.has_recording: return LGHorizonRecordingList([]) _LOGGER.debug("Retrieving recordings...") service_url = self._service_config.get_service_url("recordingService") @@ -305,7 +338,7 @@ async def get_show_recordings( self, show_id: str, channel_id: str ) -> LGHorizonShowRecordingList: # type: ignore[valid-type] """Retrieve all recordings.""" - if not self._customer.has_cloud_recording: + if not self._entitlements.has_recording: return LGHorizonShowRecordingList(None, None, []) _LOGGER.debug("Retrieving recordings fro show...") service_url = self._service_config.get_service_url("recordingService") @@ -320,7 +353,7 @@ async def get_show_recordings( async def get_recording_quota(self) -> LGHorizonRecordingQuota: """Refresh recording quota.""" _LOGGER.debug("Refreshing recording quota...") - if not self._customer.has_cloud_recording: + if not self._entitlements.has_recording: return LGHorizonRecordingQuota({}) service_url = self._service_config.get_service_url("recordingService") quota_json = await self.auth.request( diff --git a/lghorizon/lghorizon_device.py b/lghorizon/lghorizon_device.py index 68d5b56..0934c04 100644 --- a/lghorizon/lghorizon_device.py +++ b/lghorizon/lghorizon_device.py @@ -45,7 +45,7 @@ class LGHorizonDevice: _device_state: LGHorizonDeviceState _manufacturer: Optional[str] _model: Optional[str] - _recording_capacity: Optional[int] + _local_recording_capacity: Optional[int] _device_state_processor: LGHorizonDeviceStateProcessor _mqtt_client: LGHorizonMqttClient _change_callback: Callable[[str], Coroutine[Any, Any, Any]] @@ -72,7 +72,7 @@ def __init__( self._device_state = LGHorizonDeviceState() # Initialize state self._manufacturer = None self._model = None - self._recording_capacity = None + self._local_recording_capacity = None self._device_state_processor = device_state_processor self._change_callback = None @@ -122,14 +122,14 @@ def device_state(self) -> LGHorizonDeviceState: return self._device_state @property - def recording_capacity(self) -> Optional[int]: - """Return the recording capacity used.""" - return self._recording_capacity + def local_recording_capacity(self) -> Optional[int]: + """Return the local HDD recording capacity used.""" + return self._local_recording_capacity - @recording_capacity.setter - def recording_capacity(self, value: int) -> None: - """Set the recording capacity used.""" - self._recording_capacity = value + @local_recording_capacity.setter + def local_recording_capacity(self, value: int) -> None: + """Set the local HDD recording capacity used.""" + self._local_recording_capacity = value @property def last_ui_message_timestamp(self) -> int: @@ -169,7 +169,7 @@ async def set_callback( # Always request current state from the box so we get an initial # UI status even when the box is already ONLINE_RUNNING at startup. await self._request_settop_box_state() - await self._request_settop_box_recording_capacity() + await self._request_settop_box_local_recording_capacity() async def handle_status_message( self, status_message: LGHorizonStatusMessage @@ -192,7 +192,7 @@ async def handle_status_message( await self._request_settop_box_state() await self._trigger_callback() - await self._request_settop_box_recording_capacity() + await self._request_settop_box_local_recording_capacity() async def handle_ui_status_message( self, status_message: LGHorizonUIStatusMessage @@ -205,11 +205,11 @@ async def handle_ui_status_message( self.last_ui_message_timestamp = status_message.message_timestamp await self._trigger_callback() - async def update_recording_capacity(self, payload) -> None: - """Updates the recording capacity.""" + async def update_local_recording_capacity(self, payload) -> None: + """Updates the local recording capacity from a CPE.capacity response.""" if "CPE.capacity" not in payload or "used" not in payload: return - self.recording_capacity = payload["used"] # Use the setter + self.local_recording_capacity = payload["used"] async def _trigger_callback(self): """Trigger the registered callback function. @@ -453,7 +453,7 @@ async def _request_settop_box_state(self) -> None: } await self._mqtt_client.publish_message(topic, json.dumps(payload)) - async def _request_settop_box_recording_capacity(self) -> None: + async def _request_settop_box_local_recording_capacity(self) -> None: """Send mqtt message to receive state from settop box.""" topic = f"{self._auth.household_id}/{self.device_id}" payload = { diff --git a/lghorizon/lghorizon_models.py b/lghorizon/lghorizon_models.py index 1bb9f6a..0105d24 100644 --- a/lghorizon/lghorizon_models.py +++ b/lghorizon/lghorizon_models.py @@ -1104,6 +1104,26 @@ def entitlement_ids(self) -> list[str]: """Returns a list of entitlement IDs.""" return [e["id"] for e in self.entitlements if "id" in e] + @property + def features(self) -> list[str]: + """Returns the list of feature flags (e.g. 'PVR', 'LOCALDVR').""" + return self.entitlements_json.get("features", []) + + @property + def has_pvr(self) -> bool: + """Return whether the account supports cloud recording (PVR/NDVR).""" + return "PVR" in self.features + + @property + def has_local_dvr(self) -> bool: + """Return whether the account supports local recording (LOCALDVR).""" + return "LOCALDVR" in self.features + + @property + def has_recording(self) -> bool: + """Return whether the account supports any recording (cloud or local).""" + return self.has_pvr or self.has_local_dvr + class LGHorizonReplayEvent: """LGhorizon replay event.""" @@ -1300,6 +1320,21 @@ def channel_id(self) -> str: """Return the channel ID of the recording.""" return self._recording_payload["channelId"] + @property + def recording_type(self) -> str: + """Return the recording type (e.g. 'nDVR', 'localDVR', 'LDVR').""" + return self._recording_payload.get("recordingType", "") + + @property + def cpe_id(self) -> Optional[str]: + """Return the CPE device ID. Only present for local DVR recordings.""" + return self._recording_payload.get("cpeId", None) + + @property + def is_local_recording(self) -> bool: + """Return whether this is a local DVR recording.""" + return self.cpe_id is not None + @property def poster_url(self) -> Optional[str]: """Return the poster URL of the recording.""" diff --git a/tests/test_api.py b/tests/test_api.py index cca6cce..4c829cc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -8,6 +8,7 @@ from lghorizon.lghorizon_models import ( LGHorizonAuth, LGHorizonChannel, + LGHorizonEntitlements, LGHorizonCustomer, LGHorizonEpg, LGHorizonEventDetail, @@ -147,6 +148,86 @@ def test_returns_false_when_no_recording_retention( assert api.has_cloud_recording is False +# --------------------------------------------------------------------------- +# has_pvr / has_local_dvr properties +# --------------------------------------------------------------------------- + + +class TestHasPvr: + def test_raises_if_not_initialized(self, mock_auth): + api = make_api(mock_auth) + with pytest.raises(RuntimeError, match="not initialized"): + _ = api.has_pvr + + def test_returns_true_when_pvr_feature_present( + self, mock_auth, sample_customer_json, sample_channel_json + ): + api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + api._entitlements = LGHorizonEntitlements({"features": ["PVR", "LOCALDVR"]}) + assert api.has_pvr is True + + def test_returns_false_when_pvr_feature_absent( + self, mock_auth, sample_customer_json, sample_channel_json + ): + api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + api._entitlements = LGHorizonEntitlements({"features": ["LOCALDVR"]}) + assert api.has_pvr is False + + +class TestHasLocalDvr: + def test_raises_if_not_initialized(self, mock_auth): + api = make_api(mock_auth) + with pytest.raises(RuntimeError, match="not initialized"): + _ = api.has_local_dvr + + def test_returns_true_when_localdvr_feature_present( + self, mock_auth, sample_customer_json, sample_channel_json + ): + api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + api._entitlements = LGHorizonEntitlements({"features": ["LOCALDVR"]}) + assert api.has_local_dvr is True + + def test_returns_false_when_localdvr_feature_absent( + self, mock_auth, sample_customer_json, sample_channel_json + ): + api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + api._entitlements = LGHorizonEntitlements({"features": ["PVR"]}) + assert api.has_local_dvr is False + + +# --------------------------------------------------------------------------- +# has_recording property +# --------------------------------------------------------------------------- + + +class TestHasRecording: + def test_raises_if_not_initialized(self, mock_auth): + api = make_api(mock_auth) + with pytest.raises(RuntimeError, match="not initialized"): + _ = api.has_recording + + def test_returns_true_when_pvr_feature_present( + self, mock_auth, sample_customer_json, sample_channel_json + ): + api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + api._entitlements = LGHorizonEntitlements({"features": ["PVR"]}) + assert api.has_recording is True + + def test_returns_true_when_localdvr_feature_present( + self, mock_auth, sample_customer_json, sample_channel_json + ): + api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + api._entitlements = LGHorizonEntitlements({"features": ["LOCALDVR"]}) + assert api.has_recording is True + + def test_returns_false_when_no_recording_features( + self, mock_auth, sample_customer_json, sample_channel_json + ): + api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + api._entitlements = LGHorizonEntitlements({"features": []}) + assert api.has_recording is False + + # --------------------------------------------------------------------------- # get_profile_channels() # --------------------------------------------------------------------------- @@ -220,7 +301,7 @@ async def test_returns_all_channels_if_no_profiles_exist( class TestGetAllRecordings: - async def test_returns_empty_list_if_no_cloud_recording( + async def test_returns_empty_list_if_no_recording_entitlement( self, mock_auth, sample_channel_json ): no_recording_json = { @@ -235,15 +316,17 @@ async def test_returns_empty_list_if_no_cloud_recording( api = make_api(mock_auth) api._initialized = True api._customer = LGHorizonCustomer(no_recording_json) + api._entitlements = LGHorizonEntitlements({"features": []}) result = await api.get_all_recordings() assert isinstance(result, LGHorizonRecordingList) - # No cloud recording → recording service should NOT have been called + # No recording entitlement → recording service should NOT have been called mock_auth.request.assert_not_called() async def test_calls_recording_service_when_cloud_recording_available( self, mock_auth, sample_customer_json, sample_channel_json ): api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + api._entitlements = LGHorizonEntitlements({"features": ["PVR"]}) # Provide a service config mock so get_service_url works service_config = MagicMock() service_config.get_service_url = MagicMock(return_value="https://recording.example.com") @@ -267,7 +350,7 @@ async def test_calls_recording_service_when_cloud_recording_available( class TestGetRecordingQuota: - async def test_returns_empty_quota_if_no_cloud_recording(self, mock_auth): + async def test_returns_empty_quota_if_no_recording_entitlement(self, mock_auth): no_recording_json = { "customerId": "cust-x", "hashedCustomerId": "hashed-x", @@ -280,6 +363,7 @@ async def test_returns_empty_quota_if_no_cloud_recording(self, mock_auth): api = make_api(mock_auth) api._initialized = True api._customer = LGHorizonCustomer(no_recording_json) + api._entitlements = LGHorizonEntitlements({"features": []}) result = await api.get_recording_quota() assert isinstance(result, LGHorizonRecordingQuota) mock_auth.request.assert_not_called() @@ -288,6 +372,7 @@ async def test_calls_quota_api_when_cloud_recording_available( self, mock_auth, sample_customer_json, sample_channel_json ): api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + api._entitlements = LGHorizonEntitlements({"features": ["PVR"]}) service_config = MagicMock() service_config.get_service_url = MagicMock(return_value="https://recording.example.com") api._service_config = service_config @@ -338,8 +423,9 @@ async def test_routes_ui_status_message_to_correct_device( self, mock_auth, sample_customer_json, sample_channel_json, sample_ui_status_payload ): api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) - mock_device = _make_mock_device(state=LGHorizonRunningState.ONLINE_RUNNING) + mock_device = _make_mock_device() api._devices = {"device-1": mock_device} + mock_device.device_state.state = LGHorizonRunningState.ONLINE_RUNNING ui_msg = MagicMock(spec=LGHorizonUIStatusMessage) ui_msg.message_type = LGHorizonMessageType.UI_STATUS @@ -353,6 +439,26 @@ async def test_routes_ui_status_message_to_correct_device( mock_device.handle_ui_status_message.assert_awaited_once_with(ui_msg) mock_device.handle_status_message.assert_not_awaited() + async def test_routes_capacity_message_to_correct_device( + self, mock_auth, sample_customer_json, sample_channel_json + ): + api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + mock_device = MagicMock(spec=LGHorizonDevice) + mock_device.update_local_recording_capacity = AsyncMock() + api._devices = {"device-1": mock_device} + payload = {"type": "CPE.capacity", "source": "device-1", "used": 50} + await api._on_mqtt_message(payload, "household-123/device-1") + mock_device.update_local_recording_capacity.assert_awaited_once_with(payload) + + async def test_ignores_capacity_message_for_unknown_device( + self, mock_auth, sample_customer_json, sample_channel_json + ): + api = make_initialized_api(mock_auth, sample_customer_json, sample_channel_json) + api._devices = {} + payload = {"type": "CPE.capacity", "source": "unknown-device", "used": 50} + # Should not raise + await api._on_mqtt_message(payload, "household-123/unknown-device") + async def test_ignores_status_message_for_unknown_device( self, mock_auth, sample_customer_json, sample_channel_json, sample_status_payload ): diff --git a/tests/test_device.py b/tests/test_device.py index fbcfb78..763d14a 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -157,7 +157,7 @@ async def test_set_callback_stores_callback(device): async def test_set_callback_calls_register_mqtt(device, mqtt_client, mock_auth): callback = AsyncMock() await device.set_callback(callback) - # register_mqtt + _request_settop_box_state + _request_settop_box_recording_capacity + # register_mqtt + _request_settop_box_state + _request_settop_box_local_recording_capacity assert mqtt_client.publish_message.call_count == 3 # First call: register our own HGO status topic, payload_str = mqtt_client.publish_message.call_args_list[0][0] diff --git a/tests/test_models.py b/tests/test_models.py index 7346deb..9133431 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -874,6 +874,42 @@ def test_poster_url_none_when_missing(self): assert r.poster_url is None +class TestLGHorizonRecordingBaseProperties: + """Tests for properties added to the LGHorizonRecording base class.""" + + def test_recording_type_ndvr(self, sample_recording_single_json): + sample_recording_single_json["recordingType"] = "nDVR" + r = LGHorizonRecordingSingle(sample_recording_single_json) + assert r.recording_type == "nDVR" + + def test_recording_type_local_dvr(self, sample_recording_single_json): + sample_recording_single_json["recordingType"] = "localDVR" + r = LGHorizonRecordingSingle(sample_recording_single_json) + assert r.recording_type == "localDVR" + + def test_recording_type_default_empty(self, sample_recording_single_json): + r = LGHorizonRecordingSingle(sample_recording_single_json) + assert r.recording_type == "" + + def test_cpe_id_present(self, sample_recording_single_json): + sample_recording_single_json["cpeId"] = "3C36E4-EOSSTB-003597101009" + r = LGHorizonRecordingSingle(sample_recording_single_json) + assert r.cpe_id == "3C36E4-EOSSTB-003597101009" + + def test_cpe_id_none_when_missing(self, sample_recording_single_json): + r = LGHorizonRecordingSingle(sample_recording_single_json) + assert r.cpe_id is None + + def test_is_local_recording_true(self, sample_recording_single_json): + sample_recording_single_json["cpeId"] = "some-device-id" + r = LGHorizonRecordingSingle(sample_recording_single_json) + assert r.is_local_recording is True + + def test_is_local_recording_false(self, sample_recording_single_json): + r = LGHorizonRecordingSingle(sample_recording_single_json) + assert r.is_local_recording is False + + class TestLGHorizonRecordingSeason: def test_id(self, sample_recording_season_json): r = LGHorizonRecordingSeason(sample_recording_season_json) @@ -1219,6 +1255,59 @@ def test_entitlement_ids_skips_missing_id(self): e = LGHorizonEntitlements(data) assert e.entitlement_ids == ["ent-1"] + def test_features(self): + data = {"features": ["PVR", "LOCALDVR", "VOD"]} + e = LGHorizonEntitlements(data) + assert e.features == ["PVR", "LOCALDVR", "VOD"] + + def test_features_empty(self): + e = LGHorizonEntitlements({}) + assert e.features == [] + + def test_has_pvr_true(self): + e = LGHorizonEntitlements({"features": ["PVR", "LOCALDVR"]}) + assert e.has_pvr is True + + def test_has_pvr_false(self): + e = LGHorizonEntitlements({"features": ["LOCALDVR"]}) + assert e.has_pvr is False + + def test_has_pvr_false_when_empty(self): + e = LGHorizonEntitlements({}) + assert e.has_pvr is False + + def test_has_local_dvr_true(self): + e = LGHorizonEntitlements({"features": ["PVR", "LOCALDVR"]}) + assert e.has_local_dvr is True + + def test_has_local_dvr_false(self): + e = LGHorizonEntitlements({"features": ["PVR"]}) + assert e.has_local_dvr is False + + def test_has_local_dvr_false_when_empty(self): + e = LGHorizonEntitlements({}) + assert e.has_local_dvr is False + + def test_has_recording_true_when_pvr(self): + e = LGHorizonEntitlements({"features": ["PVR"]}) + assert e.has_recording is True + + def test_has_recording_true_when_local_dvr(self): + e = LGHorizonEntitlements({"features": ["LOCALDVR"]}) + assert e.has_recording is True + + def test_has_recording_true_when_both(self): + e = LGHorizonEntitlements({"features": ["PVR", "LOCALDVR"]}) + assert e.has_recording is True + + def test_has_recording_false_when_empty(self): + e = LGHorizonEntitlements({}) + assert e.has_recording is False + + def test_has_recording_false_when_no_pvr_or_localdvr(self): + e = LGHorizonEntitlements({"features": ["VOD"]}) + assert e.has_recording is False + # --------------------------------------------------------------------------- # LGHorizonEpgEvent diff --git a/web.py b/web.py index 9df88ea..a745c98 100644 --- a/web.py +++ b/web.py @@ -292,6 +292,53 @@ async def get_services(request: web.Request): return web.json_response({"services": services}) +@routes.get("/api/recording-capacity") +async def get_recording_capacity(request: web.Request): + """Return cloud recording quota and per-device local recording capacity.""" + if not app_state["connected"]: + return web.json_response({"error": "Not connected."}, status=401) + + api = app_state["api"] + + # Entitlement flags + full feature list + entitlements = { + "features": api._entitlements.features, + "has_pvr": api.has_pvr, + "has_local_dvr": api.has_local_dvr, + "has_recording": api.has_recording, + } + + # Cloud quota (account-level) + cloud_quota = None + if api.has_pvr: + try: + quota = await api.get_recording_quota() + cloud_quota = { + "quota_mb": quota.quota, + "occupied_mb": quota.occupied, + "percentage_used": round(quota.percentage_used, 1), + } + except Exception as e: + _LOGGER.error("Failed to fetch cloud quota: %s", e) + cloud_quota = {"error": str(e)} + + # Per-device local recording capacity + local_devices = {} + if api.has_local_dvr: + for dev_id, device in app_state["devices"].items(): + capacity = device.local_recording_capacity + local_devices[dev_id] = { + "name": device.device_friendly_name, + "local_recording_capacity_used": capacity, + } + + return web.json_response({ + "entitlements": entitlements, + "cloud_quota": cloud_quota, + "local_devices": local_devices, + }) + + @routes.post("/api/explore") async def explore_service(request: web.Request): """Probe any service endpoint and return raw JSON response. @@ -463,8 +510,8 @@ def create_app() -> web.Application: if __name__ == "__main__": - print("┌────────────────────────────────────────┐") - print("│ LG Horizon Test UI │") - print("│ Open: http://localhost:8080 │") - print("└────────────────────────────────────────┘") + print("+----------------------------------------+") + print("| LG Horizon Test UI |") + print("| Open: http://localhost:8080 |") + print("+----------------------------------------+") web.run_app(create_app(), host="0.0.0.0", port=8080) diff --git a/web_ui.html b/web_ui.html index d288a56..ac62cf8 100644 --- a/web_ui.html +++ b/web_ui.html @@ -236,6 +236,49 @@