Metadata-Version: 2.4
Name: lghorizon
Version: 0.12.8
Summary: Python client for Liberty Global Horizon settop boxes
Author-email: Rudolf Offereins <r.offereins@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/sholofly/LGHorizon-python
Project-URL: Issues, https://github.com/Sholofly/lghorizon-python/issues
Keywords: LG,Horizon,API,Settop box
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Operating System :: OS Independent
Classifier: Natural Language :: English
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: paho-mqtt<3,>=2.1
Requires-Dist: aiohttp>=3.8.0
Requires-Dist: backoff>=1.9.0
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-asyncio; extra == "test"
Provides-Extra: lint
Requires-Dist: ruff; extra == "lint"
Dynamic: license-file

# LG Horizon API Python Library

A Python library to interact with and control LG Horizon set-top boxes (Ziggo, Telenet, Virgin Media, UPC, BASE TV). Provides authentication, real-time device monitoring via MQTT, and remote control capabilities.

## Supported Providers

| Code | Provider | Country |
|------|----------|---------|
| `nl` | Ziggo | Netherlands |
| `be-nl` | Telenet | Belgium |
| `be-basetv` | BASE TV | Belgium |
| `ch` | UPC Switzerland | Switzerland |
| `gb` | Virgin Media | United Kingdom |
| `ie` | Virgin Media | Ireland |
| `pl` | UPC | Poland |

## Features

### Authentication
- Username/password and refresh token authentication
- Automatic access token refreshing
- Token refresh callback for persisting new tokens
- Support for provider-specific auth flows

### Device Management
- Discover all set-top boxes on your account
- Device info: manufacturer, model, platform type
- Real-time availability monitoring (online/standby/offline)

### Real-time Status via MQTT
- Live device state changes via callback
- Playback info: channel, show title, episode, season/episode numbers
- Source types: linear TV, replay, VOD, nDVR, localDVR, review buffer, apps
- Media types: channel, movie, episode, app
- Playback position, duration, speed, paused state
- Channel and program images
- Automatic MQTT reconnection with exponential backoff

### Channel Information
- Full channel list with logos and stream images
- Channel number, radio flag, linear products
- Replay pre/post padding info
- Profile-specific favorite channels

### Recording Management
- List all recordings (single, season, show)
- Recording states: recorded, ongoing
- Episode details for season/show recordings
- Recording quota and usage percentage
- Play recordings on a set-top box

### Device Control
- Power on/off
- Play, pause, stop
- Rewind, fast forward
- Channel up/down and direct channel selection
- Record current program
- Set player position (seek)
- Send any remote control key press
- Display custom messages on the TV screen

## Installation

```bash
pip install lghorizon
```

**Requirements**: Python 3.13+, aiohttp, paho-mqtt (2.1 or newer, below 3), backoff

## Quick Start

Create a `secrets.json` file:

```json
{
  "username": "your_username",
  "password": "your_password",
  "country": "nl",
  "timezone": "Europe/Amsterdam"
}
```

> For providers with refresh token auth (Telenet, UPC CH, Virgin Media GB), use `"refresh_token"` instead of username/password.

### Basic usage

```python
import asyncio
import aiohttp
from lghorizon import LGHorizonApi, LGHorizonAuth

async def main():
    async with aiohttp.ClientSession() as session:
        auth = LGHorizonAuth(session, "nl", username="user", password="pass")
        api = LGHorizonApi(auth, profile_id=None)

        try:
            await api.initialize()
            devices = await api.get_devices()

            # Print all devices
            for device in devices.values():
                print(f"{device.device_friendly_name} ({device.manufacturer} {device.model})")
                print(f"  State: {device.device_state.state.value}")
                print(f"  Available: {device.is_available}")

            # Get channels
            channels = await api.get_profile_channels()
            for ch in channels.values():
                print(f"  {ch.channel_number} - {ch.title}")

            # Monitor state changes
            async def on_state_change(device_id: str):
                device = devices[device_id]
                s = device.device_state
                print(f"{device.device_friendly_name}: {s.channel_name} - {s.show_title}")
                print(f"  Source: {s.source_type.value}, Position: {s.position}/{s.duration}")

            for device in devices.values():
                await device.set_callback(on_state_change)

            # Keep running to receive MQTT updates
            await asyncio.Event().wait()

        finally:
            await api.disconnect()

asyncio.run(main())
```

### Device control

```python
device = devices["device-id"]

# Power
await device.turn_on()
await device.turn_off()

# Playback
await device.play()
await device.pause()
await device.stop()
await device.rewind()
await device.fast_forward()

# Channels
await device.next_channel()
await device.previous_channel()
await device.set_channel("NPO 1")

# Recording
await device.record()
await device.play_recording("recording-id")

# Position (milliseconds)
await device.set_player_position(60000)

# Display message on screen
await device.display_message("linear", "Hello from Python!")
```

### Recordings & quota

```python
if api.has_cloud_recording:
    # Quota
    quota = await api.get_recording_quota()
    print(f"Used: {quota.occupied}/{quota.quota} MB ({quota.percentage_used:.1f}%)")

    # All recordings
    recordings = await api.get_all_recordings()
    for rec in recordings.recordings:
        print(f"[{rec.type.value}] {rec.title} ({rec.recording_state.value})")

    # Episodes of a show recording
    episodes = await api.get_show_recording_episodes("show-recording-id")
    for ep in episodes.recordings:
        print(f"  S{ep.season_number}E{ep.episode_number}: {ep.episode_title}")
```

