diff --git a/AGENTS.md b/AGENTS.md index 5081886..5f25f6a 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 @@ -33,11 +46,11 @@ The library has four modules with a clear separation of concerns: - **`player.py`** — `Player` class: the public API. Each method makes one HTTP GET request to the BluOS endpoint, passing arguments as query parameters, then delegates the raw response bytes to a parse function. All methods are async and decorated with `@_wrap_in_unreachable_error`. -- **`parse.py`** — Stateless XML parsing functions. Each takes `bytes` from the HTTP response and returns a typed entity. Uses `lxml.etree` for parsing. All functions are decorated with `@_wrap_in_unxpected_response_error`. +- **`parse.py`** — Stateless XML parsing functions. Each takes `bytes` from the HTTP response and returns a typed entity. Uses `lxml.etree` for parsing. All public parse functions are decorated with `@_wrap_in_unxpected_response_error`. -- **`entities.py`** — Pure `@dataclass` types (`Status`, `Volume`, `SyncStatus`, `PairedPlayer`, `PlayQueue`, `Preset`, `Input`). No logic. +- **`entities.py`** — Pure `@dataclass` types for player state, play queues, and media browsing, including `PlayQueue`, `PlayQueueTrack`, `BrowseResult`, `BrowseItem`, and `ContextMenuAction`. No logic. -- **`errors.py`** — Exception hierarchy (`PlayerError` → `PlayerUnreachableError` / `PlayerUnexpectedResponseError`) and two decorator factories that wrap exceptions at the Player and parse layers respectively. +- **`errors.py`** — Exception hierarchy (`PlayerError` → `PlayerUnreachableError` / `PlayerUnexpectedResponseError` / `PlayerCommandError` / `PlayerBrowseError`) and decorators/helpers for translating transport, parser, and structured player errors. ### Key Conventions @@ -47,6 +60,8 @@ The library has four modules with a clear separation of concerns: - All operations use HTTP GET, including mutations (play, pause, volume set). - `inputs()` calls `/RadioBrowse?service=Capture`, not a dedicated inputs endpoint. - `play_url()` and `play()` both map to the `/Play` endpoint. +- Browse keys, `playURL` / `autoplayURL`, and context-menu action URLs are opaque. They map to `BrowseItem.play_action_url` / `autoplay_action_url` and `ContextMenuAction.action_url`; pass them unchanged to `Player.execute_action()`, never to `Player.play_url()`. Resolve context-menu keys through `context_menu()`; actions may mutate playback, the queue, presets, or service favorites. +- `/Playlist` returns queue metadata as child elements for `length=1`, but as attributes for full and paginated listings; `parse_play_queue()` supports both forms. Optional metadata varies by response and player state: `name`, `modified`, `shuffle`, and `repeat` may be absent and are exposed as `None`. - The API uses "master/slave" terminology; the library exposes this as "leader/follower". **Long polling**: `status()` and `sync_status()` accept an `etag` parameter. When provided, `poll_timeout` must be strictly less than `timeout` — the Player method validates this and raises `ValueError` if violated. diff --git a/docs/api.rst b/docs/api.rst index 4d17278..a2c4ec4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -29,9 +29,24 @@ Data Classes .. autoclass:: pyblu.PlayQueue :members: +.. autoclass:: pyblu.PlayQueueTrack + :members: + .. autoclass:: pyblu.Input :members: +.. autoclass:: pyblu.BrowseResult + :members: + +.. autoclass:: pyblu.BrowseItem + :members: + +.. autoclass:: pyblu.BrowseCategory + :members: + +.. autoclass:: pyblu.ContextMenuAction + :members: + Exceptions ---------- @@ -42,4 +57,10 @@ Exceptions :members: .. autoclass:: pyblu.errors.PlayerUnexpectedResponseError + :members: + +.. autoclass:: pyblu.errors.PlayerCommandError + :members: + +.. autoclass:: pyblu.errors.PlayerBrowseError :members: \ No newline at end of file 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..74e0dca --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,94 @@ +Playing sources and browse actions +=================================== + +BluOS exposes two different kinds of URL-like values. They are invoked differently. + +Source URLs +----------- + +:meth:`Player.play_url ` 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 + + root = await player.browse() + browse_item = next(item for item in root.items if item.browse_key is not None) + result = await player.browse(key=browse_item.browse_key) + item = result.items[0] + + if item.play_action_url is not None: + await player.execute_action(item.play_action_url) + + # Use this instead when the service provides an auto-fill action. + if item.autoplay_action_url is not None: + await player.execute_action(item.autoplay_action_url) + +For example, an action URI might be ``/Add?service=Service&albumid=1&playnow=1``. It is already a complete player +request. Calling ``player.play_url(item.play_action_url)`` would incorrectly place that complete URI inside a second +``/Play?url=...`` request. + +Context-menu actions +-------------------- + +Context-menu action URLs use the same execution method. Actions can start playback, modify the play queue, add a +preset, or change a service favorite. + +Actions can be requested lazily using an item's ``context_menu_key``: + +.. code-block:: python + + if item.context_menu_key is not None: + actions = await player.context_menu(item.context_menu_key) + for action in actions: + print(action.text, action.type) + + if actions: + await player.execute_action(actions[0].action_url) + +Alternatively, request inline actions while browsing: + +.. code-block:: python + + root = await player.browse() + browse_item = next(item for item in root.items if item.browse_key is not None) + result = await player.browse( + key=browse_item.browse_key, + with_context_menu_items=True, + ) + item = result.items[0] + + if item.context_menu: + await player.execute_action(item.context_menu[0].action_url) + +Browse keys and all action URLs are opaque. Do not parse, decode, reconstruct, or otherwise modify them before +passing them back to the same player that returned them. diff --git a/src/pyblu/__init__.py b/src/pyblu/__init__.py index f0fa39e..fc7a8e0 100644 --- a/src/pyblu/__init__.py +++ b/src/pyblu/__init__.py @@ -1,10 +1,15 @@ """A Python library for controlling BluOS players.""" from .entities import ( + BrowseCategory, + BrowseItem, + BrowseResult, + ContextMenuAction, Input, ListeningModeValue, PairedPlayer, PlayQueue, + PlayQueueTrack, Preset, Status, SubwooferModeValue, @@ -13,4 +18,20 @@ ) from .player import Player -__all__ = ["Input", "ListeningModeValue", "PairedPlayer", "PlayQueue", "Player", "Preset", "Status", "SubwooferModeValue", "SyncStatus", "Volume"] +__all__ = [ + "BrowseCategory", + "BrowseItem", + "BrowseResult", + "ContextMenuAction", + "Input", + "ListeningModeValue", + "PairedPlayer", + "PlayQueue", + "PlayQueueTrack", + "Player", + "Preset", + "Status", + "SubwooferModeValue", + "SyncStatus", + "Volume", +] diff --git a/src/pyblu/entities.py b/src/pyblu/entities.py index 6dcd8a5..4394527 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""" - modified: bool - """PlayQueue was modified since it was loaded""" + shuffle: bool | None + """Whether the play queue is shuffled, or *None* if the response does not include the shuffle state.""" + modified: bool | None + """Whether the play queue was modified since it was loaded, or *None* if the response does not include this state.""" length: int - """Number of tracks in the play queue""" + """Total number of tracks in the play queue, including tracks not returned by a paginated request.""" + name: str | None = None + """Name of the current play queue.""" + repeat: int | None = None + """Repeat mode: 0 repeats the queue, 1 repeats the current track, and 2 disables repeat.""" + tracks: list[PlayQueueTrack] = field(default_factory=list) + """Tracks returned by the request. Empty for a status-only request or an empty queue.""" @dataclass @@ -165,6 +197,83 @@ class Input: """URL to play the input. Can be passed to *play_url*""" +@dataclass +class ContextMenuAction: + type: str + """Service-specific action type. Treat unknown values as a display hint only.""" + text: str | None + """Human-readable action label.""" + action_url: str + """Opaque relative action URI. Pass it unchanged to *Player.execute_action*.""" + + +@dataclass +class BrowseItem: + type: str + """Item type. Common values are "link" (descend with *browse_key*), "audio" (playable), "album", "track", + "artist", "playlist", "folder", "section", "text". The list is open — treat unknown values as a display hint only.""" + text: str | None + """Primary display label.""" + text2: str | None + """Secondary display label from the BluOS ``text2`` attribute. + The meaning is service-specific: it may be an artist, station slogan, current show, date, or another subtitle.""" + image: str | None + """Icon or artwork URL.""" + play_action_url: str | None + """Opaque relative URI from the item's *playURL* attribute. Pass it unchanged to *Player.execute_action*. + *None* if the item does not provide a default play action. Do not pass this value to *Player.play_url*.""" + browse_key: str | None + """Opaque key. Pass to *Player.browse* to descend into this item. *None* if the item is a leaf.""" + input_type: str | None + """Input kind for items that represent a physical input (e.g. "bluetooth", "arc", "spdif"). Usually only set on the root menu.""" + context_menu_key: str | None + """Opaque key for this item's context menu. Pass it to *Player.context_menu*.""" + context_menu: list[ContextMenuAction] + """Inline context-menu actions. Usually empty because BluOS normally supplies *context_menu_key* instead.""" + autoplay_action_url: str | None = None + """Opaque relative URI from the item's *autoplayURL* attribute. Pass it unchanged to *Player.execute_action*. + *None* if the item does not provide an auto-fill play action. Do not pass this value to *Player.play_url*.""" + duration: int | None = None + """Duration in seconds for a track or collection.""" + is_favourite: bool | None = None + """Whether the item is a favourite.""" + tracks: int | None = None + """Number of tracks in a collection.""" + + +@dataclass +class BrowseCategory: + text: str | None + """Category heading.""" + next_key: str | None + """Opaque key for the next page of items in this category. Pass to *Player.browse*.""" + parent_key: str | None + """Opaque key for navigating up from this category. Pass to *Player.browse*.""" + items: list[BrowseItem] + """Items in this category.""" + + +@dataclass +class BrowseResult: + type: str + """Result list type. Common values are "menu", "items", "albums", "tracks", "playlists", "sections", "folders".""" + service_name: str | None + """Human-readable service name, suitable for UI.""" + service_icon: str | None + """URL of an icon for the service.""" + search_key: str | None + """Opaque key for searching the current service. Pass to *Player.browse* together with the **q** + parameter (the search term). *None* if search is not available here.""" + next_key: str | None + """Opaque key for the next page of results. Pass to *Player.browse*.""" + parent_key: str | None + """Opaque key for navigating up the hierarchy. Pass to *Player.browse*.""" + items: list[BrowseItem] + """Top-level items. Empty when the response is grouped into *categories*.""" + categories: list[BrowseCategory] + """Categories. Empty unless the response groups items under headings.""" + + @dataclass class ListeningModeValue: name: str diff --git a/src/pyblu/errors.py b/src/pyblu/errors.py index 059d15d..83396e5 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", "PlayerCommandError", "PlayerBrowseError"] P = ParamSpec("P") R = TypeVar("R") @@ -27,11 +26,29 @@ 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. + + 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 c283c78..128c701 100644 --- a/src/pyblu/parse.py +++ b/src/pyblu/parse.py @@ -3,17 +3,22 @@ from lxml import etree from pyblu.entities import ( + BrowseCategory, + BrowseItem, + BrowseResult, + ContextMenuAction, Input, ListeningModeValue, PairedPlayer, PlayQueue, + PlayQueueTrack, Preset, Status, SubwooferModeValue, SyncStatus, Volume, ) -from pyblu.errors import _wrap_in_unxpected_response_error +from pyblu.errors import PlayerBrowseError, PlayerCommandError, _wrap_in_unxpected_response_error @_wrap_in_unxpected_response_error @@ -149,6 +154,21 @@ 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) + + +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 + + +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: """ @@ -161,14 +181,83 @@ 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=_optional_bool_attribute_or_child(playlist_element, "modified"), + length=int(length), + 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, ) - 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) + 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" + assert entries_elements[0].text is not None, "Saved entry count not found" + return int(entries_elements[0].text) @_wrap_in_unxpected_response_error @@ -226,6 +315,118 @@ 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"], + text=x.attrib.get("text"), + action_url=x.attrib["actionURL"], + ) + + +def _browse_item(x: etree._Element) -> BrowseItem: + return BrowseItem( + type=x.attrib["type"], + text=x.attrib.get("text"), + text2=x.attrib.get("text2"), + image=x.attrib.get("image"), + 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"), + 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, + ) + + +def _browse_element(response: bytes) -> etree._Element: + 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: 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 = [ + 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_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, + categories=categories, + ) + + 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 8000a2c..0ebf5a1 100644 --- a/src/pyblu/player.py +++ b/src/pyblu/player.py @@ -1,22 +1,21 @@ from types import TracebackType +from urllib.parse import urljoin import aiohttp -from pyblu.entities import ( - Input, - PairedPlayer, - PlayQueue, - Preset, - Status, - SyncStatus, - Volume, -) +from pyblu.entities import BrowseResult, ContextMenuAction, Input, PairedPlayer, PlayQueue, Preset, Status, SyncStatus, Volume from pyblu.errors import PlayerUnreachableError from pyblu.parse import ( parse_add_follower, + parse_browse_result, + parse_command_response, + 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_status, @@ -75,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: @@ -191,9 +190,13 @@ 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. 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 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. @@ -207,6 +210,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. @@ -338,6 +359,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. @@ -368,6 +454,21 @@ 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 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. + + :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. @@ -422,3 +523,62 @@ 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, + 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. + + **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 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. 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. + + :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 + 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 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) diff --git a/tests/test_parse.py b/tests/test_parse.py index 3ca21ed..c81920a 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -1,5 +1,20 @@ -from pyblu import PairedPlayer -from pyblu.parse import parse_add_follower, parse_presets, parse_status, parse_sync_status +import pytest + +from pyblu import ContextMenuAction, PairedPlayer, PlayQueueTrack +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, + parse_play_queue, + parse_presets, + parse_saved_play_queue, + parse_status, + parse_sync_status, +) def test_parse_add_follower_no_follower(): @@ -246,6 +261,102 @@ 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 is True + assert play_queue.length == 13 + 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 + 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") + + +@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 = """ @@ -305,3 +416,207 @@ 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_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 + 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_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" + 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_action_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_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_with_context_menus(): + 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_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_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_action_url == "/Play?url=Service%3Astream-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 = """ + +""" + + 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_preserves_non_play_action_url(): + data = """ + +""" + + result = parse_browse_result(data) + + 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 = """ + + +""" + + 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 + 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 8e853b0..dba4ef6 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 @@ -7,9 +9,9 @@ from mocket.mocks.mockhttp import Entry from mocket.plugins.aiohttp_connector import MocketTCPConnector -from pyblu import PairedPlayer, Player, SubwooferModeValue +from pyblu import ContextMenuAction, PairedPlayer, Player, SubwooferModeValue from pyblu.entities import Input, ListeningModeValue, Preset -from pyblu.errors import PlayerUnreachableError +from pyblu.errors import PlayerBrowseError, PlayerCommandError, PlayerUnreachableError @async_mocketize(strict_mode=True) @@ -551,9 +553,125 @@ 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) +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.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 == [] + + +@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.modified is False + assert play_queue.shuffle is None + 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_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) @@ -827,3 +945,175 @@ 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_action_url == "/Play?url=Capture%3Abluez%3Abluetooth" + 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_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( + 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( + 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"] + + +@pytest.mark.parametrize( + ("action_url", "request_url"), + [ + ("/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, 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: + await client.execute_action(action_url) + + assert len(Mocket.request_list()) == 1 + + +@async_mocketize(strict_mode=True) +async def test_execute_action_command_error(): + action_url = "/Add?service=ServiceA&albumid=1&playnow=1" + Entry.single_register( + Entry.GET, + 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.execute_action(action_url) + + assert len(Mocket.request_list()) == 1 + + +@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", + ) + ]