Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ test/nl.py
secrets.json
logfile.log
lghorizon.log
lghorizon_web.log
check_adbreaks.py
mqtt_capture.json
2 changes: 2 additions & 0 deletions lghorizon/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
LGHorizonChannel,
LGHorizonCustomer,
LGHorizonDeviceState,
LGHorizonEntitlements,
LGHorizonProfile,
LGHorizonRecording,
LGHorizonRecordingList,
Expand Down Expand Up @@ -102,6 +103,7 @@
"LGHorizonChannel",
"LGHorizonCustomer",
"LGHorizonDeviceState",
"LGHorizonEntitlements",
"LGHorizonProfile",
"LGHorizonApiError",
"LGHorizonApiConnectionError",
Expand Down
41 changes: 37 additions & 4 deletions lghorizon/lghorizon_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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(
Expand Down
30 changes: 15 additions & 15 deletions lghorizon/lghorizon_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 = {
Expand Down
35 changes: 35 additions & 0 deletions lghorizon/lghorizon_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
Loading