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
33 changes: 26 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ Keep this file up to date as the codebase evolves — update it when commands, a

`pyblu` is an async Python library for controlling BluOS players via their HTTP API (port 11000). No authentication is required. The library is published to PyPI and uses `uv` for dependency management.

## BluOS API Documentation

Use the official BluOS Custom Integration API PDF linked near the top of `README.md` as the source of truth for endpoints and response formats. Download that document directly instead of searching the web. To make it searchable locally:

```bash
api_url=$(grep -o 'https://[^)]*\.pdf' README.md | head -1)
curl -fL "$api_url" -o /tmp/bluos-api.pdf
pdftotext -layout /tmp/bluos-api.pdf /tmp/bluos-api.txt
rg -n -C 10 '/Playlist|/Delete|/Move|/Save' /tmp/bluos-api.txt
```

The PDF and extracted text are temporary reference files; do not commit them.

## Commands

```bash
Expand All @@ -29,24 +42,28 @@ uv run invoke release # select a stable/dev release (requires GITHUB_

## Architecture

The library has four modules with a clear separation of concerns:
The library has five main modules with a clear separation of concerns:

- **`player.py`** — `Player` class: the public API. Each method makes one HTTP GET request to the BluOS endpoint, passing arguments as query parameters, then delegates the raw response bytes to a parse function. All methods are async and decorated with `@_wrap_in_unreachable_error`.
- **`player.py`** — `Player` class: the public API. Async endpoint methods use `_get()` to make HTTP GET requests, then delegate the raw response bytes to parse functions. `_get()` centralizes transport error handling.

- **`parse.py`** — Stateless XML parsing functions. Each takes `bytes` from the HTTP response and returns a typed entity. Uses `lxml.etree` for parsing. All functions are decorated with `@_wrap_in_unxpected_response_error`.
- **`settings.py`** — Settings API exposed through `Player.settings`. Uses the player's `_get()` callable for requests, sharing its session, timeouts, and transport error handling.

- **`entities.py`** — Pure `@dataclass` types (`Status`, `Volume`, `SyncStatus`, `PairedPlayer`, `PlayQueue`, `Preset`, `Input`). No logic.
- **`parse.py`** — Stateless XML parsing functions. Each takes `bytes` from the HTTP response and returns a typed entity. Uses `lxml.etree` for parsing. All public parse functions are decorated with `@_wrap_in_unxpected_response_error`.

- **`errors.py`** — Exception hierarchy (`PlayerError` → `PlayerUnreachableError` / `PlayerUnexpectedResponseError`) and two decorator factories that wrap exceptions at the Player and parse layers respectively.
- **`entities.py`** — Pure `@dataclass` types for player state, play queues, and media browsing, including `PlayQueue`, `PlayQueueTrack`, `BrowseResult`, `BrowseItem`, and `ContextMenuAction`. No logic.

- **`errors.py`** — Exception hierarchy (`PlayerError` → `PlayerUnreachableError` / `PlayerUnexpectedResponseError` / `PlayerCommandError` / `PlayerBrowseError`) and the decorator for translating parser failures.

### Key Conventions

**Error handling via decorators**: `_wrap_in_unreachable_error` (on Player methods) catches `TimeoutError` and `aiohttp.ClientConnectionError`. `_wrap_in_unxpected_response_error` (on parse functions) catches everything else. Never add try/except inside Player methods or parse functions — let the decorators handle it.
**Centralized error handling**: `Player._get()` catches `TimeoutError` and `aiohttp.ClientConnectionError` and raises `PlayerUnreachableError`. `_wrap_in_unxpected_response_error` on parse functions preserves existing `PlayerError` exceptions and wraps other exceptions in `PlayerUnexpectedResponseError`. Parsers raise `PlayerCommandError` or `PlayerBrowseError` for structured player errors. Do not duplicate try/except handling in endpoint methods or parse functions.

**BluOS API quirks**:
- All operations use HTTP GET, including mutations (play, pause, volume set).
- `inputs()` calls `/RadioBrowse?service=Capture`, not a dedicated inputs endpoint.
- `play_url()` and `play()` both map to the `/Play` endpoint.
- Browse keys, `playURL` / `autoplayURL`, and context-menu action URLs are opaque. They map to `BrowseItem.play_action_url` / `autoplay_action_url` and `ContextMenuAction.action_url`; pass them unchanged to `Player.execute_action()`, never to `Player.play_url()`. Resolve context-menu keys through `context_menu()`; actions may mutate playback, the queue, presets, or service favorites. `execute_action()` passes a relative `yarl.URL(encoded=True)` to `_get()` to preserve its encoding. `_get()` accepts relative strings or `URL` objects, rejects URIs with a scheme or host, and resolves them against the player's base URL; normal endpoint query parameters still use aiohttp's encoding.
- `/Playlist` returns queue metadata as child elements for `length=1`, but as attributes for full and paginated listings; `parse_play_queue()` supports both forms. Optional metadata varies by response and player state: `name`, `modified`, `shuffle`, and `repeat` may be absent and are exposed as `None`.
- The API uses "master/slave" terminology; the library exposes this as "leader/follower".

**Long polling**: `status()` and `sync_status()` accept an `etag` parameter. When provided, `poll_timeout` must be strictly less than `timeout` — the Player method validates this and raises `ValueError` if violated.
Expand All @@ -71,9 +88,11 @@ async def test_example():
assert result.field == expected
```

