Skip to content
Open
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
21 changes: 18 additions & 3 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 @@ -33,11 +46,11 @@ The library has four 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`.

- **`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`.
- **`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`.

- **`entities.py`** — Pure `@dataclass` types (`Status`, `Volume`, `SyncStatus`, `PairedPlayer`, `PlayQueue`, `Preset`, `Input`). No logic.
- **`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`) and two decorator factories that wrap exceptions at the Player and parse layers respectively.
- **`errors.py`** — Exception hierarchy (`PlayerError` → `PlayerUnreachableError` / `PlayerUnexpectedResponseError` / `PlayerCommandError` / `PlayerBrowseError`) and decorators/helpers for translating transport, parser, and structured player errors.

### Key Conventions

Expand All @@ -47,6 +60,8 @@ The library has four modules with a clear separation of concerns:
- 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.
- `/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 Down
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.
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",
]
121 changes: 115 additions & 6 deletions src/pyblu/entities.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field


@dataclass
Expand Down Expand Up @@ -127,16 +127,48 @@ class Volume:
"""Mute status"""


@dataclass
class PlayQueueTrack:
id: int
"""Position of the track in the play queue, starting from 0."""
title: str | None = None
"""Track title."""
artist: str | None = None
"""Artist name."""
album: str | None = None
"""Album name."""
filename: str | None = None
"""Service-specific filename. Treat this as an opaque value."""
image: str | None = None
"""URL of the track artwork."""
duration: float | None = None
"""Track duration in seconds."""
service: str | None = None
"""Music service that supplied the track."""
song_id: str | None = None
"""Service-specific song id."""
album_id: str | None = None
"""Service-specific album id."""
artist_id: str | None = None
"""Service-specific artist id."""


@dataclass
class PlayQueue:
id: str
"""Unique id for the current play queue state. Changes whenever the play queue changes."""
shuffle: bool
"""PlayQueue is shuffled"""
modified: bool
"""PlayQueue was modified since it was loaded"""
shuffle: bool | None
"""Whether the play queue is shuffled, or *None* if the response does not include the shuffle state."""
modified: bool | None
"""Whether the play queue was modified since it was loaded, or *None* if the response does not include this state."""
length: int
"""Number of tracks in the play queue"""
"""Total number of tracks in the play queue, including tracks not returned by a paginated request."""
name: str | None = None
"""Name of the current play queue."""
repeat: int | None = None
"""Repeat mode: 0 repeats the queue, 1 repeats the current track, and 2 disables repeat."""
tracks: list[PlayQueueTrack] = field(default_factory=list)
"""Tracks returned by the request. Empty for a status-only request or an empty queue."""


@dataclass
Expand Down Expand Up @@ -165,6 +197,83 @@ class Input:
"""URL to play the input. Can be passed to *play_url*"""


@dataclass
class ContextMenuAction:
type: str
"""Service-specific action type. Treat unknown values as a display hint only."""
text: str | None
"""Human-readable action label."""
action_url: str
"""Opaque relative action URI. Pass it unchanged to *Player.execute_action*."""


@dataclass
class BrowseItem:
type: str
"""Item type. Common values are "link" (descend with *browse_key*), "audio" (playable), "album", "track",
"artist", "playlist", "folder", "section", "text". The list is open — treat unknown values as a display hint only."""
text: str | None
"""Primary display label."""
text2: str | None
"""Secondary display label from the BluOS ``text2`` attribute.
The meaning is service-specific: it may be an artist, station slogan, current show, date, or another subtitle."""
image: str | None
"""Icon or artwork URL."""
play_action_url: str | None
"""Opaque relative URI from the item's *playURL* attribute. Pass it unchanged to *Player.execute_action*.
*None* if the item does not provide a default play action. Do not pass this value to *Player.play_url*."""
browse_key: str | None
"""Opaque key. Pass to *Player.browse* to descend into this item. *None* if the item is a leaf."""
input_type: str | None
"""Input kind for items that represent a physical input (e.g. "bluetooth", "arc", "spdif"). Usually only set on the root menu."""
context_menu_key: str | None
"""Opaque key for this item's context menu. Pass it to *Player.context_menu*."""
context_menu: list[ContextMenuAction]
"""Inline context-menu actions. Usually empty because BluOS normally supplies *context_menu_key* instead."""
autoplay_action_url: str | None = None
"""Opaque relative URI from the item's *autoplayURL* attribute. Pass it unchanged to *Player.execute_action*.
*None* if the item does not provide an auto-fill play action. Do not pass this value to *Player.play_url*."""
duration: int | None = None
"""Duration in seconds for a track or collection."""
is_favourite: bool | None = None
"""Whether the item is a favourite."""
tracks: int | None = None
"""Number of tracks in a collection."""


@dataclass
class BrowseCategory:
text: str | None
"""Category heading."""
next_key: str | None
"""Opaque key for the next page of items in this category. Pass to *Player.browse*."""
parent_key: str | None
"""Opaque key for navigating up from this category. Pass to *Player.browse*."""
items: list[BrowseItem]
"""Items in this category."""


@dataclass
class BrowseResult:
type: str
"""Result list type. Common values are "menu", "items", "albums", "tracks", "playlists", "sections", "folders"."""
service_name: str | None
"""Human-readable service name, suitable for UI."""
service_icon: str | None
"""URL of an icon for the service."""
search_key: str | None
"""Opaque key for searching the current service. Pass to *Player.browse* together with the **q**
parameter (the search term). *None* if search is not available here."""
next_key: str | None
"""Opaque key for the next page of results. Pass to *Player.browse*."""
parent_key: str | None
"""Opaque key for navigating up the hierarchy. Pass to *Player.browse*."""
items: list[BrowseItem]
"""Top-level items. Empty when the response is grouped into *categories*."""
categories: list[BrowseCategory]
"""Categories. Empty unless the response groups items under headings."""


@dataclass
class ListeningModeValue:
name: str
Expand Down
Loading
Loading