Metadata-Version: 2.4
Name: lghorizon
Version: 0.12.6
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.

## 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
