From df0c2c44dc6c6c7d57acdd96b852a42497c9c48d Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Sun, 17 May 2026 10:07:07 +0000 Subject: [PATCH 01/16] Add media browsing Throwaway uv-script that crawls a BluOS device's /Browse endpoint and saves raw XML responses to scripts/bluos_scrape/responses/. Used to gather concrete reference data for designing a content-browsing client. --- docs/api.rst | 12 ++++ src/pyblu/__init__.py | 27 +++++++- src/pyblu/entities.py | 52 +++++++++++++++ src/pyblu/errors.py | 19 +++++- src/pyblu/parse.py | 84 +++++++++++++++++++++++- src/pyblu/player.py | 28 +++++++- tests/test_parse.py | 149 +++++++++++++++++++++++++++++++++++++++++- tests/test_player.py | 64 +++++++++++++++++- 8 files changed, 424 insertions(+), 11 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 4d17278..5d585be 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -32,6 +32,15 @@ Data Classes .. autoclass:: pyblu.Input :members: +.. autoclass:: pyblu.BrowseResult + :members: + +.. autoclass:: pyblu.BrowseItem + :members: + +.. autoclass:: pyblu.BrowseCategory + :members: + Exceptions ---------- @@ -42,4 +51,7 @@ Exceptions :members: .. autoclass:: pyblu.errors.PlayerUnexpectedResponseError + :members: + +.. autoclass:: pyblu.errors.PlayerBrowseError :members: \ No newline at end of file diff --git a/src/pyblu/__init__.py b/src/pyblu/__init__.py index 3010839..915b385 100644 --- a/src/pyblu/__init__.py +++ b/src/pyblu/__init__.py @@ -1,6 +1,29 @@ """A Python library for controlling BluOS players.""" from .player import Player -from .entities import Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input +from .entities import ( + BrowseCategory, + BrowseItem, + BrowseResult, + Input, + PairedPlayer, + PlayQueue, + Preset, + Status, + SyncStatus, + Volume, +) -__all__ = ["Player", "Status", "Volume", "SyncStatus", "PairedPlayer", "PlayQueue", "Preset", "Input"] +__all__ = [ + "Player", + "Status", + "Volume", + "SyncStatus", + "PairedPlayer", + "PlayQueue", + "Preset", + "Input", + "BrowseResult", + "BrowseItem", + "BrowseCategory", +] diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index 90c5b54..3d03816 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -163,3 +163,55 @@ class Input: """URL of the input image""" url: str """URL to play the input. Can be passed to *play_url*""" + + +@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 label.""" + text2: str | None + """Secondary label (e.g. artist when the item is an album).""" + image: str | None + """Icon or artwork URL.""" + play_url: str | None + """Stream URL extracted from the item's *playURL* attribute. Pass to *Player.play_url* to play it. + *None* if the item is not directly playable or uses a non-/Play action URL (e.g. service-specific /Add).""" + 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.""" + + +@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: str | None + """Service id (e.g. "TuneIn", "Deezer"). Not for UI display.""" + service_name: str | None + """Human-readable service name, suitable for UI.""" + service_icon: str | None + """URL of an icon for the service.""" + 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.""" diff --git a/src/pyblu/errors.py b/src/pyblu/errors.py index 059d15d..e78fe04 100644 --- a/src/pyblu/errors.py +++ b/src/pyblu/errors.py @@ -1,9 +1,8 @@ +from functools import wraps from collections.abc import Callable from typing import ParamSpec, TypeVar -from functools import wraps - -__all__ = ["PlayerError", "PlayerUnreachableError", "PlayerUnexpectedResponseError"] +__all__ = ["PlayerError", "PlayerUnreachableError", "PlayerUnexpectedResponseError", "PlayerBrowseError"] P = ParamSpec("P") R = TypeVar("R") @@ -27,11 +26,25 @@ class PlayerUnexpectedResponseError(PlayerError): """Exception raised when the player returns an unexpected response. This is likely a bug in this library.""" +class PlayerBrowseError(PlayerError): + """Exception raised when the /Browse endpoint returns a structured response. + + Unlike *PlayerUnexpectedResponseError* this is an error the player intentionally reported + (e.g. invalid key, service unavailable) rather than a parsing failure. + """ + + def __init__(self, message: str, details: list[str] | None = None): + super().__init__(message) + self.details = details or [] + + def _wrap_in_unxpected_response_error(func: Callable[P, R]) -> Callable[P, R]: @wraps(func) def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: try: return func(*args, **kwargs) + except PlayerError: + raise except Exception as e: raise PlayerUnexpectedResponseError(f"Unexpected response from player: {e}") from e diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index 573cad0..7847190 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -1,9 +1,20 @@ -from urllib.parse import unquote +from urllib.parse import parse_qs, unquote, urlsplit from lxml import etree -from pyblu.entities import Input, PairedPlayer, SyncStatus, Status, Volume, PlayQueue, Preset -from pyblu.errors import _wrap_in_unxpected_response_error +from pyblu.entities import ( + BrowseCategory, + BrowseItem, + BrowseResult, + Input, + PairedPlayer, + PlayQueue, + Preset, + Status, + SyncStatus, + Volume, +) +from pyblu.errors import PlayerBrowseError, _wrap_in_unxpected_response_error @_wrap_in_unxpected_response_error @@ -216,6 +227,73 @@ def parse_sleep(response: bytes) -> int: return int(sleep_element.text) if sleep_element.text else 0 +def _browse_item(x) -> BrowseItem: + # The url query param is extracted from the relative /Play?url=...&title=... attribute so it can be + # passed directly to Player.play_url. Returns None when the underlying URL is not a /Play?url=X + # (e.g. service-specific /Add?service=...&albumid=...&playnow=1). + play_url: str | None = None + play_url_attr = x.attrib.get("playURL") + if play_url_attr: + values = parse_qs(urlsplit(play_url_attr).query, keep_blank_values=True).get("url") + if values: + play_url = values[0] + + return BrowseItem( + type=x.attrib["type"], + text=x.attrib.get("text"), + text2=x.attrib.get("text2"), + image=x.attrib.get("image"), + play_url=play_url, + browse_key=x.attrib.get("browseKey"), + input_type=x.attrib.get("inputType"), + ) + + +@_wrap_in_unxpected_response_error +def parse_browse_result(response: bytes) -> BrowseResult: + """ + :raises PlayerBrowseError: If the response is a structured response from /Browse. + :raises PlayerUnexpectedResponseError: If the response is not as expected. + """ + # pylint: disable=c-extension-no-member + tree = etree.fromstring(response) + + error_elements = tree.xpath("//error") + if error_elements: + error_element = error_elements[0] + message = (error_element.findtext("message") or "").strip() or "" + details = [d.text.strip() for d in error_element.findall("detail") if d.text and d.text.strip()] + raise PlayerBrowseError(message, details) + + browse_elements = tree.xpath("//browse") + assert len(browse_elements) == 1, "Browse element not found or multiple found" + browse_element = browse_elements[0] + + items = [_browse_item(x) for x in browse_element.xpath("./item")] + categories = [ + BrowseCategory( + text=x.attrib.get("text"), + next_key=x.attrib.get("nextKey"), + parent_key=x.attrib.get("parentKey"), + items=[_browse_item(y) for y in x.xpath("./item")], + ) + for x in browse_element.xpath("./category") + ] + + browse_result = BrowseResult( + type=browse_element.attrib["type"], + service=browse_element.attrib.get("service"), + service_name=browse_element.attrib.get("serviceName"), + service_icon=browse_element.attrib.get("serviceIcon"), + next_key=browse_element.attrib.get("nextKey"), + parent_key=browse_element.attrib.get("parentKey"), + items=items, + categories=categories, + ) + + return browse_result + + @_wrap_in_unxpected_response_error def parse_inputs(response: bytes) -> list[Input]: """ diff --git a/src/pyblu/player.py b/src/pyblu/player.py index a79a439..51281ea 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -2,9 +2,10 @@ import aiohttp -from pyblu.entities import Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input +from pyblu.entities import BrowseResult, Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input from pyblu.parse import ( parse_add_follower, + parse_browse_result, parse_inputs, parse_sleep, parse_state, @@ -412,3 +413,28 @@ async def inputs(self, timeout: float | None = None) -> list[Input]: params: dict[str, str | int] = {"service": "Capture"} data = await self._get("/RadioBrowse", params=params, timeout=timeout) return parse_inputs(data) + + async def browse(self, key: str | None = None, timeout: float | None = None) -> BrowseResult: + """Browse media available on the player. + Call without parameters to get the top-level menu. Call with **key** to descend, paginate, or navigate up. + + **key** is an opaque value taken from a previous browse response: *browse_key* of a *BrowseItem*, + or *next_key* / *parent_key* of a *BrowseResult* or *BrowseCategory*. Do not parse or modify it. + + Playable items expose *play_url* extracted from the underlying /Play URL, which can be passed directly to *play_url*. + + :param key: The opaque key to browse. None returns the top-level menu. + :param timeout: The timeout in seconds for the request. This overrides the default timeout. + + :raises PlayerBrowseError: If the player returns a structured error response. + :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library. + :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. + + :return: The browse result. + """ + params: dict[str, str | int] = {} + if key is not None: + params["key"] = key + + data = await self._get("/Browse", params=params, timeout=timeout) + return parse_browse_result(data) diff --git a/tests/test_parse.py b/tests/test_parse.py index 3ca21ed..1013c06 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -1,5 +1,14 @@ +import pytest + from pyblu import PairedPlayer -from pyblu.parse import parse_add_follower, parse_presets, parse_status, parse_sync_status +from pyblu.errors import PlayerBrowseError +from pyblu.parse import ( + parse_add_follower, + parse_browse_result, + parse_presets, + parse_status, + parse_sync_status, +) def test_parse_add_follower_no_follower(): @@ -305,3 +314,141 @@ def test_parse_status_optionals(): assert status.group_volume is None assert status.stream_url is None + + +def test_parse_browse_root_menu(): + data = """ + + + +""" + + result = parse_browse_result(data) + + assert result.type == "menu" + assert result.service is None + assert result.service_name is None + assert result.next_key is None + assert result.parent_key is None + assert not result.categories + assert len(result.items) == 3 + + playlists, bluetooth, service_a = result.items + + assert playlists.type == "link" + assert playlists.text == "Playlists" + assert playlists.browse_key == "playlists" + assert playlists.play_url is None + assert playlists.input_type is None + + assert bluetooth.type == "audio" + assert bluetooth.text == "Bluetooth" + assert bluetooth.play_url == "Capture:bluez:bluetooth" + assert bluetooth.browse_key is None + assert bluetooth.input_type == "bluetooth" + + assert service_a.type == "link" + assert service_a.browse_key == "ServiceA:" + assert service_a.play_url is None + + +def test_parse_browse_empty_list(): + data = """""" + + result = parse_browse_result(data) + + assert result.type == "playlists" + assert not result.items + assert not result.categories + + +def test_parse_browse_service_menu(): + data = """ + + +""" + + result = parse_browse_result(data) + + assert result.type == "items" + assert result.service == "ServiceA" + assert result.service_name == "Service A" + assert result.service_icon == "/icons/service_a.png" + assert len(result.items) == 2 + assert result.items[0].browse_key == "ServiceA:browse/category-one" + assert result.items[1].text == "Category Two" + + +def test_parse_browse_categories_ignore_context_menu(): + data = """ + + + + + + + + + + + +""" + + result = parse_browse_result(data) + + assert result.type == "items" + assert result.service_name == "Generic" + assert not result.items + assert len(result.categories) == 2 + + group_one, group_two = result.categories + + assert group_one.text == "Group One" + assert len(group_one.items) == 2 + assert group_one.items[0].text == "Station One" + assert group_one.items[0].text2 == "Artist One" + assert group_one.items[0].play_url == "Service:stream-1" + assert group_one.items[1].play_url == "Service:stream-2" + + assert group_two.text == "Group Two" + assert len(group_two.items) == 1 + assert group_two.items[0].play_url == "Service:stream-3" + + +def test_parse_browse_pagination(): + data = """ + +""" + + result = parse_browse_result(data) + + assert result.next_key == "Service:opaque-next-page-key" + assert result.parent_key == "Service:opaque-parent-key" + assert len(result.items) == 1 + + +def test_parse_browse_non_play_action_url_returns_none(): + # Service-specific items use /Add?service=...&playnow=1 rather than /Play?url=X. + # The library exposes play_url=None for those — the caller cannot stream them via play_url(). + data = """ + +""" + + result = parse_browse_result(data) + + assert result.items[0].play_url is None + + +def test_parse_browse_error_response(): + data = """ + Invalid key + key was not recognised + retry from root +""" + + with pytest.raises(PlayerBrowseError) as exc_info: + parse_browse_result(data) + + assert "Invalid key" in str(exc_info.value) + assert exc_info.value.details == ["key was not recognised", "retry from root"] diff --git a/tests/test_player.py b/tests/test_player.py index da8cab7..36a68a2 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -9,7 +9,7 @@ from pyblu import Player, PairedPlayer from pyblu.entities import Preset, Input -from pyblu.errors import PlayerUnreachableError +from pyblu.errors import PlayerBrowseError, PlayerUnreachableError @async_mocketize(strict_mode=True) @@ -767,3 +767,65 @@ async def test_get_maps_connection_error_to_unreachable(): player = _player_with_failing_session(aiohttp.ClientConnectionError()) with pytest.raises(PlayerUnreachableError): await player.status() + + +@async_mocketize(strict_mode=True) +async def test_browse_root(): + Entry.single_register( + Entry.GET, + "http://node:11000/Browse", + status=200, + body=""" + + + + + """, + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + result = await client.browse() + + assert len(Mocket.request_list()) == 1 + + assert result.type == "menu" + assert len(result.items) == 2 + assert result.items[0].browse_key == "playlists" + assert result.items[1].play_url == "Capture:bluez:bluetooth" + assert result.items[1].input_type == "bluetooth" + + +@async_mocketize(strict_mode=True) +async def test_browse_with_key(): + Entry.single_register( + Entry.GET, + f"http://node:11000/Browse?key={quote('ServiceA:')}", + status=200, + body="""""", + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + result = await client.browse(key="ServiceA:") + + assert len(Mocket.request_list()) == 1 + + assert result.type == "items" + assert result.service_name == "Service A" + assert not result.items + + +@async_mocketize(strict_mode=True) +async def test_browse_error_response(): + Entry.single_register( + Entry.GET, + "http://node:11000/Browse?key=bad", + status=200, + body="Invalid keynot recognised", + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + with pytest.raises(PlayerBrowseError) as exc_info: + await client.browse(key="bad") + + assert "Invalid key" in str(exc_info.value) + assert exc_info.value.details == ["not recognised"] From 878eaa74529aeb3f164bb2e9c24be36cfb58f31b Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Wed, 8 Jul 2026 20:59:13 +0000 Subject: [PATCH 02/16] Add search support to media browsing Expose BrowseResult.search_key (searchKey attribute) and add an optional q parameter to Player.browse() for /Browse?key=&q=. Co-Authored-By: Claude Opus 4.8 --- src/pyblu/entities.py | 3 +++ src/pyblu/parse.py | 1 + src/pyblu/player.py | 9 +++++++-- tests/test_parse.py | 14 ++++++++++++++ tests/test_player.py | 25 +++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index 3d03816..665df63 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -207,6 +207,9 @@ class BrowseResult: """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 diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index 7847190..e8661d8 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -285,6 +285,7 @@ def parse_browse_result(response: bytes) -> BrowseResult: service=browse_element.attrib.get("service"), service_name=browse_element.attrib.get("serviceName"), service_icon=browse_element.attrib.get("serviceIcon"), + search_key=browse_element.attrib.get("searchKey"), next_key=browse_element.attrib.get("nextKey"), parent_key=browse_element.attrib.get("parentKey"), items=items, diff --git a/src/pyblu/player.py b/src/pyblu/player.py index 51281ea..daf6a10 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -414,16 +414,19 @@ async def inputs(self, timeout: float | None = None) -> list[Input]: data = await self._get("/RadioBrowse", params=params, timeout=timeout) return parse_inputs(data) - async def browse(self, key: str | None = None, timeout: float | None = None) -> BrowseResult: + async def browse(self, key: str | None = None, q: str | None = None, timeout: float | None = None) -> BrowseResult: """Browse media available on the player. Call without parameters to get the top-level menu. Call with **key** to descend, paginate, or navigate up. **key** is an opaque value taken from a previous browse response: *browse_key* of a *BrowseItem*, - or *next_key* / *parent_key* of a *BrowseResult* or *BrowseCategory*. Do not parse or modify it. + or *search_key* / *next_key* / *parent_key* of a *BrowseResult* or *BrowseCategory*. Do not parse or modify it. + + To search, pass **q** together with a **key** taken from the *search_key* of a previous *BrowseResult*. Playable items expose *play_url* extracted from the underlying /Play URL, which can be passed directly to *play_url*. :param key: The opaque key to browse. None returns the top-level menu. + :param q: The search term. Only meaningful together with a *search_key* passed as **key**. :param timeout: The timeout in seconds for the request. This overrides the default timeout. :raises PlayerBrowseError: If the player returns a structured error response. @@ -435,6 +438,8 @@ async def browse(self, key: str | None = None, timeout: float | None = None) -> params: dict[str, str | int] = {} if key is not None: params["key"] = key + if q is not None: + params["q"] = q data = await self._get("/Browse", params=params, timeout=timeout) return parse_browse_result(data) diff --git a/tests/test_parse.py b/tests/test_parse.py index 1013c06..6c9b417 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -328,6 +328,7 @@ def test_parse_browse_root_menu(): assert result.type == "menu" assert result.service is None assert result.service_name is None + assert result.search_key is None assert result.next_key is None assert result.parent_key is None assert not result.categories @@ -416,6 +417,19 @@ def test_parse_browse_categories_ignore_context_menu(): assert group_two.items[0].play_url == "Service:stream-3" +def test_parse_browse_search_key(): + data = """ + +""" + + result = parse_browse_result(data) + + assert result.service_name == "Radio" + assert result.search_key == "Airable:Search" + assert len(result.items) == 1 + assert result.items[0].text == "Most popular stations" + + def test_parse_browse_pagination(): data = """ diff --git a/tests/test_player.py b/tests/test_player.py index 36a68a2..5837241 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -814,6 +814,31 @@ async def test_browse_with_key(): assert not result.items +@async_mocketize(strict_mode=True) +async def test_browse_search(): + Entry.single_register( + Entry.GET, + f"http://node:11000/Browse?key={quote('Airable:Search')}&q=jazz", + status=200, + body=""" + + + + + """, + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + result = await client.browse(key="Airable:Search", q="jazz") + + assert len(Mocket.request_list()) == 1 + + assert result.search_key == "Airable:Search" + assert len(result.items) == 2 + assert result.items[0].text == "Stations" + assert result.items[1].browse_key == "Airable:BrowseMenu/podcasts" + + @async_mocketize(strict_mode=True) async def test_browse_error_response(): Entry.single_register( From a426fed42e26f51ae8d891d82b30542b0b327cfc Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Thu, 9 Jul 2026 21:59:28 +0000 Subject: [PATCH 03/16] Fix typing --- src/pyblu/parse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index e8661d8..89cff9f 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -227,7 +227,7 @@ def parse_sleep(response: bytes) -> int: return int(sleep_element.text) if sleep_element.text else 0 -def _browse_item(x) -> BrowseItem: +def _browse_item(x: etree._Element) -> BrowseItem: # The url query param is extracted from the relative /Play?url=...&title=... attribute so it can be # passed directly to Player.play_url. Returns None when the underlying URL is not a /Play?url=X # (e.g. service-specific /Add?service=...&albumid=...&playnow=1). From 237d7394373068c5c4b120bf9a9bcd1f8de3dd9c Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Fri, 10 Jul 2026 15:48:10 +0200 Subject: [PATCH 04/16] Update docs --- src/pyblu/entities.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index 665df63..fcbfe85 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -171,9 +171,10 @@ class BrowseItem: """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 label.""" + """Primary display label.""" text2: str | None - """Secondary label (e.g. artist when the item is an album).""" + """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_url: str | None From 3ab4a606eda89b208e98622d18de7529a53e9c20 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Wed, 29 Jul 2026 22:47:02 +0200 Subject: [PATCH 05/16] Add context menus --- AGENTS.md | 5 +++-- docs/api.rst | 3 +++ src/pyblu/__init__.py | 2 ++ src/pyblu/entities.py | 14 +++++++++++++ src/pyblu/parse.py | 45 ++++++++++++++++++++++++++++++++------- src/pyblu/player.py | 34 +++++++++++++++++++++++++++++- tests/test_parse.py | 40 +++++++++++++++++++++++++++++++---- tests/test_player.py | 49 ++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 176 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 62e6572..db69a30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,9 +33,9 @@ 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 and media browsing, including `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. @@ -47,6 +47,7 @@ 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 context-menu keys and action URLs are opaque. Resolve keys through `context_menu()`; actions may mutate playback, the queue, presets, or service favorites. - 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. diff --git a/docs/api.rst b/docs/api.rst index 5d585be..4e69e8d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -41,6 +41,9 @@ Data Classes .. autoclass:: pyblu.BrowseCategory :members: +.. autoclass:: pyblu.ContextMenuAction + :members: + Exceptions ---------- diff --git a/src/pyblu/__init__.py b/src/pyblu/__init__.py index 915b385..295841c 100644 --- a/src/pyblu/__init__.py +++ b/src/pyblu/__init__.py @@ -5,6 +5,7 @@ BrowseCategory, BrowseItem, BrowseResult, + ContextMenuAction, Input, PairedPlayer, PlayQueue, @@ -26,4 +27,5 @@ "BrowseResult", "BrowseItem", "BrowseCategory", + "ContextMenuAction", ] diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index fcbfe85..691fb68 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -165,6 +165,16 @@ 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 URL used by *Player.execute_context_menu_action*. Do not parse or modify it.""" + + @dataclass class BrowseItem: type: str @@ -184,6 +194,10 @@ class BrowseItem: """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.""" @dataclass diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index 89cff9f..10fd559 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -6,6 +6,7 @@ BrowseCategory, BrowseItem, BrowseResult, + ContextMenuAction, Input, PairedPlayer, PlayQueue, @@ -227,6 +228,14 @@ def parse_sleep(response: bytes) -> int: return int(sleep_element.text) if sleep_element.text else 0 +def _context_menu_action(x: etree._Element) -> ContextMenuAction: + return ContextMenuAction( + type=x.attrib["type"], + text=x.attrib.get("text"), + action_url=x.attrib["actionURL"], + ) + + def _browse_item(x: etree._Element) -> BrowseItem: # The url query param is extracted from the relative /Play?url=...&title=... attribute so it can be # passed directly to Player.play_url. Returns None when the underlying URL is not a /Play?url=X @@ -246,16 +255,12 @@ def _browse_item(x: etree._Element) -> BrowseItem: play_url=play_url, browse_key=x.attrib.get("browseKey"), input_type=x.attrib.get("inputType"), + context_menu_key=x.attrib.get("contextMenuKey"), + context_menu=[_context_menu_action(y) for y in x.xpath("./contextMenu/item")], ) -@_wrap_in_unxpected_response_error -def parse_browse_result(response: bytes) -> BrowseResult: - """ - :raises PlayerBrowseError: If the response is a structured response from /Browse. - :raises PlayerUnexpectedResponseError: If the response is not as expected. - """ - # pylint: disable=c-extension-no-member +def _browse_element(response: bytes) -> etree._Element: tree = etree.fromstring(response) error_elements = tree.xpath("//error") @@ -267,7 +272,18 @@ def parse_browse_result(response: bytes) -> BrowseResult: browse_elements = tree.xpath("//browse") assert len(browse_elements) == 1, "Browse element not found or multiple found" - browse_element = browse_elements[0] + browse_element: etree._Element = browse_elements[0] + return browse_element + + +@_wrap_in_unxpected_response_error +def parse_browse_result(response: bytes) -> BrowseResult: + """ + :raises PlayerBrowseError: If the response is a structured response from /Browse. + :raises PlayerUnexpectedResponseError: If the response is not as expected. + """ + # pylint: disable=c-extension-no-member + browse_element = _browse_element(response) items = [_browse_item(x) for x in browse_element.xpath("./item")] categories = [ @@ -295,6 +311,19 @@ def parse_browse_result(response: bytes) -> BrowseResult: return browse_result +@_wrap_in_unxpected_response_error +def parse_context_menu(response: bytes) -> list[ContextMenuAction]: + """ + :raises PlayerBrowseError: If the response is a structured response from /Browse. + :raises PlayerUnexpectedResponseError: If the response is not as expected. + """ + # pylint: disable=c-extension-no-member + browse_element = _browse_element(response) + assert browse_element.attrib["type"] == "contextMenu", "Browse response is not a context menu" + + return [_context_menu_action(x) for x in browse_element.xpath("./item")] + + @_wrap_in_unxpected_response_error def parse_inputs(response: bytes) -> list[Input]: """ diff --git a/src/pyblu/player.py b/src/pyblu/player.py index daf6a10..ad5a46b 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -2,10 +2,11 @@ import aiohttp -from pyblu.entities import BrowseResult, Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input +from pyblu.entities import BrowseResult, ContextMenuAction, Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input from pyblu.parse import ( parse_add_follower, parse_browse_result, + parse_context_menu, parse_inputs, parse_sleep, parse_state, @@ -420,6 +421,7 @@ async def browse(self, key: str | None = None, q: str | None = None, timeout: fl **key** is an opaque value taken from a previous browse response: *browse_key* of a *BrowseItem*, or *search_key* / *next_key* / *parent_key* of a *BrowseResult* or *BrowseCategory*. Do not parse or modify it. + Use *context_menu* rather than this method for a *context_menu_key*. To search, pass **q** together with a **key** taken from the *search_key* of a previous *BrowseResult*. @@ -443,3 +445,33 @@ async def browse(self, key: str | None = None, q: str | None = None, timeout: fl data = await self._get("/Browse", params=params, timeout=timeout) return parse_browse_result(data) + + async def context_menu(self, key: str, timeout: float | None = None) -> list[ContextMenuAction]: + """Get the context-menu actions available for a browse item. + + **key** is the opaque *context_menu_key* from a *BrowseItem*. Do not parse or modify it. Available actions are service-specific and can change. + + :param key: The opaque context-menu key from a browse item. + :param timeout: The timeout in seconds for the request. This overrides the default timeout. + + :raises PlayerBrowseError: If the player returns a structured error response. + :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library. + :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. + + :return: The context-menu actions available for the item. + """ + data = await self._get("/Browse", params={"key": key}, timeout=timeout) + return parse_context_menu(data) + + async def execute_context_menu_action(self, action: ContextMenuAction, timeout: float | None = None) -> None: + """Execute a context-menu action returned by *context_menu* or embedded in a *BrowseItem*. + + Context-menu actions can mutate player or service state: for example, they may start playback, modify the play queue, + add a preset, or change a favorite. The action's opaque relative URL is sent directly to the player. + + :param action: The context-menu action to execute. + :param timeout: The timeout in seconds for the request. This overrides the default timeout. + + :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. + """ + await self._get(action.action_url, timeout=timeout) diff --git a/tests/test_parse.py b/tests/test_parse.py index 6c9b417..a1dc9b4 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -1,10 +1,11 @@ import pytest -from pyblu import PairedPlayer +from pyblu import ContextMenuAction, PairedPlayer from pyblu.errors import PlayerBrowseError from pyblu.parse import ( parse_add_follower, parse_browse_result, + parse_context_menu, parse_presets, parse_status, parse_sync_status, @@ -380,13 +381,14 @@ def test_parse_browse_service_menu(): assert result.items[1].text == "Category Two" -def test_parse_browse_categories_ignore_context_menu(): +def test_parse_browse_categories_with_context_menus(): data = """ + contextMenuKey="Generic:ContextMenu/opaque%2Fkey%3Fid%3D1" text="Station One" text2="Artist One" + image="http://example.com/cover.jpg" type="audio"> - + @@ -410,7 +412,11 @@ def test_parse_browse_categories_ignore_context_menu(): assert group_one.items[0].text == "Station One" assert group_one.items[0].text2 == "Artist One" assert group_one.items[0].play_url == "Service:stream-1" + assert group_one.items[0].context_menu_key == "Generic:ContextMenu/opaque%2Fkey%3Fid%3D1" + assert group_one.items[0].context_menu == [ContextMenuAction(type="favourite-add", text="Action", action_url="/Action?id=1&value=opaque%2Fvalue")] assert group_one.items[1].play_url == "Service:stream-2" + assert group_one.items[1].context_menu_key is None + assert not group_one.items[1].context_menu assert group_two.text == "Group Two" assert len(group_two.items) == 1 @@ -454,6 +460,32 @@ def test_parse_browse_non_play_action_url_returns_none(): assert result.items[0].play_url is None +def test_parse_context_menu(): + data = """ + + +""" + + actions = parse_context_menu(data) + + assert actions == [ + ContextMenuAction( + type="favourite-add", + text="Favourite", + action_url="/AddFavourite?service=Airable&url=opaque%3Avalue%2F1", + ), + ContextMenuAction( + type="queue-last", + text="Add last", + action_url="/Add?file=episode%3A1&playnow=-1&where=last", + ), + ] + + +def test_parse_empty_context_menu(): + assert parse_context_menu('') == [] + + def test_parse_browse_error_response(): data = """ Invalid key diff --git a/tests/test_player.py b/tests/test_player.py index 5837241..14c497d 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -7,7 +7,7 @@ from mocket.plugins.aiohttp_connector import MocketTCPConnector import pytest -from pyblu import Player, PairedPlayer +from pyblu import ContextMenuAction, Player, PairedPlayer from pyblu.entities import Preset, Input from pyblu.errors import PlayerBrowseError, PlayerUnreachableError @@ -854,3 +854,50 @@ async def test_browse_error_response(): assert "Invalid key" in str(exc_info.value) assert exc_info.value.details == ["not recognised"] + + +@async_mocketize(strict_mode=True) +async def test_context_menu(): + key = "Airable:ContextMenu/opaque?url=station%3A1&hasInfo=1" + Entry.single_register( + Entry.GET, + f"http://node:11000/Browse?key={quote(key)}", + status=200, + body=""" + + + + """, + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + actions = await client.context_menu(key) + + assert len(Mocket.request_list()) == 1 + assert actions == [ + ContextMenuAction( + type="favourite-add", + text="Favourite", + action_url="/AddFavourite?service=Airable&url=opaque%3Astation%2F1", + ) + ] + + +@async_mocketize(strict_mode=True) +async def test_execute_context_menu_action(): + action = ContextMenuAction( + type="favourite-add", + text="Favourite", + action_url="/AddFavourite?service=Airable&url=opaque%3Astation%2F1", + ) + Entry.single_register( + Entry.GET, + "http://node:11000/AddFavourite?service=Airable&url=opaque%3Astation%2F1", + status=200, + body="", + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + await client.execute_context_menu_action(action) + + assert len(Mocket.request_list()) == 1 From c3096f7cd1373a9d7a53a86b34de71ffa09fe98a Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Thu, 30 Jul 2026 17:20:20 +0200 Subject: [PATCH 06/16] Add play queue management --- AGENTS.md | 3 +- docs/api.rst | 3 ++ src/pyblu/__init__.py | 2 + src/pyblu/entities.py | 40 +++++++++++++++-- src/pyblu/parse.py | 82 +++++++++++++++++++++++++++++++--- src/pyblu/player.py | 86 ++++++++++++++++++++++++++++++++++- tests/test_parse.py | 68 +++++++++++++++++++++++++++- tests/test_player.py | 101 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 371 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index db69a30..30a1d82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ The library has four modules with a clear separation of concerns: - **`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 for player state and media browsing, including `BrowseResult`, `BrowseItem`, and `ContextMenuAction`. 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. @@ -48,6 +48,7 @@ The library has four modules with a clear separation of concerns: - `inputs()` calls `/RadioBrowse?service=Capture`, not a dedicated inputs endpoint. - `play_url()` and `play()` both map to the `/Play` endpoint. - Browse context-menu keys and action URLs are opaque. Resolve 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. - 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. diff --git a/docs/api.rst b/docs/api.rst index 4e69e8d..d5aa433 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -29,6 +29,9 @@ Data Classes .. autoclass:: pyblu.PlayQueue :members: +.. autoclass:: pyblu.PlayQueueTrack + :members: + .. autoclass:: pyblu.Input :members: diff --git a/src/pyblu/__init__.py b/src/pyblu/__init__.py index 295841c..a3a3382 100644 --- a/src/pyblu/__init__.py +++ b/src/pyblu/__init__.py @@ -9,6 +9,7 @@ Input, PairedPlayer, PlayQueue, + PlayQueueTrack, Preset, Status, SyncStatus, @@ -22,6 +23,7 @@ "SyncStatus", "PairedPlayer", "PlayQueue", + "PlayQueueTrack", "Preset", "Input", "BrowseResult", diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index 691fb68..aab3080 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass @@ -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""" + """Play queue is shuffled.""" modified: bool - """PlayQueue was modified since it was loaded""" + """Play queue was modified since it was loaded.""" 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 diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index 10fd559..3157912 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -10,6 +10,7 @@ Input, PairedPlayer, PlayQueue, + PlayQueueTrack, Preset, Status, SyncStatus, @@ -151,6 +152,11 @@ def parse_volume(response: bytes) -> Volume: return volume +def _attribute_or_child(element: etree._Element, name: str) -> str | None: + value = element.attrib.get(name) + return value if value is not None else element.findtext(name) + + @_wrap_in_unxpected_response_error def parse_play_queue(response: bytes) -> PlayQueue: """ @@ -163,14 +169,78 @@ def parse_play_queue(response: bytes) -> PlayQueue: assert len(playlist_elements) == 1, "Playlist element not found or multiple found" playlist_element = playlist_elements[0] - play_queue = PlayQueue( - id=playlist_element.attrib["id"], - modified=playlist_element.attrib.get("modified") == "1", - length=int(playlist_element.attrib["length"]), - shuffle=playlist_element.attrib.get("shuffle") == "1", + queue_id = _attribute_or_child(playlist_element, "id") + length = _attribute_or_child(playlist_element, "length") + assert queue_id is not None, "Playlist id not found" + assert length is not None, "Playlist length not found" + + tracks = [ + PlayQueueTrack( + id=int(x.attrib["id"]), + title=x.findtext("title"), + artist=x.findtext("art"), + album=x.findtext("alb"), + filename=x.findtext("fn"), + image=x.findtext("image"), + duration=float(duration) if (duration := x.findtext("time")) is not None else None, + service=x.attrib.get("service"), + song_id=x.attrib.get("songid"), + album_id=x.attrib.get("albumid"), + artist_id=x.attrib.get("artistid"), + ) + for x in playlist_element.xpath("./song") + ] + + return PlayQueue( + id=queue_id, + modified=_attribute_or_child(playlist_element, "modified") == "1", + length=int(length), + shuffle=_attribute_or_child(playlist_element, "shuffle") == "1", + name=_attribute_or_child(playlist_element, "name"), + repeat=int(repeat) if (repeat := _attribute_or_child(playlist_element, "repeat")) is not None else None, + tracks=tracks, ) - return play_queue + +@_wrap_in_unxpected_response_error +def parse_deleted_play_queue_track(response: bytes) -> int: + """ + :raises PlayerUnexpectedResponseError: If the response is not as expected. + """ + # pylint: disable=c-extension-no-member + tree = etree.fromstring(response) + deleted_elements = tree.xpath("//deleted") + + assert len(deleted_elements) == 1, "Deleted element not found or multiple found" + assert deleted_elements[0].text is not None, "Deleted track id not found" + return int(deleted_elements[0].text) + + +@_wrap_in_unxpected_response_error +def parse_moved_play_queue_track(response: bytes) -> None: + """ + :raises PlayerUnexpectedResponseError: If the response is not as expected. + """ + # pylint: disable=c-extension-no-member + tree = etree.fromstring(response) + moved_elements = tree.xpath("//moved") + + assert len(moved_elements) == 1, "Moved element not found or multiple found" + assert moved_elements[0].text == "moved", "Track was not moved" + + +@_wrap_in_unxpected_response_error +def parse_saved_play_queue(response: bytes) -> int: + """ + :raises PlayerUnexpectedResponseError: If the response is not as expected. + """ + # pylint: disable=c-extension-no-member + tree = etree.fromstring(response) + entries_elements = tree.xpath("//saved/entries") + + assert len(entries_elements) == 1, "Saved entries element not found or multiple found" + assert entries_elements[0].text is not None, "Saved entry count not found" + return int(entries_elements[0].text) @_wrap_in_unxpected_response_error diff --git a/src/pyblu/player.py b/src/pyblu/player.py index ad5a46b..327ca23 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -7,14 +7,17 @@ parse_add_follower, parse_browse_result, parse_context_menu, + parse_deleted_play_queue_track, parse_inputs, + parse_moved_play_queue_track, + parse_play_queue, + parse_presets, + parse_saved_play_queue, parse_sleep, parse_state, parse_sync_status, parse_status, parse_volume, - parse_play_queue, - parse_presets, ) from pyblu.errors import PlayerUnreachableError @@ -330,6 +333,71 @@ async def remove_followers(self, followers: list[PairedPlayer], timeout: float | data = await self._get("/RemoveSlave", params=params, timeout=timeout) return parse_sync_status(data) + async def play_queue( + self, + start: int | None = None, + end: int | None = None, + status_only: bool = False, + timeout: float | None = None, + ) -> PlayQueue: + """Get the current play queue. + + Use **start** and **end** to retrieve an inclusive page of tracks. Both positions start at 0 and must be supplied together. + Use **status_only** to retrieve only queue metadata. Calling without pagination or **status_only** returns every track and may produce a large response. + + :param start: The first track position to include, starting from 0. + :param end: The last track position to include, inclusive. + :param status_only: Return queue metadata without track details. + :param timeout: The timeout in seconds for the request. This overrides the default timeout. + + :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library. + :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. + :raises ValueError: If only one pagination position is supplied, or pagination and **status_only** are combined. + + :return: The current play queue and the requested tracks. + """ + if (start is None) != (end is None): + raise ValueError("start and end have to be supplied together") + if status_only and start is not None: + raise ValueError("status_only cannot be combined with start and end") + + params: dict[str, str | int] = {} + if status_only: + params["length"] = 1 + elif start is not None and end is not None: + params["start"] = start + params["end"] = end + + data = await self._get("/Playlist", params=params, timeout=timeout) + return parse_play_queue(data) + + async def delete_play_queue_track(self, track_id: int, timeout: float | None = None) -> int: + """Delete a track from the current play queue. + + :param track_id: The track id from *PlayQueueTrack.id*. + :param timeout: The timeout in seconds for the request. This overrides the default timeout. + + :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library. + :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. + + :return: The id of the deleted track. + """ + data = await self._get("/Delete", params={"id": track_id}, timeout=timeout) + return parse_deleted_play_queue_track(data) + + async def move_play_queue_track(self, old_position: int, new_position: int, timeout: float | None = None) -> None: + """Move a track within the current play queue. + + :param old_position: The current track position from *PlayQueueTrack.id*. + :param new_position: The destination position. + :param timeout: The timeout in seconds for the request. This overrides the default timeout. + + :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library. + :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. + """ + data = await self._get("/Move", params={"new": new_position, "old": old_position}, timeout=timeout) + parse_moved_play_queue_track(data) + async def shuffle(self, shuffle: bool, timeout: float | None = None) -> PlayQueue: """Set shuffle on current play queue. @@ -360,6 +428,20 @@ async def clear(self, timeout: float | None = None) -> PlayQueue: data = await self._get("/Clear", timeout=timeout) return parse_play_queue(data) + async def save_play_queue(self, name: str, timeout: float | None = None) -> int: + """Save the current play queue as a named BluOS playlist. + + :param name: The name of the saved playlist. + :param timeout: The timeout in seconds for the request. This overrides the default timeout. + + :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library. + :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. + + :return: The number of tracks saved. + """ + data = await self._get("/Save", params={"name": name}, timeout=timeout) + return parse_saved_play_queue(data) + async def sleep_timer(self, timeout: float | None = None) -> int: """Set sleep timer. Time steps are 15, 30, 45, 60, 90 minutes. Each call goes to next step. Resets to 0 if called when 90 minutes are set. diff --git a/tests/test_parse.py b/tests/test_parse.py index a1dc9b4..5d57702 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -1,12 +1,16 @@ import pytest -from pyblu import ContextMenuAction, PairedPlayer +from pyblu import ContextMenuAction, PairedPlayer, PlayQueueTrack from pyblu.errors import PlayerBrowseError from pyblu.parse import ( parse_add_follower, parse_browse_result, parse_context_menu, + parse_deleted_play_queue_track, + parse_moved_play_queue_track, + parse_play_queue, parse_presets, + parse_saved_play_queue, parse_status, parse_sync_status, ) @@ -256,6 +260,68 @@ def test_parse_sync_status_without_leader(): assert sync_status.followers is None +def test_parse_play_queue_listing(): + data = """ + + 2002 + Anne-Marie + Speak Your Mind + + Deezer:487381362 + /Artwork?song=487381362 + + """ + + play_queue = parse_play_queue(data) + + assert play_queue.id == "1054" + assert play_queue.name == "Calm Piano" + assert not play_queue.modified + assert play_queue.length == 160 + assert play_queue.shuffle + assert play_queue.repeat == 2 + assert play_queue.tracks == [ + PlayQueueTrack( + id=25, + title="2002", + artist="Anne-Marie", + album="Speak Your Mind", + filename="Deezer:487381362", + image="/Artwork?song=487381362", + duration=185.5, + service="Deezer", + song_id="Deezer:487381362", + album_id="61483452", + artist_id="6396188", + ) + ] + + +def test_parse_play_queue_status(): + data = """ + 13 + 243 + + 1 + """ + + play_queue = parse_play_queue(data) + + assert play_queue.id == "243" + assert play_queue.name == "" + assert play_queue.modified + assert play_queue.length == 13 + assert not play_queue.shuffle + assert play_queue.repeat is None + assert not play_queue.tracks + + +def test_parse_play_queue_mutation_responses(): + assert parse_deleted_play_queue_track("9") == 9 + assert parse_moved_play_queue_track("moved") is None + assert parse_saved_play_queue("126") == 126 + + def test_parse_presets(): data = """ diff --git a/tests/test_player.py b/tests/test_player.py index 14c497d..ecc4347 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -1,3 +1,5 @@ +# pylint: disable=too-many-lines + from unittest.mock import AsyncMock, MagicMock from urllib.parse import quote @@ -556,6 +558,105 @@ async def test_clear(): assert not play_queue.shuffle +@async_mocketize(strict_mode=True) +async def test_play_queue(): + Entry.single_register( + Entry.GET, + "http://node:11000/Playlist", + status=200, + body=""" + + TrackArtistAlbumService:track-1 + + """, + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + play_queue = await client.play_queue() + + assert len(Mocket.request_list()) == 1 + assert play_queue.name == "Queue" + assert play_queue.length == 1 + assert play_queue.repeat == 2 + assert play_queue.tracks[0].title == "Track" + assert play_queue.tracks[0].id == 0 + + +@async_mocketize(strict_mode=True) +async def test_play_queue_status_only(): + Entry.single_register( + Entry.GET, + "http://node:11000/Playlist?length=1", + status=200, + body="3151", + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + play_queue = await client.play_queue(status_only=True) + + assert len(Mocket.request_list()) == 1 + assert play_queue.id == "15" + assert play_queue.length == 3 + assert play_queue.tracks == [] + + +@async_mocketize(strict_mode=True) +async def test_play_queue_page(): + Entry.single_register( + Entry.GET, + "http://node:11000/Playlist?start=10&end=19", + status=200, + body='Track 10', + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + play_queue = await client.play_queue(start=10, end=19) + + assert len(Mocket.request_list()) == 1 + assert play_queue.length == 30 + assert play_queue.tracks[0].id == 10 + + +async def test_play_queue_rejects_incomplete_or_conflicting_pagination(): + async with Player("node") as client: + with pytest.raises(ValueError, match="start and end"): + await client.play_queue(start=0) + with pytest.raises(ValueError, match="status_only"): + await client.play_queue(start=0, end=9, status_only=True) + + +@async_mocketize(strict_mode=True) +async def test_delete_play_queue_track(): + Entry.single_register(Entry.GET, "http://node:11000/Delete?id=9", status=200, body="9") + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + deleted_id = await client.delete_play_queue_track(9) + + assert len(Mocket.request_list()) == 1 + assert deleted_id == 9 + + +@async_mocketize(strict_mode=True) +async def test_move_play_queue_track(): + Entry.single_register(Entry.GET, "http://node:11000/Move?new=8&old=2", status=200, body="moved") + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + await client.move_play_queue_track(old_position=2, new_position=8) + + assert len(Mocket.request_list()) == 1 + + +@async_mocketize(strict_mode=True) +async def test_save_play_queue(): + Entry.single_register(Entry.GET, "http://node:11000/Save?name=Dinner+Music", status=200, body="126") + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + entries = await client.save_play_queue("Dinner Music") + + assert len(Mocket.request_list()) == 1 + assert entries == 126 + + @async_mocketize(strict_mode=True) async def test_play_url(): Entry.single_register( From a075ff94da2effb68ce195b944da40c4979aedef Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Thu, 30 Jul 2026 17:35:27 +0200 Subject: [PATCH 07/16] Add error handling for empty queue save --- docs/api.rst | 3 +++ src/pyblu/errors.py | 6 +++++- src/pyblu/parse.py | 7 ++++++- src/pyblu/player.py | 1 + tests/test_parse.py | 7 ++++++- tests/test_player.py | 13 ++++++++++++- 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index d5aa433..a2c4ec4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -59,5 +59,8 @@ Exceptions .. autoclass:: pyblu.errors.PlayerUnexpectedResponseError :members: +.. autoclass:: pyblu.errors.PlayerCommandError + :members: + .. autoclass:: pyblu.errors.PlayerBrowseError :members: \ No newline at end of file diff --git a/src/pyblu/errors.py b/src/pyblu/errors.py index e78fe04..83396e5 100644 --- a/src/pyblu/errors.py +++ b/src/pyblu/errors.py @@ -2,7 +2,7 @@ from collections.abc import Callable from typing import ParamSpec, TypeVar -__all__ = ["PlayerError", "PlayerUnreachableError", "PlayerUnexpectedResponseError", "PlayerBrowseError"] +__all__ = ["PlayerError", "PlayerUnreachableError", "PlayerUnexpectedResponseError", "PlayerCommandError", "PlayerBrowseError"] P = ParamSpec("P") R = TypeVar("R") @@ -26,6 +26,10 @@ class PlayerUnexpectedResponseError(PlayerError): """Exception raised when the player returns an unexpected response. This is likely a bug in this library.""" +class PlayerCommandError(PlayerError): + """Exception raised when the player intentionally rejects a command.""" + + class PlayerBrowseError(PlayerError): """Exception raised when the /Browse endpoint returns a structured response. diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index 3157912..0046bf3 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -16,7 +16,7 @@ SyncStatus, Volume, ) -from pyblu.errors import PlayerBrowseError, _wrap_in_unxpected_response_error +from pyblu.errors import PlayerBrowseError, PlayerCommandError, _wrap_in_unxpected_response_error @_wrap_in_unxpected_response_error @@ -236,6 +236,11 @@ def parse_saved_play_queue(response: bytes) -> int: """ # pylint: disable=c-extension-no-member tree = etree.fromstring(response) + if tree.tag == "error": + error = (tree.text or "").strip() + message = "Cannot save an empty play queue" if error == "empty" else error or "The player rejected the save command" + raise PlayerCommandError(message) + entries_elements = tree.xpath("//saved/entries") assert len(entries_elements) == 1, "Saved entries element not found or multiple found" diff --git a/src/pyblu/player.py b/src/pyblu/player.py index 327ca23..44d3799 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -434,6 +434,7 @@ async def save_play_queue(self, name: str, timeout: float | None = None) -> int: :param name: The name of the saved playlist. :param timeout: The timeout in seconds for the request. This overrides the default timeout. + :raises PlayerCommandError: If the player rejects the save command, such as when the play queue is empty. :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library. :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. diff --git a/tests/test_parse.py b/tests/test_parse.py index 5d57702..f5b757d 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -1,7 +1,7 @@ import pytest from pyblu import ContextMenuAction, PairedPlayer, PlayQueueTrack -from pyblu.errors import PlayerBrowseError +from pyblu.errors import PlayerBrowseError, PlayerCommandError from pyblu.parse import ( parse_add_follower, parse_browse_result, @@ -322,6 +322,11 @@ def test_parse_play_queue_mutation_responses(): assert parse_saved_play_queue("126") == 126 +def test_parse_save_empty_play_queue_error(): + with pytest.raises(PlayerCommandError, match="Cannot save an empty play queue"): + parse_saved_play_queue("empty") + + def test_parse_presets(): data = """ diff --git a/tests/test_player.py b/tests/test_player.py index ecc4347..c0d172b 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -11,7 +11,7 @@ from pyblu import ContextMenuAction, Player, PairedPlayer from pyblu.entities import Preset, Input -from pyblu.errors import PlayerBrowseError, PlayerUnreachableError +from pyblu.errors import PlayerBrowseError, PlayerCommandError, PlayerUnreachableError @async_mocketize(strict_mode=True) @@ -657,6 +657,17 @@ async def test_save_play_queue(): assert entries == 126 +@async_mocketize(strict_mode=True) +async def test_save_empty_play_queue(): + Entry.single_register(Entry.GET, "http://node:11000/Save?name=Empty", status=200, body="empty") + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + with pytest.raises(PlayerCommandError, match="Cannot save an empty play queue"): + await client.save_play_queue("Empty") + + assert len(Mocket.request_list()) == 1 + + @async_mocketize(strict_mode=True) async def test_play_url(): Entry.single_register( From 05030d8548ded057a031f2e1deaaefdc0f6d610d Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Thu, 30 Jul 2026 17:36:16 +0200 Subject: [PATCH 08/16] Update AGENTS.md --- AGENTS.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 30a1d82..6b0250e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -37,7 +50,7 @@ The library has four modules with a clear separation of concerns: - **`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 From 147862b8514b7e80d53c98f30b62e7559ea4c9d0 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Thu, 30 Jul 2026 17:55:38 +0200 Subject: [PATCH 09/16] Add autoplay_url --- src/pyblu/entities.py | 7 +++- src/pyblu/parse.py | 15 ++----- src/pyblu/player.py | 37 ++++++++++++++++-- tests/test_parse.py | 20 +++++----- tests/test_player.py | 91 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 142 insertions(+), 28 deletions(-) diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index aab3080..c8c5f5e 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -220,8 +220,8 @@ class BrowseItem: image: str | None """Icon or artwork URL.""" play_url: str | None - """Stream URL extracted from the item's *playURL* attribute. Pass to *Player.play_url* to play it. - *None* if the item is not directly playable or uses a non-/Play action URL (e.g. service-specific /Add).""" + """Opaque relative URI from the item's *playURL* attribute. Use *Player.play_browse_item* to invoke it. + *None* if the item does not provide a default play action.""" 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 @@ -230,6 +230,9 @@ class BrowseItem: """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_url: str | None = None + """Opaque relative URI from the item's *autoplayURL* attribute. Use *Player.play_browse_item* with **autoplay=True** to invoke it. + *None* if the item does not provide an auto-fill play action.""" @dataclass diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index 0046bf3..26cba37 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -1,4 +1,4 @@ -from urllib.parse import parse_qs, unquote, urlsplit +from urllib.parse import unquote from lxml import etree @@ -312,22 +312,13 @@ def _context_menu_action(x: etree._Element) -> ContextMenuAction: def _browse_item(x: etree._Element) -> BrowseItem: - # The url query param is extracted from the relative /Play?url=...&title=... attribute so it can be - # passed directly to Player.play_url. Returns None when the underlying URL is not a /Play?url=X - # (e.g. service-specific /Add?service=...&albumid=...&playnow=1). - play_url: str | None = None - play_url_attr = x.attrib.get("playURL") - if play_url_attr: - values = parse_qs(urlsplit(play_url_attr).query, keep_blank_values=True).get("url") - if values: - play_url = values[0] - return BrowseItem( type=x.attrib["type"], text=x.attrib.get("text"), text2=x.attrib.get("text2"), image=x.attrib.get("image"), - play_url=play_url, + play_url=x.attrib.get("playURL"), + autoplay_url=x.attrib.get("autoplayURL"), browse_key=x.attrib.get("browseKey"), input_type=x.attrib.get("inputType"), context_menu_key=x.attrib.get("contextMenuKey"), diff --git a/src/pyblu/player.py b/src/pyblu/player.py index 44d3799..a7926c6 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -2,7 +2,7 @@ import aiohttp -from pyblu.entities import BrowseResult, ContextMenuAction, Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input +from pyblu.entities import BrowseItem, BrowseResult, ContextMenuAction, Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input from pyblu.parse import ( parse_add_follower, parse_browse_result, @@ -498,7 +498,13 @@ async def inputs(self, timeout: float | None = None) -> list[Input]: data = await self._get("/RadioBrowse", params=params, timeout=timeout) return parse_inputs(data) - async def browse(self, key: str | None = None, q: str | None = None, timeout: float | None = None) -> BrowseResult: + async def browse( + self, + key: str | None = None, + q: str | None = None, + timeout: float | None = None, + with_context_menu_items: bool = False, + ) -> BrowseResult: """Browse media available on the player. Call without parameters to get the top-level menu. Call with **key** to descend, paginate, or navigate up. @@ -507,12 +513,14 @@ async def browse(self, key: str | None = None, q: str | None = None, timeout: fl Use *context_menu* rather than this method for a *context_menu_key*. To search, pass **q** together with a **key** taken from the *search_key* of a previous *BrowseResult*. + Set **with_context_menu_items** to include each item's context-menu actions in the response. - Playable items expose *play_url* extracted from the underlying /Play URL, which can be passed directly to *play_url*. + Playable items expose opaque *play_url* and optionally *autoplay_url* values. Invoke them with *play_browse_item*. :param key: The opaque key to browse. None returns the top-level menu. :param q: The search term. Only meaningful together with a *search_key* passed as **key**. :param timeout: The timeout in seconds for the request. This overrides the default timeout. + :param with_context_menu_items: Include inline context-menu actions for returned items. :raises PlayerBrowseError: If the player returns a structured error response. :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library. @@ -525,10 +533,33 @@ async def browse(self, key: str | None = None, q: str | None = None, timeout: fl params["key"] = key if q is not None: params["q"] = q + if with_context_menu_items: + params["withContextMenuItems"] = 1 data = await self._get("/Browse", params=params, timeout=timeout) return parse_browse_result(data) + async def play_browse_item(self, item: BrowseItem, autoplay: bool = False, timeout: float | None = None) -> None: + """Invoke a browse item's play action. + + The opaque URI supplied by the player is sent back unchanged. By default this uses *BrowseItem.play_url*. + Set **autoplay** to use *BrowseItem.autoplay_url*, which may add subsequent tracks from the containing object + to the auto-fill section of the play queue. + + :param item: The browse item to play. + :param autoplay: Use the item's auto-fill play action instead of its default play action. + :param timeout: The timeout in seconds for the request. This overrides the default timeout. + + :raises ValueError: If the item does not provide the selected play action. + :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. + """ + action_url = item.autoplay_url if autoplay else item.play_url + if action_url is None: + action_name = "autoplayURL" if autoplay else "playURL" + raise ValueError(f"Browse item does not provide {action_name}") + + await self._get(action_url, timeout=timeout) + async def context_menu(self, key: str, timeout: float | None = None) -> list[ContextMenuAction]: """Get the context-menu actions available for a browse item. diff --git a/tests/test_parse.py b/tests/test_parse.py index f5b757d..55bd46e 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -416,7 +416,8 @@ def test_parse_browse_root_menu(): assert bluetooth.type == "audio" assert bluetooth.text == "Bluetooth" - assert bluetooth.play_url == "Capture:bluez:bluetooth" + assert bluetooth.play_url == "/Play?url=Capture%3Abluez%3Abluetooth" + assert bluetooth.autoplay_url is None assert bluetooth.browse_key is None assert bluetooth.input_type == "bluetooth" @@ -462,7 +463,8 @@ def test_parse_browse_categories_with_context_menus(): - + @@ -482,16 +484,18 @@ def test_parse_browse_categories_with_context_menus(): assert len(group_one.items) == 2 assert group_one.items[0].text == "Station One" assert group_one.items[0].text2 == "Artist One" - assert group_one.items[0].play_url == "Service:stream-1" + assert group_one.items[0].play_url == "/Play?url=Service%3Astream-1&title=Station+One&image=http%3A%2F%2Fexample.com%2Fcover.jpg" + assert group_one.items[0].autoplay_url is None assert group_one.items[0].context_menu_key == "Generic:ContextMenu/opaque%2Fkey%3Fid%3D1" assert group_one.items[0].context_menu == [ContextMenuAction(type="favourite-add", text="Action", action_url="/Action?id=1&value=opaque%2Fvalue")] - assert group_one.items[1].play_url == "Service:stream-2" + assert group_one.items[1].play_url == "/Play?url=Service%3Astream-2" + assert group_one.items[1].autoplay_url == "/Play?url=Service%3Astream-2&autofill=1" assert group_one.items[1].context_menu_key is None assert not group_one.items[1].context_menu assert group_two.text == "Group Two" assert len(group_two.items) == 1 - assert group_two.items[0].play_url == "Service:stream-3" + assert group_two.items[0].play_url == "/Play?url=Service%3Astream-3" def test_parse_browse_search_key(): @@ -519,16 +523,14 @@ def test_parse_browse_pagination(): assert len(result.items) == 1 -def test_parse_browse_non_play_action_url_returns_none(): - # Service-specific items use /Add?service=...&playnow=1 rather than /Play?url=X. - # The library exposes play_url=None for those — the caller cannot stream them via play_url(). +def test_parse_browse_preserves_non_play_action_url(): data = """ """ result = parse_browse_result(data) - assert result.items[0].play_url is None + assert result.items[0].play_url == "/Add?service=Generic&albumid=12345&playnow=1" def test_parse_context_menu(): diff --git a/tests/test_player.py b/tests/test_player.py index c0d172b..51f2796 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -9,7 +9,7 @@ from mocket.plugins.aiohttp_connector import MocketTCPConnector import pytest -from pyblu import ContextMenuAction, Player, PairedPlayer +from pyblu import BrowseItem, ContextMenuAction, Player, PairedPlayer from pyblu.entities import Preset, Input from pyblu.errors import PlayerBrowseError, PlayerCommandError, PlayerUnreachableError @@ -903,7 +903,7 @@ async def test_browse_root(): assert result.type == "menu" assert len(result.items) == 2 assert result.items[0].browse_key == "playlists" - assert result.items[1].play_url == "Capture:bluez:bluetooth" + assert result.items[1].play_url == "/Play?url=Capture%3Abluez%3Abluetooth" assert result.items[1].input_type == "bluetooth" @@ -926,6 +926,28 @@ async def test_browse_with_key(): assert not result.items +@async_mocketize(strict_mode=True) +async def test_browse_with_inline_context_menu_items(): + Entry.single_register( + Entry.GET, + f"http://node:11000/Browse?key={quote('ServiceA:albums')}&withContextMenuItems=1", + status=200, + body=""" + + + + + + """, + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + result = await client.browse(key="ServiceA:albums", with_context_menu_items=True) + + assert len(Mocket.request_list()) == 1 + assert result.items[0].context_menu == [ContextMenuAction(type="add-last", text="Add", action_url="/Add?service=ServiceA&albumid=1")] + + @async_mocketize(strict_mode=True) async def test_browse_search(): Entry.single_register( @@ -968,6 +990,71 @@ async def test_browse_error_response(): assert exc_info.value.details == ["not recognised"] +def _browse_item_with_play_actions() -> BrowseItem: + return BrowseItem( + type="album", + text="Album", + text2=None, + image=None, + play_url="/Add?service=ServiceA&albumid=1&playnow=1", + autoplay_url="/Add?service=ServiceA&albumid=1&autofill=1", + browse_key=None, + input_type=None, + context_menu_key=None, + context_menu=[], + ) + + +@async_mocketize(strict_mode=True) +async def test_play_browse_item(): + Entry.single_register( + Entry.GET, + "http://node:11000/Add?service=ServiceA&albumid=1&playnow=1", + status=200, + body="", + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + await client.play_browse_item(_browse_item_with_play_actions()) + + assert len(Mocket.request_list()) == 1 + + +@async_mocketize(strict_mode=True) +async def test_autoplay_browse_item(): + Entry.single_register( + Entry.GET, + "http://node:11000/Add?service=ServiceA&albumid=1&autofill=1", + status=200, + body="", + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + await client.play_browse_item(_browse_item_with_play_actions(), autoplay=True) + + assert len(Mocket.request_list()) == 1 + + +async def test_play_browse_item_rejects_missing_action(): + item = BrowseItem( + type="link", + text="Folder", + text2=None, + image=None, + play_url=None, + autoplay_url=None, + browse_key="folder", + input_type=None, + context_menu_key=None, + context_menu=[], + ) + async with Player("node") as client: + with pytest.raises(ValueError, match="playURL"): + await client.play_browse_item(item) + with pytest.raises(ValueError, match="autoplayURL"): + await client.play_browse_item(item, autoplay=True) + + @async_mocketize(strict_mode=True) async def test_context_menu(): key = "Airable:ContextMenu/opaque?url=station%3A1&hasInfo=1" From 84c43ecb380e6816f572446dc328e5f427d391bc Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Thu, 30 Jul 2026 18:07:26 +0200 Subject: [PATCH 10/16] Better url distinction --- AGENTS.md | 2 +- src/pyblu/entities.py | 4 ++-- src/pyblu/parse.py | 27 +++++++++++++++++++++++-- src/pyblu/player.py | 28 ++++++++++++++++++-------- tests/test_parse.py | 40 ++++++++++++++++++++++++++---------- tests/test_player.py | 47 ++++++++++++++++++++++++++++++++++++++----- 6 files changed, 119 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6b0250e..d6d6f73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ 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 context-menu keys and action URLs are opaque. Resolve keys through `context_menu()`; actions may mutate playback, the queue, presets, or service favorites. +- Browse keys, `playURL` / `autoplayURL`, and context-menu action URLs are opaque. They map to `BrowseItem.play_action_url` / `autoplay_action_url`; do not pass them to `Player.play_url()`. Resolve context-menu keys through `context_menu()` and invoke the returned URIs unchanged; 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. - The API uses "master/slave" terminology; the library exposes this as "leader/follower". diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index c8c5f5e..c4a37b8 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -219,7 +219,7 @@ class BrowseItem: 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_url: str | None + play_action_url: str | None """Opaque relative URI from the item's *playURL* attribute. Use *Player.play_browse_item* to invoke it. *None* if the item does not provide a default play action.""" browse_key: str | None @@ -230,7 +230,7 @@ class BrowseItem: """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_url: str | None = None + autoplay_action_url: str | None = None """Opaque relative URI from the item's *autoplayURL* attribute. Use *Player.play_browse_item* with **autoplay=True** to invoke it. *None* if the item does not provide an auto-fill play action.""" diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index 26cba37..e04353e 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -303,6 +303,29 @@ def parse_sleep(response: bytes) -> int: return int(sleep_element.text) if sleep_element.text else 0 +@_wrap_in_unxpected_response_error +def parse_command_response(response: bytes) -> None: + """Raise *PlayerCommandError* if an opaque command returns an error response. + + Successful command responses vary by endpoint and are intentionally ignored. + + :raises PlayerCommandError: If the player intentionally rejects the command. + :raises PlayerUnexpectedResponseError: If the response is not valid XML. + """ + if not response.strip(): + return + + tree = etree.fromstring(response) + if tree.tag != "error": + return + + message = (tree.findtext("message") or tree.text or "").strip() or "The player rejected the command" + details = [detail.text.strip() for detail in tree.findall("detail") if detail.text and detail.text.strip()] + if details: + message = f"{message}: {'; '.join(details)}" + raise PlayerCommandError(message) + + def _context_menu_action(x: etree._Element) -> ContextMenuAction: return ContextMenuAction( type=x.attrib["type"], @@ -317,8 +340,8 @@ def _browse_item(x: etree._Element) -> BrowseItem: text=x.attrib.get("text"), text2=x.attrib.get("text2"), image=x.attrib.get("image"), - play_url=x.attrib.get("playURL"), - autoplay_url=x.attrib.get("autoplayURL"), + play_action_url=x.attrib.get("playURL"), + autoplay_action_url=x.attrib.get("autoplayURL"), browse_key=x.attrib.get("browseKey"), input_type=x.attrib.get("inputType"), context_menu_key=x.attrib.get("contextMenuKey"), diff --git a/src/pyblu/player.py b/src/pyblu/player.py index a7926c6..2b18cae 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -6,6 +6,7 @@ from pyblu.parse import ( parse_add_follower, parse_browse_result, + parse_command_response, parse_context_menu, parse_deleted_play_queue_track, parse_inputs, @@ -81,6 +82,10 @@ async def _get(self, path: str, params: dict[str, str | int] | None = None, time except aiohttp.ClientConnectionError as e: raise PlayerUnreachableError(f"Connection error: {e}") from e + async def _execute_action_url(self, action_url: str, timeout: float | None = None) -> None: + data = await self._get(action_url, timeout=timeout) + parse_command_response(data) + async def status(self, etag: str | None = None, poll_timeout: int = 30, timeout: float | None = None) -> Status: """Get the current status of the player. @@ -186,9 +191,12 @@ async def play(self, seek: int | None = None, timeout: float | None = None) -> s return parse_state(data) async def play_url(self, url: str, timeout: float | None = None) -> str: - """Start playing a track from a URL. Can also be used to select inputs. See *inputs* for available inputs. + """Start playing a track from a source URL. Can also be used to select inputs. See *inputs* for available inputs. + + This method constructs a /Play request from a stream URL or BluOS source identifier. To invoke the opaque + action URI from a *BrowseItem*, use *play_browse_item* instead. - :param url: The URL of the track to play. + :param url: The stream URL or BluOS source identifier to play. :param timeout: The timeout in seconds for the request. This overrides the default timeout. :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library. @@ -515,7 +523,7 @@ async def browse( To search, pass **q** together with a **key** taken from the *search_key* of a previous *BrowseResult*. Set **with_context_menu_items** to include each item's context-menu actions in the response. - Playable items expose opaque *play_url* and optionally *autoplay_url* values. Invoke them with *play_browse_item*. + Playable items expose opaque *play_action_url* and optionally *autoplay_action_url* values. Invoke them with *play_browse_item*. :param key: The opaque key to browse. None returns the top-level menu. :param q: The search term. Only meaningful together with a *search_key* passed as **key**. @@ -542,8 +550,8 @@ async def browse( async def play_browse_item(self, item: BrowseItem, autoplay: bool = False, timeout: float | None = None) -> None: """Invoke a browse item's play action. - The opaque URI supplied by the player is sent back unchanged. By default this uses *BrowseItem.play_url*. - Set **autoplay** to use *BrowseItem.autoplay_url*, which may add subsequent tracks from the containing object + The opaque URI supplied by the player is sent back unchanged. By default this uses *BrowseItem.play_action_url*. + Set **autoplay** to use *BrowseItem.autoplay_action_url*, which may add subsequent tracks from the containing object to the auto-fill section of the play queue. :param item: The browse item to play. @@ -551,14 +559,16 @@ async def play_browse_item(self, item: BrowseItem, autoplay: bool = False, timeo :param timeout: The timeout in seconds for the request. This overrides the default timeout. :raises ValueError: If the item does not provide the selected play action. + :raises PlayerCommandError: If the player rejects the play action. + :raises PlayerUnexpectedResponseError: If the command response is not valid XML. :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. """ - action_url = item.autoplay_url if autoplay else item.play_url + action_url = item.autoplay_action_url if autoplay else item.play_action_url if action_url is None: action_name = "autoplayURL" if autoplay else "playURL" raise ValueError(f"Browse item does not provide {action_name}") - await self._get(action_url, timeout=timeout) + await self._execute_action_url(action_url, timeout=timeout) async def context_menu(self, key: str, timeout: float | None = None) -> list[ContextMenuAction]: """Get the context-menu actions available for a browse item. @@ -586,6 +596,8 @@ async def execute_context_menu_action(self, action: ContextMenuAction, timeout: :param action: The context-menu action to execute. :param timeout: The timeout in seconds for the request. This overrides the default timeout. + :raises PlayerCommandError: If the player rejects the action. + :raises PlayerUnexpectedResponseError: If the command response is not valid XML. :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. """ - await self._get(action.action_url, timeout=timeout) + await self._execute_action_url(action.action_url, timeout=timeout) diff --git a/tests/test_parse.py b/tests/test_parse.py index 55bd46e..3d40393 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -1,10 +1,11 @@ import pytest from pyblu import ContextMenuAction, PairedPlayer, PlayQueueTrack -from pyblu.errors import PlayerBrowseError, PlayerCommandError +from pyblu.errors import PlayerBrowseError, PlayerCommandError, PlayerUnexpectedResponseError from pyblu.parse import ( parse_add_follower, parse_browse_result, + parse_command_response, parse_context_menu, parse_deleted_play_queue_track, parse_moved_play_queue_track, @@ -327,6 +328,23 @@ def test_parse_save_empty_play_queue_error(): parse_saved_play_queue("empty") +@pytest.mark.parametrize("data", [b"", b"", b"play", b""]) +def test_parse_successful_command_response(data: bytes): + assert parse_command_response(data) is None + + +def test_parse_command_error_response(): + data = b"Service unavailableTry again later" + + with pytest.raises(PlayerCommandError, match="Service unavailable: Try again later"): + parse_command_response(data) + + +def test_parse_invalid_command_response(): + with pytest.raises(PlayerUnexpectedResponseError): + parse_command_response(b"not XML") + + def test_parse_presets(): data = """ @@ -411,19 +429,19 @@ def test_parse_browse_root_menu(): assert playlists.type == "link" assert playlists.text == "Playlists" assert playlists.browse_key == "playlists" - assert playlists.play_url is None + assert playlists.play_action_url is None assert playlists.input_type is None assert bluetooth.type == "audio" assert bluetooth.text == "Bluetooth" - assert bluetooth.play_url == "/Play?url=Capture%3Abluez%3Abluetooth" - assert bluetooth.autoplay_url is None + assert bluetooth.play_action_url == "/Play?url=Capture%3Abluez%3Abluetooth" + assert bluetooth.autoplay_action_url is None assert bluetooth.browse_key is None assert bluetooth.input_type == "bluetooth" assert service_a.type == "link" assert service_a.browse_key == "ServiceA:" - assert service_a.play_url is None + assert service_a.play_action_url is None def test_parse_browse_empty_list(): @@ -484,18 +502,18 @@ def test_parse_browse_categories_with_context_menus(): assert len(group_one.items) == 2 assert group_one.items[0].text == "Station One" assert group_one.items[0].text2 == "Artist One" - assert group_one.items[0].play_url == "/Play?url=Service%3Astream-1&title=Station+One&image=http%3A%2F%2Fexample.com%2Fcover.jpg" - assert group_one.items[0].autoplay_url is None + assert group_one.items[0].play_action_url == "/Play?url=Service%3Astream-1&title=Station+One&image=http%3A%2F%2Fexample.com%2Fcover.jpg" + assert group_one.items[0].autoplay_action_url is None assert group_one.items[0].context_menu_key == "Generic:ContextMenu/opaque%2Fkey%3Fid%3D1" assert group_one.items[0].context_menu == [ContextMenuAction(type="favourite-add", text="Action", action_url="/Action?id=1&value=opaque%2Fvalue")] - assert group_one.items[1].play_url == "/Play?url=Service%3Astream-2" - assert group_one.items[1].autoplay_url == "/Play?url=Service%3Astream-2&autofill=1" + assert group_one.items[1].play_action_url == "/Play?url=Service%3Astream-2" + assert group_one.items[1].autoplay_action_url == "/Play?url=Service%3Astream-2&autofill=1" assert group_one.items[1].context_menu_key is None assert not group_one.items[1].context_menu assert group_two.text == "Group Two" assert len(group_two.items) == 1 - assert group_two.items[0].play_url == "/Play?url=Service%3Astream-3" + assert group_two.items[0].play_action_url == "/Play?url=Service%3Astream-3" def test_parse_browse_search_key(): @@ -530,7 +548,7 @@ def test_parse_browse_preserves_non_play_action_url(): result = parse_browse_result(data) - assert result.items[0].play_url == "/Add?service=Generic&albumid=12345&playnow=1" + assert result.items[0].play_action_url == "/Add?service=Generic&albumid=12345&playnow=1" def test_parse_context_menu(): diff --git a/tests/test_player.py b/tests/test_player.py index 51f2796..d9abed6 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -903,7 +903,7 @@ async def test_browse_root(): assert result.type == "menu" assert len(result.items) == 2 assert result.items[0].browse_key == "playlists" - assert result.items[1].play_url == "/Play?url=Capture%3Abluez%3Abluetooth" + assert result.items[1].play_action_url == "/Play?url=Capture%3Abluez%3Abluetooth" assert result.items[1].input_type == "bluetooth" @@ -996,8 +996,8 @@ def _browse_item_with_play_actions() -> BrowseItem: text="Album", text2=None, image=None, - play_url="/Add?service=ServiceA&albumid=1&playnow=1", - autoplay_url="/Add?service=ServiceA&albumid=1&autofill=1", + play_action_url="/Add?service=ServiceA&albumid=1&playnow=1", + autoplay_action_url="/Add?service=ServiceA&albumid=1&autofill=1", browse_key=None, input_type=None, context_menu_key=None, @@ -1020,6 +1020,22 @@ async def test_play_browse_item(): assert len(Mocket.request_list()) == 1 +@async_mocketize(strict_mode=True) +async def test_play_browse_item_command_error(): + Entry.single_register( + Entry.GET, + "http://node:11000/Add?service=ServiceA&albumid=1&playnow=1", + status=200, + body="Service unavailable", + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + with pytest.raises(PlayerCommandError, match="Service unavailable"): + await client.play_browse_item(_browse_item_with_play_actions()) + + assert len(Mocket.request_list()) == 1 + + @async_mocketize(strict_mode=True) async def test_autoplay_browse_item(): Entry.single_register( @@ -1041,8 +1057,8 @@ async def test_play_browse_item_rejects_missing_action(): text="Folder", text2=None, image=None, - play_url=None, - autoplay_url=None, + play_action_url=None, + autoplay_action_url=None, browse_key="folder", input_type=None, context_menu_key=None, @@ -1100,3 +1116,24 @@ async def test_execute_context_menu_action(): await client.execute_context_menu_action(action) assert len(Mocket.request_list()) == 1 + + +@async_mocketize(strict_mode=True) +async def test_execute_context_menu_action_command_error(): + action = ContextMenuAction( + type="favourite-add", + text="Favourite", + action_url="/AddFavourite?service=Airable&url=opaque%3Astation%2F1", + ) + Entry.single_register( + Entry.GET, + "http://node:11000/AddFavourite?service=Airable&url=opaque%3Astation%2F1", + status=200, + body="Service unavailable", + ) + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: + async with Player("node", session=session) as client: + with pytest.raises(PlayerCommandError, match="Service unavailable"): + await client.execute_context_menu_action(action) + + assert len(Mocket.request_list()) == 1 From 2d0ee785a003f685a29a6004588e3b1bb1ab1873 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Thu, 30 Jul 2026 18:22:12 +0200 Subject: [PATCH 11/16] Use single method to run opaque urls --- AGENTS.md | 2 +- docs/index.rst | 3 +- docs/usage.rst | 90 +++++++++++++++++++++++++++++++ src/pyblu/entities.py | 10 ++-- src/pyblu/player.py | 69 ++++++++---------------- tests/test_player.py | 120 ++++++------------------------------------ 6 files changed, 138 insertions(+), 156 deletions(-) create mode 100644 docs/usage.rst diff --git a/AGENTS.md b/AGENTS.md index d6d6f73..440b806 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ 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`; do not pass them to `Player.play_url()`. Resolve context-menu keys through `context_menu()` and invoke the returned URIs unchanged; actions may mutate playback, the queue, presets, or service favorites. +- 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. - The API uses "master/slave" terminology; the library exposes this as "leader/follower". diff --git a/docs/index.rst b/docs/index.rst index f0cd13b..ae59af4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,7 +2,7 @@ pyblu ============ This is an Python library for interfacing with BluOS player. It uses the -`BluOS API `_ +`BluOS API `_ to control and query the status of BluOS players. Basic usage example: @@ -19,4 +19,5 @@ Basic usage example: .. toctree:: :maxdepth: 2 + usage api diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..3f39414 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,90 @@ +Playing sources and browse actions +=================================== + +BluOS exposes two different kinds of URL-like values. They are invoked differently. + +Source URLs +----------- + +:meth:`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 ` and +:meth:`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 `; do not pass them to +:meth:`Player.play_url `. + +A :class:`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 + + result = await player.browse(key="Service:albums") + 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 + + result = await player.browse( + key="Service:albums", + 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. diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index c4a37b8..0fbb23a 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -204,7 +204,7 @@ class ContextMenuAction: text: str | None """Human-readable action label.""" action_url: str - """Opaque relative URL used by *Player.execute_context_menu_action*. Do not parse or modify it.""" + """Opaque relative action URI. Pass it unchanged to *Player.execute_action*.""" @dataclass @@ -220,8 +220,8 @@ class BrowseItem: image: str | None """Icon or artwork URL.""" play_action_url: str | None - """Opaque relative URI from the item's *playURL* attribute. Use *Player.play_browse_item* to invoke it. - *None* if the item does not provide a default play action.""" + """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 @@ -231,8 +231,8 @@ class BrowseItem: 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. Use *Player.play_browse_item* with **autoplay=True** to invoke it. - *None* if the item does not provide an auto-fill play action.""" + """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*.""" @dataclass diff --git a/src/pyblu/player.py b/src/pyblu/player.py index 2b18cae..30a429a 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -2,7 +2,7 @@ import aiohttp -from pyblu.entities import BrowseItem, BrowseResult, ContextMenuAction, Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input +from pyblu.entities import BrowseResult, ContextMenuAction, Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input from pyblu.parse import ( parse_add_follower, parse_browse_result, @@ -82,10 +82,6 @@ async def _get(self, path: str, params: dict[str, str | int] | None = None, time except aiohttp.ClientConnectionError as e: raise PlayerUnreachableError(f"Connection error: {e}") from e - async def _execute_action_url(self, action_url: str, timeout: float | None = None) -> None: - data = await self._get(action_url, timeout=timeout) - parse_command_response(data) - async def status(self, etag: str | None = None, poll_timeout: int = 30, timeout: float | None = None) -> Status: """Get the current status of the player. @@ -193,8 +189,9 @@ async def play(self, seek: int | None = None, timeout: float | None = None) -> s async def play_url(self, url: str, timeout: float | None = None) -> str: """Start playing a track from a source URL. Can also be used to select inputs. See *inputs* for available inputs. - This method constructs a /Play request from a stream URL or BluOS source identifier. To invoke the opaque - action URI from a *BrowseItem*, use *play_browse_item* instead. + This method constructs a /Play request from a stream URL or BluOS source identifier. Do not pass it an action + URI from the browse API; invoke *BrowseItem.play_action_url*, *BrowseItem.autoplay_action_url*, and + *ContextMenuAction.action_url* values with *execute_action* instead. :param url: The stream URL or BluOS source identifier to play. :param timeout: The timeout in seconds for the request. This overrides the default timeout. @@ -210,6 +207,24 @@ async def play_url(self, url: str, timeout: float | None = None) -> str: data = await self._get("/Play", params=params, timeout=timeout) return parse_state(data) + async def execute_action(self, action_url: str, timeout: float | None = None) -> None: + """Invoke an opaque action URI returned by the browse API. + + Pass a *BrowseItem.play_action_url*, *BrowseItem.autoplay_action_url*, or *ContextMenuAction.action_url* + to this method without parsing, decoding, or otherwise modifying it. Unlike *play_url*, this method does not + construct a /Play request: the complete action URI is sent directly to the player. Actions can start playback, + modify the play queue, add a preset, or change a service favorite. + + :param action_url: An opaque action URI supplied by the player. + :param timeout: The timeout in seconds for the request. This overrides the default timeout. + + :raises PlayerCommandError: If the player rejects the action. + :raises PlayerUnexpectedResponseError: If the command response is not valid XML. + :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. + """ + data = await self._get(action_url, timeout=timeout) + parse_command_response(data) + async def pause(self, toggle: bool | None = None, timeout: float | None = None) -> str: """Pause the current track. **toggle** can be used to toggle between playing and pause. @@ -523,7 +538,7 @@ async def browse( To search, pass **q** together with a **key** taken from the *search_key* of a previous *BrowseResult*. Set **with_context_menu_items** to include each item's context-menu actions in the response. - Playable items expose opaque *play_action_url* and optionally *autoplay_action_url* values. Invoke them with *play_browse_item*. + Playable items expose opaque *play_action_url* and optionally *autoplay_action_url* values. Invoke either value with *execute_action*. :param key: The opaque key to browse. None returns the top-level menu. :param q: The search term. Only meaningful together with a *search_key* passed as **key**. @@ -547,29 +562,6 @@ async def browse( data = await self._get("/Browse", params=params, timeout=timeout) return parse_browse_result(data) - async def play_browse_item(self, item: BrowseItem, autoplay: bool = False, timeout: float | None = None) -> None: - """Invoke a browse item's play action. - - The opaque URI supplied by the player is sent back unchanged. By default this uses *BrowseItem.play_action_url*. - Set **autoplay** to use *BrowseItem.autoplay_action_url*, which may add subsequent tracks from the containing object - to the auto-fill section of the play queue. - - :param item: The browse item to play. - :param autoplay: Use the item's auto-fill play action instead of its default play action. - :param timeout: The timeout in seconds for the request. This overrides the default timeout. - - :raises ValueError: If the item does not provide the selected play action. - :raises PlayerCommandError: If the player rejects the play action. - :raises PlayerUnexpectedResponseError: If the command response is not valid XML. - :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. - """ - action_url = item.autoplay_action_url if autoplay else item.play_action_url - if action_url is None: - action_name = "autoplayURL" if autoplay else "playURL" - raise ValueError(f"Browse item does not provide {action_name}") - - await self._execute_action_url(action_url, timeout=timeout) - async def context_menu(self, key: str, timeout: float | None = None) -> list[ContextMenuAction]: """Get the context-menu actions available for a browse item. @@ -586,18 +578,3 @@ async def context_menu(self, key: str, timeout: float | None = None) -> list[Con """ data = await self._get("/Browse", params={"key": key}, timeout=timeout) return parse_context_menu(data) - - async def execute_context_menu_action(self, action: ContextMenuAction, timeout: float | None = None) -> None: - """Execute a context-menu action returned by *context_menu* or embedded in a *BrowseItem*. - - Context-menu actions can mutate player or service state: for example, they may start playback, modify the play queue, - add a preset, or change a favorite. The action's opaque relative URL is sent directly to the player. - - :param action: The context-menu action to execute. - :param timeout: The timeout in seconds for the request. This overrides the default timeout. - - :raises PlayerCommandError: If the player rejects the action. - :raises PlayerUnexpectedResponseError: If the command response is not valid XML. - :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out. - """ - await self._execute_action_url(action.action_url, timeout=timeout) diff --git a/tests/test_player.py b/tests/test_player.py index d9abed6..3a9f0e5 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -9,7 +9,7 @@ from mocket.plugins.aiohttp_connector import MocketTCPConnector import pytest -from pyblu import BrowseItem, ContextMenuAction, Player, PairedPlayer +from pyblu import ContextMenuAction, Player, PairedPlayer from pyblu.entities import Preset, Input from pyblu.errors import PlayerBrowseError, PlayerCommandError, PlayerUnreachableError @@ -990,87 +990,42 @@ async def test_browse_error_response(): assert exc_info.value.details == ["not recognised"] -def _browse_item_with_play_actions() -> BrowseItem: - return BrowseItem( - type="album", - text="Album", - text2=None, - image=None, - play_action_url="/Add?service=ServiceA&albumid=1&playnow=1", - autoplay_action_url="/Add?service=ServiceA&albumid=1&autofill=1", - browse_key=None, - input_type=None, - context_menu_key=None, - context_menu=[], - ) - - +@pytest.mark.parametrize( + "action_url", + [ + "/Play?url=Service%3Astream-1&title=Station+One", + "/Add?service=ServiceA&albumid=1&autofill=1", + "/AddFavourite?service=Airable&url=opaque%3Astation%2F1", + ], +) @async_mocketize(strict_mode=True) -async def test_play_browse_item(): - Entry.single_register( - Entry.GET, - "http://node:11000/Add?service=ServiceA&albumid=1&playnow=1", - status=200, - body="", - ) +async def test_execute_action(action_url: str): + Entry.single_register(Entry.GET, f"http://node:11000{action_url}", status=200, body="") + async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: async with Player("node", session=session) as client: - await client.play_browse_item(_browse_item_with_play_actions()) + await client.execute_action(action_url) assert len(Mocket.request_list()) == 1 @async_mocketize(strict_mode=True) -async def test_play_browse_item_command_error(): +async def test_execute_action_command_error(): + action_url = "/Add?service=ServiceA&albumid=1&playnow=1" Entry.single_register( Entry.GET, - "http://node:11000/Add?service=ServiceA&albumid=1&playnow=1", + f"http://node:11000{action_url}", status=200, body="Service unavailable", ) async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: async with Player("node", session=session) as client: with pytest.raises(PlayerCommandError, match="Service unavailable"): - await client.play_browse_item(_browse_item_with_play_actions()) + await client.execute_action(action_url) assert len(Mocket.request_list()) == 1 -@async_mocketize(strict_mode=True) -async def test_autoplay_browse_item(): - Entry.single_register( - Entry.GET, - "http://node:11000/Add?service=ServiceA&albumid=1&autofill=1", - status=200, - body="", - ) - async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: - async with Player("node", session=session) as client: - await client.play_browse_item(_browse_item_with_play_actions(), autoplay=True) - - assert len(Mocket.request_list()) == 1 - - -async def test_play_browse_item_rejects_missing_action(): - item = BrowseItem( - type="link", - text="Folder", - text2=None, - image=None, - play_action_url=None, - autoplay_action_url=None, - browse_key="folder", - input_type=None, - context_menu_key=None, - context_menu=[], - ) - async with Player("node") as client: - with pytest.raises(ValueError, match="playURL"): - await client.play_browse_item(item) - with pytest.raises(ValueError, match="autoplayURL"): - await client.play_browse_item(item, autoplay=True) - - @async_mocketize(strict_mode=True) async def test_context_menu(): key = "Airable:ContextMenu/opaque?url=station%3A1&hasInfo=1" @@ -1096,44 +1051,3 @@ async def test_context_menu(): action_url="/AddFavourite?service=Airable&url=opaque%3Astation%2F1", ) ] - - -@async_mocketize(strict_mode=True) -async def test_execute_context_menu_action(): - action = ContextMenuAction( - type="favourite-add", - text="Favourite", - action_url="/AddFavourite?service=Airable&url=opaque%3Astation%2F1", - ) - Entry.single_register( - Entry.GET, - "http://node:11000/AddFavourite?service=Airable&url=opaque%3Astation%2F1", - status=200, - body="", - ) - async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: - async with Player("node", session=session) as client: - await client.execute_context_menu_action(action) - - assert len(Mocket.request_list()) == 1 - - -@async_mocketize(strict_mode=True) -async def test_execute_context_menu_action_command_error(): - action = ContextMenuAction( - type="favourite-add", - text="Favourite", - action_url="/AddFavourite?service=Airable&url=opaque%3Astation%2F1", - ) - Entry.single_register( - Entry.GET, - "http://node:11000/AddFavourite?service=Airable&url=opaque%3Astation%2F1", - status=200, - body="Service unavailable", - ) - async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: - async with Player("node", session=session) as client: - with pytest.raises(PlayerCommandError, match="Service unavailable"): - await client.execute_context_menu_action(action) - - assert len(Mocket.request_list()) == 1 From 6dd30d6f137dbd969bd3ea34b3a62e74b47dbf78 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Thu, 30 Jul 2026 19:04:44 +0200 Subject: [PATCH 12/16] Fix optional fields --- AGENTS.md | 2 +- src/pyblu/entities.py | 8 ++++---- src/pyblu/parse.py | 9 +++++++-- tests/test_parse.py | 16 ++++++++++++++-- tests/test_player.py | 10 ++++++++-- 5 files changed, 34 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 440b806..ae9ac2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ The library has four modules with a clear separation of concerns: - `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. +- `/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. diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index 0fbb23a..4c23cd8 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -157,10 +157,10 @@ class PlayQueueTrack: class PlayQueue: id: str """Unique id for the current play queue state. Changes whenever the play queue changes.""" - shuffle: bool - """Play queue is shuffled.""" - modified: bool - """Play queue 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 """Total number of tracks in the play queue, including tracks not returned by a paginated request.""" name: str | None = None diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index e04353e..800b055 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -157,6 +157,11 @@ def _attribute_or_child(element: etree._Element, name: str) -> str | None: return value if value is not None else element.findtext(name) +def _optional_bool_attribute_or_child(element: etree._Element, name: str) -> bool | None: + value = _attribute_or_child(element, name) + return value == "1" if value is not None else None + + @_wrap_in_unxpected_response_error def parse_play_queue(response: bytes) -> PlayQueue: """ @@ -193,9 +198,9 @@ def parse_play_queue(response: bytes) -> PlayQueue: return PlayQueue( id=queue_id, - modified=_attribute_or_child(playlist_element, "modified") == "1", + modified=_optional_bool_attribute_or_child(playlist_element, "modified"), length=int(length), - shuffle=_attribute_or_child(playlist_element, "shuffle") == "1", + shuffle=_optional_bool_attribute_or_child(playlist_element, "shuffle"), name=_attribute_or_child(playlist_element, "name"), repeat=int(repeat) if (repeat := _attribute_or_child(playlist_element, "repeat")) is not None else None, tracks=tracks, diff --git a/tests/test_parse.py b/tests/test_parse.py index 3d40393..aed15f4 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -310,13 +310,25 @@ def test_parse_play_queue_status(): assert play_queue.id == "243" assert play_queue.name == "" - assert play_queue.modified + assert play_queue.modified is True assert play_queue.length == 13 - assert not play_queue.shuffle + assert play_queue.shuffle is None assert play_queue.repeat is None assert not play_queue.tracks +def test_parse_empty_play_queue_listing_with_optional_metadata(): + play_queue = parse_play_queue('') + + assert play_queue.id == "17" + assert play_queue.length == 0 + assert play_queue.name is None + assert play_queue.modified is None + assert play_queue.shuffle is False + assert play_queue.repeat == 0 + assert not play_queue.tracks + + def test_parse_play_queue_mutation_responses(): assert parse_deleted_play_queue_track("9") == 9 assert parse_moved_play_queue_track("moved") is None diff --git a/tests/test_player.py b/tests/test_player.py index 3a9f0e5..23cd0ba 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -553,9 +553,9 @@ async def test_clear(): assert len(Mocket.request_list()) == 1 assert play_queue.id == "1" - assert not play_queue.modified + assert play_queue.modified is False assert play_queue.length == 0 - assert not play_queue.shuffle + assert play_queue.shuffle is None @async_mocketize(strict_mode=True) @@ -597,6 +597,10 @@ async def test_play_queue_status_only(): assert len(Mocket.request_list()) == 1 assert play_queue.id == "15" assert play_queue.length == 3 + assert play_queue.name is None + assert play_queue.modified is True + assert play_queue.shuffle is None + assert play_queue.repeat is None assert play_queue.tracks == [] @@ -614,6 +618,8 @@ async def test_play_queue_page(): assert len(Mocket.request_list()) == 1 assert play_queue.length == 30 + assert play_queue.modified is False + assert play_queue.shuffle is None assert play_queue.tracks[0].id == 10 From b4f239c0ebbd42c2954890bdadd1c28a4b8839ff Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Thu, 30 Jul 2026 19:08:51 +0200 Subject: [PATCH 13/16] Small fix in docs --- docs/usage.rst | 8 ++++++-- src/pyblu/player.py | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/usage.rst b/docs/usage.rst index 3f39414..74e0dca 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -41,7 +41,9 @@ Both fields are optional, so check for ``None`` before invoking them: .. code-block:: python - result = await player.browse(key="Service:albums") + 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: @@ -77,8 +79,10 @@ 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="Service:albums", + key=browse_item.browse_key, with_context_menu_items=True, ) item = result.items[0] diff --git a/src/pyblu/player.py b/src/pyblu/player.py index 30a429a..dad4baa 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -535,13 +535,14 @@ async def browse( or *search_key* / *next_key* / *parent_key* of a *BrowseResult* or *BrowseCategory*. Do not parse or modify it. Use *context_menu* rather than this method for a *context_menu_key*. - To search, pass **q** together with a **key** taken from the *search_key* of a previous *BrowseResult*. + To search within a service or deeper browse context, pass **q** together with a **key** taken from the + *search_key* of a previous *BrowseResult*. Pass **q** without **key** to perform a top-level search. Set **with_context_menu_items** to include each item's context-menu actions in the response. Playable items expose opaque *play_action_url* and optionally *autoplay_action_url* values. Invoke either value with *execute_action*. :param key: The opaque key to browse. None returns the top-level menu. - :param q: The search term. Only meaningful together with a *search_key* passed as **key**. + :param q: The search term. Without **key**, performs a top-level search; with **key**, searches the context identified by a *search_key*. :param timeout: The timeout in seconds for the request. This overrides the default timeout. :param with_context_menu_items: Include inline context-menu actions for returned items. From 43276ff3fe2748a692bbd4aa2b42c08c22f9fb47 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Fri, 7 Aug 2026 00:09:18 +0200 Subject: [PATCH 14/16] Better url joining --- src/pyblu/player.py | 3 ++- tests/test_player.py | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/pyblu/player.py b/src/pyblu/player.py index ebec435..0ebf5a1 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -1,4 +1,5 @@ from types import TracebackType +from urllib.parse import urljoin import aiohttp @@ -73,7 +74,7 @@ async def _get(self, path: str, params: dict[str, str | int] | None = None, time used_timeout = timeout if timeout is not None else self._default_timeout try: async with self._session.get( - f"{self.base_url}{path}", + urljoin(f"{self.base_url}/", path), params=params, timeout=aiohttp.ClientTimeout(total=used_timeout), ) as response: diff --git a/tests/test_player.py b/tests/test_player.py index aea6fac..dba4ef6 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -1057,16 +1057,16 @@ async def test_browse_error_response(): @pytest.mark.parametrize( - "action_url", + ("action_url", "request_url"), [ - "/Play?url=Service%3Astream-1&title=Station+One", - "/Add?service=ServiceA&albumid=1&autofill=1", - "/AddFavourite?service=Airable&url=opaque%3Astation%2F1", + ("/Play?url=Service%3Astream-1&title=Station+One", "http://node:11000/Play?url=Service%3Astream-1&title=Station+One"), + ("Add?service=ServiceA&albumid=1&autofill=1", "http://node:11000/Add?service=ServiceA&albumid=1&autofill=1"), + ("http://node:11000/AddFavourite?service=Airable&url=opaque%3Astation%2F1", "http://node:11000/AddFavourite?service=Airable&url=opaque%3Astation%2F1"), ], ) @async_mocketize(strict_mode=True) -async def test_execute_action(action_url: str): - Entry.single_register(Entry.GET, f"http://node:11000{action_url}", status=200, body="") +async def test_execute_action(action_url: str, request_url: str): + Entry.single_register(Entry.GET, request_url, status=200, body="") async with aiohttp.ClientSession(connector=MocketTCPConnector()) as session: async with Player("node", session=session) as client: From aaacb35fa25f714d0bd33789ff023ce46fbe3161 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Fri, 7 Aug 2026 22:42:37 +0200 Subject: [PATCH 15/16] Drop service field --- src/pyblu/entities.py | 2 -- src/pyblu/parse.py | 1 - tests/test_parse.py | 4 +--- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index f92c180..ec7debc 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -251,8 +251,6 @@ class BrowseCategory: class BrowseResult: type: str """Result list type. Common values are "menu", "items", "albums", "tracks", "playlists", "sections", "folders".""" - service: str | None - """Service id (e.g. "TuneIn", "Deezer"). Not for UI display.""" service_name: str | None """Human-readable service name, suitable for UI.""" service_icon: str | None diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index 8da9f67..b8626b0 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -394,7 +394,6 @@ def parse_browse_result(response: bytes) -> BrowseResult: browse_result = BrowseResult( type=browse_element.attrib["type"], - service=browse_element.attrib.get("service"), service_name=browse_element.attrib.get("serviceName"), service_icon=browse_element.attrib.get("serviceIcon"), search_key=browse_element.attrib.get("searchKey"), diff --git a/tests/test_parse.py b/tests/test_parse.py index aed15f4..829f49c 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -428,7 +428,6 @@ def test_parse_browse_root_menu(): result = parse_browse_result(data) assert result.type == "menu" - assert result.service is None assert result.service_name is None assert result.search_key is None assert result.next_key is None @@ -467,7 +466,7 @@ def test_parse_browse_empty_list(): def test_parse_browse_service_menu(): - data = """ + data = """ """ @@ -475,7 +474,6 @@ def test_parse_browse_service_menu(): result = parse_browse_result(data) assert result.type == "items" - assert result.service == "ServiceA" assert result.service_name == "Service A" assert result.service_icon == "/icons/service_a.png" assert len(result.items) == 2 From 533769a3a94eecfc31ed44c37958bbec6f4df42f Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Sat, 8 Aug 2026 08:58:22 +0200 Subject: [PATCH 16/16] Add duration, tracks and is_favorite --- src/pyblu/entities.py | 6 ++++++ src/pyblu/parse.py | 8 ++++++++ tests/test_parse.py | 21 +++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index ec7debc..4394527 100644 --- a/src/pyblu/entities.py +++ b/src/pyblu/entities.py @@ -233,6 +233,12 @@ class BrowseItem: 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 diff --git a/src/pyblu/parse.py b/src/pyblu/parse.py index b8626b0..128c701 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -164,6 +164,11 @@ def _optional_bool_attribute_or_child(element: etree._Element, name: str) -> boo return value == "1" if value is not None else None +def _optional_bool_attribute(element: etree._Element, name: str) -> bool | None: + value = element.attrib.get(name) + return value.lower() in ("1", "true") if value is not None else None + + @_wrap_in_unxpected_response_error def parse_play_queue(response: bytes) -> PlayQueue: """ @@ -353,6 +358,9 @@ def _browse_item(x: etree._Element) -> BrowseItem: input_type=x.attrib.get("inputType"), context_menu_key=x.attrib.get("contextMenuKey"), context_menu=[_context_menu_action(y) for y in x.xpath("./contextMenu/item")], + duration=int(duration) if (duration := x.attrib.get("duration")) is not None else None, + is_favourite=_optional_bool_attribute(x, "isFavourite"), + tracks=int(tracks) if (tracks := x.attrib.get("tracks")) is not None else None, ) diff --git a/tests/test_parse.py b/tests/test_parse.py index 829f49c..c81920a 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -442,6 +442,9 @@ def test_parse_browse_root_menu(): assert playlists.browse_key == "playlists" assert playlists.play_action_url is None assert playlists.input_type is None + assert playlists.duration is None + assert playlists.is_favourite is None + assert playlists.tracks is None assert bluetooth.type == "audio" assert bluetooth.text == "Bluetooth" @@ -561,6 +564,24 @@ def test_parse_browse_preserves_non_play_action_url(): assert result.items[0].play_action_url == "/Add?service=Generic&albumid=12345&playnow=1" +def test_parse_browse_item_media_metadata(): + data = """ + + +""" + + result = parse_browse_result(data) + + album, track = result.items + assert album.duration == 2596 + assert album.is_favourite is True + assert album.tracks == 11 + assert track.duration == 291 + assert track.is_favourite is False + assert track.tracks is None + + def test_parse_context_menu(): data = """