The action-URL encoding regression test uses a loopback aiohttp server to assert the raw request path, since query-parsing mocks can hide URL canonicalization. It never contacts a real player.

### Adding a New Player Method

1. Add the entity dataclass to `entities.py` if needed, and export it from `__init__.py`.
2. Add a `parse_*` function in `parse.py` decorated with `@_wrap_in_unxpected_response_error`.
3. Add the async method to `Player` in `player.py` decorated with `@_wrap_in_unreachable_error`.
3. Add the async method to `Player` in `player.py`, using `_get()` for the request and delegating the response to the parser.
4. Add tests in `tests/test_player.py` (HTTP mock) and `tests/test_parse.py` (parse logic) as appropriate.
21 changes: 21 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,24 @@ Data Classes
.. autoclass:: pyblu.PlayQueue
:members:

.. autoclass:: pyblu.PlayQueueTrack
:members:

.. autoclass:: pyblu.Input
:members:

.. autoclass:: pyblu.BrowseResult
:members:

.. autoclass:: pyblu.BrowseItem
:members:

.. autoclass:: pyblu.BrowseCategory
:members:

.. autoclass:: pyblu.ContextMenuAction
:members:

Exceptions
----------

Expand All @@ -42,4 +57,10 @@ Exceptions
:members:

.. autoclass:: pyblu.errors.PlayerUnexpectedResponseError
:members:

.. autoclass:: pyblu.errors.PlayerCommandError
:members:

.. autoclass:: pyblu.errors.PlayerBrowseError
:members:
3 changes: 2 additions & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ pyblu
============

This is an Python library for interfacing with BluOS player. It uses the
`BluOS API <https://bluesound-deutschland.de/wp-content/uploads/2022/01/Custom-Integration-API-v1.0_March-2021.pdf>`_
`BluOS API <https://bluos.io/wp-content/uploads/2025/06/BluOS-Custom-Integration-API_v1.7.pdf>`_
to control and query the status of BluOS players.

Basic usage example:
Expand All @@ -19,4 +19,5 @@ Basic usage example:
.. toctree::
:maxdepth: 2

usage
api
94 changes: 94 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
Playing sources and browse actions
===================================

BluOS exposes two different kinds of URL-like values. They are invoked differently.

Source URLs
-----------