### Authentication callbacks

Both callbacks are plain functions (not coroutines) and are called from the event loop.
Pass them to `LGHorizonAuth`, or set them later on `auth` / through the API.

```python
def on_token_refresh(new_token: str) -> None:
    # Persist the new refresh token for next session
    save_to_storage(new_token)

def on_auth_failure() -> None:
    # The provider no longer accepts the credentials or refresh token,
    # for example because it was revoked while the client was running.
    ask_the_user_to_log_in_again()

auth = LGHorizonAuth(
    session,
    "nl",
    username=username,
    password=password,
    token_refresh_callback=on_token_refresh,
    auth_failure_callback=on_auth_failure,
)
```

`auth_failure_callback` is called once per failure streak, and again after a token request
has succeeded in between. Connection problems do not call it.

## Device State Properties

When monitoring a device, `device.device_state` exposes:

| Property | Type | Description |
|----------|------|-------------|
| `state` | `LGHorizonRunningState` | ONLINE_RUNNING, ONLINE_STANDBY, OFFLINE, etc. |
| `ui_state_type` | `LGHorizonUIStateType` | MAINUI, APPS, UNKNOWN |
| `source_type` | `LGHorizonSourceType` | LINEAR, VOD, NDVR, LOCALDVR, REPLAY, REVIEWBUFFER |
| `media_type` | `LGHorizonMediaType` | CHANNEL, MOVIE, EPISODE, APP |
| `channel_id` | `str \| None` | Current channel ID |
| `channel_name` | `str \| None` | Current channel name |
| `show_title` | `str \| None` | Current show/movie/app title |
| `episode_title` | `str \| None` | Current episode title |
| `season_number` | `int \| None` | Season number |
| `episode_number` | `int \| None` | Episode number |
| `position` | `int \| None` | Playback position in seconds |
| `duration` | `int \| None` | Content duration in seconds |
| `start_time` | `int \| None` | Program start (Unix timestamp) |
| `end_time` | `int \| None` | Program end (Unix timestamp) |
| `speed` | `int \| None` | Playback speed (0 = paused, 1 = normal) |
| `paused` | `bool` | Whether playback is paused |
| `image` | `str \| None` | Content/channel image URL |
| `app_name` | `str \| None` | Active app name (when source is APPS) |

## Error Handling

```python
from lghorizon import (
    LGHorizonApiError,              # Base for connection errors
    LGHorizonApiConnectionError,    # Network/connection issues
    LGHorizonApiUnauthorizedError,  # Invalid credentials or token (not a LGHorizonApiError)
    LGHorizonApiLockedError,        # Subclass of the unauthorized error
)

try:
    await api.initialize()
except LGHorizonApiUnauthorizedError:
    print("Invalid credentials or token")
except LGHorizonApiConnectionError:
    print("Could not connect to the API")
except LGHorizonApiError as e:
    print(f"API error: {e}")
```

`LGHorizonApiUnauthorizedError` is raised when the provider reports invalid credentials or
an invalid token. Other failed calls raise `LGHorizonApiConnectionError`. It does not derive from `LGHorizonApiError`, so catch it
separately. `LGHorizonApiLockedError` exists for locked accounts, but the library does not
raise it yet.

## Debugging

Set the `lghorizon` logger to `debug` to see what the library sends and receives. With Home Assistant:

```yaml
logger:
  logs:
    lghorizon: debug
```

At debug level the MQTT client logs every message it publishes (topic and payload, for example a key press or a
channel change) and every message it receives (shortened to 1000 characters). A command that does nothing on the
box shows up as a `Publishing to ...` line without a matching reaction in the `Received message on ...` lines.
Publishing errors are always logged as warnings.

Tokens are redacted in the logs, but messages contain your household id and the MAC and IP addresses of your
boxes. Remove those before posting a log in an issue.

## Development

### Setup

```bash
git clone https://github.com/Sholofly/lghorizon-python.git
cd lghorizon-python
pip install -e ".[test,lint]"
```

The version is taken from the git tags (`setuptools_scm`), so keep the tags when cloning.

### Running tests

```bash
python -m pytest tests/ -v
```

### Linting

```bash
ruff check .
```

CI runs the same lint check, the tests and a package build on every pull request.

### Running the demo script

1. Create a `secrets.json` (see Quick Start)
2. Run `python main.py`

The demo script prints all profiles, devices, channels, recordings, and then monitors live state changes with a visual progress bar. It also provides an interactive command prompt to control your set-top boxes (send messages, switch channels, etc.). Type `help` for a list of available commands.

### Web-based Test UI

A browser-based interface for testing all library features interactively:

```bash
python web.py
```

Then open [http://localhost:8080](http://localhost:8080) in your browser.

**Features:**
- Login with your provider credentials (username/password or refresh token, depending on provider)
- Live dashboard showing all set-top boxes with real-time state updates via WebSocket
- Full device control: power, playback, channel switching, message display, raw key input
- Event log with timestamped state changes
- Optional "Remember credentials" to save your provider and username between sessions (passwords are never stored)

> **Note:** No additional dependencies are required — `web.py` uses the same `aiohttp` that the library already depends on.

## License

MIT License