:meth:`Player.play_url <pyblu.Player.play_url>` accepts a stream URL or BluOS source identifier and constructs a
``/Play?url=...`` request. The ``url`` values returned by :meth:`Player.inputs <pyblu.Player.inputs>` and
:meth:`Player.presets <pyblu.Player.presets>` are source URLs:

.. code-block:: python

inputs = await player.inputs()
await player.play_url(inputs[0].url)

presets = await player.presets()
await player.play_url(presets[0].url)

await player.play_url("https://example.com/radio.mp3")

Browse action URLs
------------------

The browse API instead returns complete, opaque action URIs. These values may start with ``/Play``, ``/Add``, or
another endpoint, and may contain service-specific query parameters. Pass them unchanged to
:meth:`Player.execute_action <pyblu.Player.execute_action>`; do not pass them to
:meth:`Player.play_url <pyblu.Player.play_url>`.

A :class:`BrowseItem <pyblu.BrowseItem>` may provide two playback actions:

``play_action_url``
The item's default play action.

``autoplay_action_url``
An optional auto-fill action. Depending on the service and item, it may play the item and add subsequent tracks
from the containing album, playlist, or other object to the auto-fill section of the play queue.

Both fields are optional, so check for ``None`` before invoking them:

.. code-block:: python

root = await player.browse()
browse_item = next(item for item in root.items if item.browse_key is not None)
result = await player.browse(key=browse_item.browse_key)
item = result.items[0]

if item.play_action_url is not None:
await player.execute_action(item.play_action_url)

# Use this instead when the service provides an auto-fill action.
if item.autoplay_action_url is not None:
await player.execute_action(item.autoplay_action_url)

For example, an action URI might be ``/Add?service=Service&albumid=1&playnow=1``. It is already a complete player
request. Calling ``player.play_url(item.play_action_url)`` would incorrectly place that complete URI inside a second
``/Play?url=...`` request.

Context-menu actions
--------------------

Context-menu action URLs use the same execution method. Actions can start playback, modify the play queue, add a
preset, or change a service favorite.

Actions can be requested lazily using an item's ``context_menu_key``:

.. code-block:: python

if item.context_menu_key is not None:
actions = await player.context_menu(item.context_menu_key)
for action in actions:
print(action.text, action.type)

if actions:
await player.execute_action(actions[0].action_url)

Alternatively, request inline actions while browsing:

.. code-block:: python

root = await player.browse()
browse_item = next(item for item in root.items if item.browse_key is not None)
result = await player.browse(
key=browse_item.browse_key,
with_context_menu_items=True,
)
item = result.items[0]

if item.context_menu:
await player.execute_action(item.context_menu[0].action_url)

Browse keys and all action URLs are opaque. Do not parse, decode, reconstruct, or otherwise modify them before
passing them back to the same player that returned them.
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"aiohttp >= 3.11.11",
"lxml >= 5.0.0"]
"lxml >= 5.0.0",
"yarl>=1.17.0",
]

[project.urls]
repository = "https://github.com/LouisChrist/pyblu"
Expand Down
23 changes: 22 additions & 1 deletion src/pyblu/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
"""A Python library for controlling BluOS players."""

from .entities import (
BrowseCategory,
BrowseItem,
BrowseResult,
ContextMenuAction,
Input,
ListeningModeValue,
PairedPlayer,
PlayQueue,
PlayQueueTrack,
Preset,
Status,
SubwooferModeValue,
Expand All @@ -13,4 +18,20 @@
)
from .player import Player

__all__ = ["Input", "ListeningModeValue", "PairedPlayer", "PlayQueue", "Player", "Preset", "Status", "SubwooferModeValue", "SyncStatus", "Volume"]
__all__ = [
"BrowseCategory",
"BrowseItem",
"BrowseResult",
"ContextMenuAction",
"Input",
"ListeningModeValue",
"PairedPlayer",
"PlayQueue",
"PlayQueueTrack",
"Player",
"Preset",
"Status",
"SubwooferModeValue",
"SyncStatus",
"Volume",
]
Loading
Loading