diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 6e1f177..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,12 +0,0 @@ -**pyheos version with the issue:** - -**Python version and environment:** - -**Description of problem:** - -**Traceback (if applicable):** -``` - -``` - -**Additional information:** diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..da53e50 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Report a bug in pyheos +about: Create a report to help us improve the library +title: "" +labels: bug, triage +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Environment (please complete the following information):** + +- pyheos version: [e.g. 1.0.0] +- python version: [e.g. 3.12] +- HEOS device model: [e.g. HEOS Drive] +- HEOS device firmware version: [e.g. 3.34.620] + +**Logs** + +``` + +``` + +**Traceback (if applicable)** + +``` + +``` + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e69c158..6754d8f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,16 +26,18 @@ jobs: python -m pip install --upgrade pip pip install uv uv pip install -r requirements.txt -r test-requirements.txt --upgrade --system + - name: Set up check hooks + run: pre-commit install-hooks - name: Spelling (codespell) - run: codespell + run: pre-commit run --all-files --hook-stage manual codespell --show-diff-on-failure - name: Lint (Ruff) - run: ruff check --select I + run: pre-commit run --all-files --hook-stage manual ruff --show-diff-on-failure - name: Format (Ruff) - run: ruff format --check + run: pre-commit run --all-files --hook-stage manual ruff-format --show-diff-on-failure - name: Format (Pylint) - run: pylint pyheos tests + run: pre-commit run --all-files --hook-stage manual pylint --show-diff-on-failure - name: Typing my[py] - run: mypy pyheos tests + run: pre-commit run --all-files --hook-stage manual mypy --show-diff-on-failure tests: name: "Run tests on ${{ matrix.python-version }}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1ecb2d0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +repos: + - repo: https://github.com/codespell-project/codespell + rev: v2.4.0 + hooks: + - id: codespell + args: + - --skip="./.*,*.csv,*.json,*.ambr" + exclude_types: [csv, json, html] + exclude: ^tests/fixtures/|^tests/snapshots/ + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.3 + hooks: + - id: ruff + args: + - --extend-select=I + - --fix + - id: ruff-format + - repo: local + hooks: + - id: pylint + name: pylint + entry: pylint + language: system + types: [python] + require_serial: true + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + args: [--strict] + additional_dependencies: + - pydantic==2.10.5 + - pylint==3.3.3 + - pylint-per-file-ignores==1.4.0 + - pytest==8.3.4 + - pytest-asyncio==0.25.2 + - pytest-cov==6.0.0 + - pytest-timeout==2.3.1 + - syrupy==4.8.1 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 9807766..e933f92 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -42,5 +42,8 @@ ".ruff_cache": true, ".pytest_cache": true, ".mypy_cache": true + }, + "yaml.schemas": { + "https://json.schemastore.org/github-issue-config.json": "file:///workspaces/pyheos/.github/ISSUE_TEMPLATE/config.yml" } } \ No newline at end of file diff --git a/README.md b/README.md index 2547c4f..05cb36c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ This class encapsulates the options and configuration for connecting to a HEOS s #### `pyheos.HeosOptions(host, *, timeout, heart_beat, heart_beat_interval, dispatcher, auto_reconnect, auto_reconnect_delay, auto_reconnect_max_attempts, credentials)` - `host: str`: A host name or IP address of a HEOS-capable device. This parameter is required. -- `timeout: float`: The timeout in seconds for opening a connectoin and issuing commands to the device. Default is `pyheos.const.DEFAULT_TIMEOUT = 10.0`. This parameter is required. +- `timeout: float`: The timeout in seconds for opening a connection and issuing commands to the device. Default is `pyheos.const.DEFAULT_TIMEOUT = 10.0`. This parameter is required. - `heart_beat: bool`: Set to `True` to enable heart beat messages, `False` to disable. Used in conjunction with `heart_beat_delay`. The default is `True`. - `heart_beat_interval: float`: The interval in seconds between heart beat messages. Used in conjunction with `heart_beat`. Default is `pyheos.const.DEFAULT_HEART_BEAT = 10.0` - `events: bool`: Set to `True` to enable event updates, `False` to disable. The default is `True`. diff --git a/pyheos/abc.py b/pyheos/abc.py new file mode 100644 index 0000000..23c3f53 --- /dev/null +++ b/pyheos/abc.py @@ -0,0 +1,15 @@ +"""Define abstract base classes for HEOS.""" + +from abc import ABC +from typing import Any + + +class RemoveHeosFieldABC(ABC): + """Define an abstract base class that removes the 'heos' from dataclass's fields list to prevent serialization.""" + + def __post_init__(self, *args: Any, **kwargs: Any) -> None: + """Post initialize the player.""" + # Prevent the heos instance from being serialized + fields = self.__dataclass_fields__.copy() # type: ignore[has-type] # pylint: disable=access-member-before-definition + del fields["heos"] + self.__dataclass_fields__ = fields diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index 9442f0c..3c49e96 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -166,22 +166,31 @@ def optional_int(value: str | None) -> int | None: return None -def parse_enum( - key: str, data: dict[str, Any], enum_type: type[TEnum], default: TEnum -) -> TEnum: - """Parse an enum value from the provided data. This is a safe operation that will return the default value if the key is missing or the value is not recognized.""" +def parse_optional_enum( + key: str, data: dict[str, Any], enum_type: type[TEnum] +) -> TEnum | None: + """Parse an enum value from the provided data. This is a safe operation that will return None if the key is missing or the value is not recognized.""" value = data.get(key) if value is None: - return default + return None try: return enum_type(value) except ValueError: _LOGGER.warning( - "Unrecognized '%s' value: '%s', using default value: '%s'. Full data: %s. %s", + "Unrecognized '%s' value: '%s'. Full data: %s. %s", key, value, - default, data, REPORT_ISSUE_TEXT, ) + return None + + +def parse_enum( + key: str, data: dict[str, Any], enum_type: type[TEnum], default: TEnum +) -> TEnum: + """Parse an enum value from the provided data. This is a safe operation that will return the default value if the key is missing or the value is not recognized.""" + value = parse_optional_enum(key, data, enum_type) + if value is None: return default + return value diff --git a/pyheos/command/browse.py b/pyheos/command/browse.py index 92538ec..5751a6e 100644 --- a/pyheos/command/browse.py +++ b/pyheos/command/browse.py @@ -80,7 +80,7 @@ async def get_music_sources( HeosCommand(c.COMMAND_BROWSE_GET_SOURCES, params) ) self._music_sources.clear() - for data in cast(Sequence[dict], message.payload): + for data in cast(Sequence[dict[str, Any]], message.payload): source = MediaMusicSource.from_data(data, cast("Heos", self)) self._music_sources[source.source_id] = source self._music_sources_loaded = True diff --git a/pyheos/command/group.py b/pyheos/command/group.py index bfb4b71..1d2c12a 100644 --- a/pyheos/command/group.py +++ b/pyheos/command/group.py @@ -38,9 +38,9 @@ async def get_groups(self, *, refresh: bool = False) -> dict[int, HeosGroup]: References: 4.3.1 Get Groups""" if not self._groups_loaded or refresh: - groups = {} + groups: dict[int, HeosGroup] = {} result = await self._connection.command(HeosCommand(c.COMMAND_GET_GROUPS)) - payload = cast(Sequence[dict], result.payload) + payload = cast(Sequence[dict[str, Any]], result.payload) for data in payload: group = HeosGroup._from_data(data, cast("Heos", self)) groups[group.group_id] = group diff --git a/pyheos/command/player.py b/pyheos/command/player.py index badc74e..7e149cc 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -13,14 +13,8 @@ from pyheos.command.connection import ConnectionMixin from pyheos.media import QueueItem from pyheos.message import HeosCommand -from pyheos.player import ( - HeosNowPlayingMedia, - HeosPlayer, - PlayerUpdateResult, - PlayMode, - PlayState, -) -from pyheos.types import RepeatType +from pyheos.player import HeosNowPlayingMedia, HeosPlayer, PlayerUpdateResult, PlayMode +from pyheos.types import PlayState, RepeatType if TYPE_CHECKING: from pyheos.heos import Heos @@ -46,7 +40,6 @@ async def get_players(self, *, refresh: bool = False) -> dict[int, HeosPlayer]: References: 4.2.1 Get Players""" - # get players and pull initial state if not self._players_loaded or refresh: await self.load_players() return self._players @@ -103,7 +96,7 @@ async def load_players(self) -> PlayerUpdateResult: players: dict[int, HeosPlayer] = {} response = await self._connection.command(HeosCommand(c.COMMAND_GET_PLAYERS)) - payload = cast(Sequence[dict], response.payload) + payload = cast(Sequence[dict[str, str]], response.payload) existing = list(self._players.values()) for player_data in payload: player_id = int(player_data[c.ATTR_PLAYER_ID]) @@ -464,7 +457,7 @@ async def player_get_quick_selects(self, player_id: int) -> dict[int, str]: ) return { int(data[c.ATTR_ID]): data[c.ATTR_NAME] - for data in cast(list[dict], result.payload) + for data in cast(list[dict[str, Any]], result.payload) } async def player_check_update(self, player_id: int) -> bool: diff --git a/pyheos/command/system.py b/pyheos/command/system.py index dbf4123..f1fcc4c 100644 --- a/pyheos/command/system.py +++ b/pyheos/command/system.py @@ -132,7 +132,7 @@ async def get_system_info(self) -> HeosSystem: References: 4.2.1 Get Players""" response = await self._connection.command(HeosCommand(c.COMMAND_GET_PLAYERS)) - payload = cast(Sequence[dict], response.payload) + payload = cast(Sequence[dict[str, Any]], response.payload) hosts = list([HeosHost._from_data(item) for item in payload]) host = next(host for host in hosts if host.ip_address == self._options.host) return HeosSystem(self._signed_in_username, host, hosts) diff --git a/pyheos/connection.py b/pyheos/connection.py index 032b236..546addc 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -3,8 +3,9 @@ import asyncio import logging from collections.abc import Awaitable, Callable, Coroutine +from contextlib import suppress from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Any, Final from pyheos.command import COMMAND_HEART_BEAT, COMMAND_REBOOT from pyheos.message import HeosCommand, HeosMessage @@ -34,15 +35,15 @@ def __init__(self, host: str, *, timeout: float) -> None: self._state: ConnectionState = ConnectionState.DISCONNECTED self._writer: asyncio.StreamWriter | None = None self._pending_command_event = ResponseEvent() - self._running_tasks: set[asyncio.Task] = set() + self._running_tasks: set[asyncio.Task[None]] = set() self._last_activity: datetime = datetime.now() self._command_lock = asyncio.Lock() - self._on_event_callbacks: list[Callable[[HeosMessage], Awaitable]] = [] - self._on_connected_callbacks: list[Callable[[], Awaitable]] = [] - self._on_disconnected_callbacks: list[Callable[[bool], Awaitable]] = [] + self._on_event_callbacks: list[Callable[[HeosMessage], Awaitable[None]]] = [] + self._on_connected_callbacks: list[Callable[[], Awaitable[None]]] = [] + self._on_disconnected_callbacks: list[Callable[[bool], Awaitable[None]]] = [] self._on_command_error_callbacks: list[ - Callable[[CommandFailedError], Awaitable] + Callable[[CommandFailedError], Awaitable[None]] ] = [] @property @@ -50,7 +51,7 @@ def state(self) -> ConnectionState: """Get the current state of the connection.""" return self._state - def add_on_event(self, callback: Callable[[HeosMessage], Awaitable]) -> None: + def add_on_event(self, callback: Callable[[HeosMessage], Awaitable[None]]) -> None: """Add a callback to be invoked when an event is received.""" self._on_event_callbacks.append(callback) @@ -59,7 +60,7 @@ async def _on_event(self, message: HeosMessage) -> None: for callback in self._on_event_callbacks: await callback(message) - def add_on_connected(self, callback: Callable[[], Awaitable]) -> None: + def add_on_connected(self, callback: Callable[[], Awaitable[None]]) -> None: """Add a callback to be invoked when connected.""" self._on_connected_callbacks.append(callback) @@ -68,7 +69,7 @@ async def _on_connected(self) -> None: for callback in self._on_connected_callbacks: await callback() - def add_on_disconnected(self, callback: Callable[[bool], Awaitable]) -> None: + def add_on_disconnected(self, callback: Callable[[bool], Awaitable[None]]) -> None: """Add a callback to be invoked when connected.""" self._on_disconnected_callbacks.append(callback) @@ -78,7 +79,7 @@ async def _on_disconnected(self, due_to_error: bool = False) -> None: await callback(due_to_error) def add_on_command_error( - self, callback: Callable[[CommandFailedError], Awaitable] + self, callback: Callable[[CommandFailedError], Awaitable[None]] ) -> None: """Add a callback to be invoked when a command error occurs.""" self._on_command_error_callbacks.append(callback) @@ -88,9 +89,11 @@ async def _on_command_error(self, error: CommandFailedError) -> None: for callback in self._on_command_error_callbacks: await callback(error) - def _register_task(self, future: Coroutine) -> None: + def _register_task( + self, future: Coroutine[Any, Any, None], name: str | None = None + ) -> None: """Register a task that is running in the background, so it can be canceled and reset later.""" - task = asyncio.ensure_future(future) + task: asyncio.Task[None] = asyncio.create_task(future, name=name) self._running_tasks.add(task) task.add_done_callback(self._running_tasks.discard) @@ -100,19 +103,14 @@ async def _reset(self) -> None: while self._running_tasks: task = self._running_tasks.pop() if task.cancel(): - try: + with suppress(asyncio.CancelledError): await task - except asyncio.CancelledError: - pass # Close the writer if self._writer: self._writer.close() - try: + with suppress(OSError, asyncio.CancelledError): await self._writer.wait_closed() - except (ConnectionError, OSError, asyncio.CancelledError): - pass - finally: - self._writer = None + self._writer = None # Reset other parameters self._pending_command_event.clear() self._last_activity = datetime.now() @@ -134,12 +132,7 @@ async def _read_handler(self, reader: asyncio.StreamReader) -> None: while True: try: binary_result = await reader.readuntil(SEPARATOR_BYTES) - except ( - ConnectionError, - asyncio.IncompleteReadError, - RuntimeError, - OSError, - ) as error: + except (asyncio.IncompleteReadError, RuntimeError, OSError) as error: await self._disconnect_from_error(error) return else: @@ -155,7 +148,7 @@ async def _handle_message(self, message: HeosMessage) -> None: return if message.is_event: _LOGGER.debug("Event received: '%s': '%s'", message.command, message) - self._register_task(self._on_event(message)) + self._register_task(self._on_event(message), "Event Handler") return # Set the message on the pending command. @@ -179,7 +172,9 @@ async def _command_impl() -> HeosMessage: await self._writer.drain() except (ConnectionError, OSError, AttributeError) as error: # Occurs when the connection is broken. Run in the background to ensure connection is reset. - self._register_task(self._disconnect_from_error(error)) + self._register_task( + self._disconnect_from_error(error), "Disconnect From Error" + ) _LOGGER.debug( "Command failed '%s': %s: %s", command, type(error).__name__, error ) @@ -251,7 +246,7 @@ async def connect(self) -> None: ) from err # Start read handler - self._register_task(self._read_handler(reader)) + self._register_task(self._read_handler(reader), "Read Handler") self._last_activity = datetime.now() self._state = ConnectionState.CONNECTED _LOGGER.debug("Connected to %s", self._host) @@ -301,13 +296,10 @@ async def _heart_beat_handler(self) -> None: while self._state == ConnectionState.CONNECTED: last_acitvity_delta = datetime.now() - self._last_activity if last_acitvity_delta >= self._heart_beat_interval_delta: - try: + with suppress(CommandError): await self.command(HeosCommand(COMMAND_HEART_BEAT)) - except (CommandError, asyncio.TimeoutError): - # Exit the task, as the connection will be reset/closed. - return # Sleep until next interval - await asyncio.sleep(float(self._heart_beat_interval / 2)) + await asyncio.sleep(self._heart_beat_interval) async def _attempt_reconnect(self) -> None: """Attempt to reconnect after disconnection from error.""" @@ -316,32 +308,28 @@ async def _attempt_reconnect(self) -> None: unlimited_attempts = self._reconnect_max_attempts == 0 delay = min(self._reconnect_delay, MAX_RECONNECT_DELAY) while (attempts < self._reconnect_max_attempts) or unlimited_attempts: + _LOGGER.debug("Waiting %s seconds before attempting to reconnect", delay) + await asyncio.sleep(delay) + _LOGGER.debug("Attempting reconnect #%s to %s", (attempts + 1), self._host) try: - _LOGGER.debug( - "Waiting %s seconds before attempting to reconnect", delay - ) - await asyncio.sleep(delay) - _LOGGER.debug( - "Attempting reconnect #%s to %s", (attempts + 1), self._host - ) await self.connect() except HeosError: attempts += 1 delay = min(delay * 2, MAX_RECONNECT_DELAY) else: - return # This never actually hits as the task is cancelled when the connection is established, but it's here for completeness. + return async def _on_connected(self) -> None: """Handle when the connection is established.""" # Start heart beat when enabled if self._heart_beat: - self._register_task(self._heart_beat_handler()) + self._register_task(self._heart_beat_handler(), "Heart Beat") await super()._on_connected() async def _on_disconnected(self, due_to_error: bool = False) -> None: """Handle when the connection is lost. Invoked after the connection has been reset.""" if due_to_error and self._reconnect: - self._register_task(self._attempt_reconnect()) + self._register_task(self._attempt_reconnect(), "Reconnect") await super()._on_disconnected(due_to_error) diff --git a/pyheos/dispatch.py b/pyheos/dispatch.py index 326a645..a5befab 100644 --- a/pyheos/dispatch.py +++ b/pyheos/dispatch.py @@ -5,24 +5,20 @@ import logging from collections import defaultdict from collections.abc import Callable, Sequence -from typing import Any, Final, TypeVar +from typing import Any, Final _LOGGER: Final = logging.getLogger(__name__) TargetType = Callable[..., Any] DisconnectType = Callable[[], None] ConnectType = Callable[[str, TargetType], DisconnectType] -SendType = Callable[..., Sequence[asyncio.Future]] - -TEvent = TypeVar("TEvent", bound=str) -TPlayerId = TypeVar("TPlayerId", bound=int) -TGroupId = TypeVar("TGroupId", bound=int) +SendType = Callable[..., Sequence[asyncio.Future[Any]]] CallbackType = Callable[[], Any] -EventCallbackType = Callable[[TEvent], Any] -ControllerEventCallbackType = Callable[[TEvent, Any], Any] -PlayerEventCallbackType = Callable[[TPlayerId, TEvent], Any] -GroupEventCallbackType = Callable[[TGroupId, TEvent], Any] +EventCallbackType = Callable[[str], Any] +ControllerEventCallbackType = Callable[[str, Any], Any] +PlayerEventCallbackType = Callable[[int, str], Any] +GroupEventCallbackType = Callable[[int, str], Any] def _is_coroutine_function(func: TargetType) -> bool: @@ -81,12 +77,12 @@ def __init__( ) -> None: """Create a new instance of the dispatch component.""" self._signal_prefix = signal_prefix - self._signals: dict[str, list] = defaultdict(list) + self._signals: dict[str, list[TargetType]] = defaultdict(list) self._loop = loop or asyncio.get_running_loop() self._connect = connect or self._default_connect self._send = send or self._default_send - self._disconnects: list[Callable] = [] - self._running_tasks: set[asyncio.Future] = set() + self._disconnects: list[DisconnectType] = [] + self._running_tasks: set[asyncio.Future[Any]] = set() def connect(self, signal: str, target: TargetType) -> DisconnectType: """Connect function to signal. Must be ran in the event loop.""" @@ -94,7 +90,7 @@ def connect(self, signal: str, target: TargetType) -> DisconnectType: self._disconnects.append(disconnect) return disconnect - def send(self, signal: str, *args: Any) -> Sequence[asyncio.Future]: + def send(self, signal: str, *args: Any) -> Sequence[asyncio.Future[Any]]: """Fire a signal. Must be ran in the event loop.""" return self._send(self._signal_prefix + signal, *args) @@ -137,7 +133,7 @@ def remove_dispatcher() -> None: return remove_dispatcher - def _log_target_exception(self, future: asyncio.Future) -> None: + def _log_target_exception(self, future: asyncio.Future[Any]) -> None: """Log the exception from the target, if raised.""" if not future.cancelled() and future.exception(): _LOGGER.exception( @@ -146,7 +142,7 @@ def _log_target_exception(self, future: asyncio.Future) -> None: exc_info=future.exception(), ) - def _default_send(self, signal: str, *args: Any) -> Sequence[asyncio.Future]: + def _default_send(self, signal: str, *args: Any) -> Sequence[asyncio.Future[Any]]: """Fire a signal. Must be ran in the event loop.""" targets = self._signals[signal] futures = [] @@ -158,7 +154,7 @@ def _default_send(self, signal: str, *args: Any) -> Sequence[asyncio.Future]: futures.append(task) return futures - def _call_target(self, target: Callable, *args: Any) -> asyncio.Future: + def _call_target(self, target: TargetType, *args: Any) -> asyncio.Future[Any]: if _is_coroutine_function(target): return self._loop.create_task(target(*args)) return self._loop.run_in_executor(None, target, *args) diff --git a/pyheos/group.py b/pyheos/group.py index cdd3e04..6d774ec 100644 --- a/pyheos/group.py +++ b/pyheos/group.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Optional +from pyheos.abc import RemoveHeosFieldABC from pyheos.const import DEFAULT_STEP, EVENT_GROUP_VOLUME_CHANGED from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper from pyheos.message import HeosMessage @@ -17,7 +18,7 @@ @dataclass -class HeosGroup: +class HeosGroup(RemoveHeosFieldABC): """A group of players.""" name: str diff --git a/pyheos/heos.py b/pyheos/heos.py index 8ba60f0..3858f30 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -43,7 +43,7 @@ async def create_and_connect(cls, host: str, **kwargs: Any) -> "Heos": Args: host: A host name or IP address of a HEOS-capable device. - timeout: The timeout in seconds for opening a connectoin and issuing commands to the device. + timeout: The timeout in seconds for opening a connection and issuing commands to the device. events: Set to True to enable event updates, False to disable. The default is True. all_progress_events: Set to True to receive media progress events, False to only receive media changed events. The default is True. dispatcher: The dispatcher instance to use for event callbacks. If not provided, an internally created instance will be used. @@ -234,6 +234,8 @@ async def _on_event_heos(self, event: HeosMessage) -> None: else: self._signed_in_username = None elif event.command == const.EVENT_GROUPS_CHANGED and self._groups_loaded: + if self._players_loaded: + await self.get_players(refresh=True) await self.get_groups(refresh=True) await self._dispatcher.wait_send( diff --git a/pyheos/media.py b/pyheos/media.py index f243296..ca7f31a 100644 --- a/pyheos/media.py +++ b/pyheos/media.py @@ -3,8 +3,10 @@ from collections.abc import Sequence from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Optional, cast +from urllib.parse import unquote from pyheos import command as c +from pyheos.abc import RemoveHeosFieldABC from pyheos.message import HeosMessage from pyheos.types import AddCriteriaType, MediaType @@ -39,7 +41,7 @@ def from_data(cls, data: dict[str, str]) -> "QueueItem": @dataclass(init=False) -class Media: +class Media(RemoveHeosFieldABC): """ Define a base media item. @@ -90,18 +92,6 @@ def _update_from_data(self, data: dict[str, Any]) -> None: self.available = data[c.ATTR_AVAILABLE] == c.VALUE_TRUE self.service_username = data.get(c.ATTR_SERVICE_USER_NAME) - def clone(self) -> "MediaMusicSource": - """Create a new instance from the current instance.""" - return MediaMusicSource( - source_id=self.source_id, - name=self.name, - type=self.type, - image_url=self.image_url, - available=self.available, - service_username=self.service_username, - heos=self.heos, - ) - async def refresh(self) -> None: """Refresh the instance with the latest data.""" assert self.heos, "Heos instance not set" @@ -151,7 +141,7 @@ def from_data( source_id=new_source_id, container_id=data.get(c.ATTR_CONTAINER_ID, container_id), browsable=new_browseable, - name=data[c.ATTR_NAME], + name=unquote(data[c.ATTR_NAME]), type=MediaType(data[c.ATTR_TYPE]), image_url=data[c.ATTR_IMAGE_URL], playable=data.get(c.ATTR_PLAYABLE) == c.VALUE_YES, @@ -162,22 +152,6 @@ def from_data( heos=heos, ) - def clone(self) -> "MediaItem": - return MediaItem( - source_id=self.source_id, - name=self.name, - type=self.type, - image_url=self.image_url, - playable=self.playable, - browsable=self.browsable, - container_id=self.container_id, - media_id=self.media_id, - artist=self.artist, - album=self.album, - album_id=self.album_id, - heos=self.heos, - ) - async def browse( self, range_start: int | None = None, @@ -246,7 +220,7 @@ def __from_data(context: str, data: dict[str, str]) -> "ServiceOption": @dataclass -class BrowseResult: +class BrowseResult(RemoveHeosFieldABC): """Define the result of a browse operation.""" count: int @@ -273,7 +247,7 @@ def _from_message( items=list( [ MediaItem.from_data(item, source_id, container_id, heos) - for item in cast(Sequence[dict], message.payload) + for item in cast(Sequence[dict[str, Any]], message.payload) ] ), options=ServiceOption._from_options(message.options), diff --git a/pyheos/message.py b/pyheos/message.py index 6163229..3622b96 100644 --- a/pyheos/message.py +++ b/pyheos/message.py @@ -22,7 +22,7 @@ class HeosCommand: parameters: dict[str, Any] = field(default_factory=dict) def __repr__(self) -> str: - """Get a string representaton of the message.""" + """Get a string representation of the message.""" return self.uri_masked @cached_property @@ -79,7 +79,7 @@ class HeosMessage: ) def __repr__(self) -> str: - """Get a string representaton of the message.""" + """Get a string representation of the message.""" return self._raw_message or f"{self.command} {self.message}" @staticmethod diff --git a/pyheos/options.py b/pyheos/options.py index 26ec39c..f12d9af 100644 --- a/pyheos/options.py +++ b/pyheos/options.py @@ -14,7 +14,7 @@ class HeosOptions: Args: host: A host name or IP address of a HEOS-capable device. - timeout: The timeout in seconds for opening a connectoin and issuing commands to the device. + timeout: The timeout in seconds for opening a connection and issuing commands to the device. events: Set to True to enable event updates, False to disable. The default is True. heart_beat: Set to True to enable heart beat messages, False to disable. Used in conjunction with heart_beat_delay. The default is True. heart_beat_interval: The interval in seconds between heart beat messages. Used in conjunction with heart_beat. diff --git a/pyheos/player.py b/pyheos/player.py index 2b5c5f0..46d192e 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -6,7 +6,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Final, Optional, cast -from pyheos.command import optional_int, parse_enum +from pyheos.abc import RemoveHeosFieldABC +from pyheos.command import optional_int, parse_enum, parse_optional_enum from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper from pyheos.media import MediaItem, QueueItem, ServiceOption from pyheos.message import HeosMessage @@ -100,7 +101,7 @@ class PlayerUpdateResult: class HeosNowPlayingMedia: """Define now playing media information.""" - type: str | None = None + type: MediaType | None = None song: str | None = None station: str | None = None album: str | None = None @@ -127,7 +128,7 @@ def __post_init__(self, *args: Any, **kwargs: Any) -> None: def _update_from_message(self, message: HeosMessage) -> None: """Update the current instance from another instance.""" data = cast(dict[str, Any], message.payload) - self.type = data.get(c.ATTR_TYPE) + self.type = parse_optional_enum(c.ATTR_TYPE, data, MediaType) self.song = data.get(c.ATTR_SONG) self.station = data.get(c.ATTR_STATION) self.album = data.get(c.ATTR_ALBUM) @@ -184,7 +185,7 @@ def _from_data(data: HeosMessage) -> "PlayMode": @dataclass -class HeosPlayer: +class HeosPlayer(RemoveHeosFieldABC): """Define a HEOS player.""" name: str = field(repr=True, hash=False, compare=False) diff --git a/pyheos/py.typed b/pyheos/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyheos/search.py b/pyheos/search.py index 7bac43d..d58053c 100644 --- a/pyheos/search.py +++ b/pyheos/search.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Final, Optional, cast from pyheos import command as c +from pyheos.abc import RemoveHeosFieldABC from pyheos.media import MediaItem from pyheos.message import HeosMessage @@ -38,7 +39,7 @@ def _from_data(data: dict[str, str]) -> "SearchCriteria": @dataclass -class SearchResult: +class SearchResult(RemoveHeosFieldABC): """Define the search result.""" source_id: int @@ -71,7 +72,7 @@ def _from_message(message: HeosMessage, heos: "Heos") -> "SearchResult": @dataclass -class MultiSearchResult: +class MultiSearchResult(RemoveHeosFieldABC): """Define the results of a multi-search.""" source_ids: Sequence[int] @@ -91,14 +92,14 @@ def _from_message(message: HeosMessage, heos: "Heos") -> "MultiSearchResult": """Create a new instance from a message.""" source_ids = message.get_message_value(c.ATTR_SOURCE_ID).split(",") criteria_ids = message.get_message_value(c.ATTR_SEARCH_CRITERIA_ID).split(",") - statisics = SearchStatistic._from_string( + statistics = SearchStatistic._from_string( message.get_message_value(c.ATTR_STATS) ) items: list[MediaItem] = [] # In order to determine the source_id of the result, we match up the index with how many items were returned for a given source payload = cast(list[dict[str, str]], message.payload) index = 0 - for stat in statisics: + for stat in statistics: assert stat.returned is not None for _ in range(stat.returned): items.append( @@ -114,7 +115,7 @@ def _from_message(message: HeosMessage, heos: "Heos") -> "MultiSearchResult": returned=message.get_message_value_int(c.ATTR_RETURNED), count=message.get_message_value_int(c.ATTR_COUNT), items=items, - statistics=statisics, + statistics=statistics, errors=SearchStatistic._from_string( message.get_message_value(c.ATTR_ERROR_NUMBER) ), diff --git a/pyheos/system.py b/pyheos/system.py index 6db5d82..83ac82e 100644 --- a/pyheos/system.py +++ b/pyheos/system.py @@ -1,7 +1,7 @@ """Define the System module.""" -from dataclasses import dataclass -from functools import cached_property +from dataclasses import dataclass, field +from typing import Any from pyheos import command as c from pyheos.types import NetworkType @@ -22,7 +22,7 @@ class HeosHost: network: NetworkType @staticmethod - def _from_data(data: dict[str, str]) -> "HeosHost": + def _from_data(data: dict[str, Any]) -> "HeosHost": """Create a HeosHost object from a dictionary. Args: @@ -51,18 +51,14 @@ class HeosSystem: signed_in_username: str | None host: HeosHost hosts: list[HeosHost] - - @property - def is_signed_in(self) -> bool: - """Return whether the system is signed in.""" - return self.signed_in_username is not None - - @cached_property - def preferred_hosts(self) -> list[HeosHost]: - """Return the preferred hosts.""" - return list([host for host in self.hosts if host.network == NetworkType.WIRED]) - - @cached_property - def connected_to_preferred_host(self) -> bool: - """Return whether the system is connected to a host.""" - return self.host in self.preferred_hosts + is_signed_in: bool = field(init=False) + preferred_hosts: list[HeosHost] = field(init=False) + connected_to_preferred_host: bool = field(init=False) + + def __post_init__(self) -> None: + """Post initialize the system.""" + self.is_signed_in = self.signed_in_username is not None + self.preferred_hosts = list( + [host for host in self.hosts if host.network == NetworkType.WIRED] + ) + self.connected_to_preferred_host = self.host in self.preferred_hosts diff --git a/pyproject.toml b/pyproject.toml index 4e2690b..4c06151 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pyheos" -version = "1.0.0" +version = "1.0.1" description = "An async python library for controlling HEOS devices through the HEOS CLI Protocol" readme = "README.md" requires-python = ">=3.11" @@ -367,4 +367,4 @@ skip_empty = true sort = "Name" [tool.codespell] -skip = "./tests/fixtures/*" \ No newline at end of file +skip = "./tests/fixtures/*,./tests/snapshots/*" \ No newline at end of file diff --git a/script/lint b/script/lint index cb477d3..930bb5b 100755 --- a/script/lint +++ b/script/lint @@ -1,8 +1,4 @@ #!/usr/bin/env bash # Run linters -codespell -ruff check --extend-select I --fix -ruff format -pylint pyheos tests -mypy pyheos tests \ No newline at end of file +pre-commit run --all-files --hook-stage manual --show-diff-on-failure \ No newline at end of file diff --git a/test-requirements.txt b/test-requirements.txt index a893afe..e8b299b 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,11 +1,13 @@ -codespell==2.3.0 +codespell==2.4.0 coveralls==4.0.1 -mypy-dev==1.15.0a1 -pydantic==2.10.4 +mypy==1.14.1 +pre-commit==4.1.0 +pydantic==2.10.5 pylint==3.3.3 -pylint-per-file-ignores==1.3.2 +pylint-per-file-ignores==1.4.0 pytest==8.3.4 -pytest-asyncio==0.25.1 +pytest-asyncio==0.25.2 pytest-cov==6.0.0 pytest-timeout==2.3.1 -ruff==0.8.6 \ No newline at end of file +ruff==0.9.3 +syrupy==4.8.1 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index a98057b..3b73ce3 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -68,7 +68,7 @@ def get_value(self, args: dict[str, Any]) -> Any: return arg_value -def calls_commands(*commands: CallCommand) -> Callable: +def calls_commands(*commands: CallCommand) -> Callable[..., Any]: """ Decorator that registers commands prior to test execution. @@ -76,7 +76,7 @@ def calls_commands(*commands: CallCommand) -> Callable: commands: The commands to register. """ - def wrapper(func: Callable) -> Callable: + def wrapper(func: Callable[..., Any]) -> Callable[..., Any]: @functools.wraps(func) async def wrapped(*args: Any, **kwargs: Any) -> Any: # Build a list of commands that match the when conditions @@ -118,7 +118,7 @@ async def wrapped(*args: Any, **kwargs: Any) -> Any: ) # Register commands - assert_list: list[Callable] = [] + assert_list: list[Callable[..., None]] = [] for command in matched_commands: # Get the fixture command @@ -174,7 +174,7 @@ def calls_command( when: dict[str, Any] | None = None, replace: bool = False, add_command_under_process: bool = False, -) -> Callable: +) -> Callable[..., Any]: """ Decorator that registers a command prior to test execution. @@ -200,7 +200,7 @@ def calls_command( def calls_player_commands( player_ids: Sequence[int] = (1, 2), *additional: CallCommand -) -> Callable: +) -> Callable[..., Any]: """ Decorator that registers player commands and any optional additional commands. """ @@ -223,7 +223,7 @@ def calls_player_commands( return calls_commands(*commands) -def calls_group_commands(*additional: CallCommand) -> Callable: +def calls_group_commands(*additional: CallCommand) -> Callable[..., Any]: commands = [ CallCommand("group.get_groups"), CallCommand("group.get_volume", {c.ATTR_GROUP_ID: 1}), @@ -350,9 +350,9 @@ def assert_command_called( if matcher.is_match(target_command, target_args, increment=False): matcher.assert_called() return - assert ( - False - ), f"Command was not registered: {target_command} with args {target_args}." + assert False, ( + f"Command was not registered: {target_command} with args {target_args}." + ) async def _handle_connection( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter @@ -456,14 +456,14 @@ def is_match( self.match_count += 1 return True - async def get_response(self, query: dict) -> list[str]: + async def get_response(self, query: dict[str, str]) -> list[str]: """Get the response body.""" responses = [] for fixture in self.responses: responses.append(await self._get_response(fixture, query)) return responses - async def _get_response(self, response: str, query: dict) -> str: + async def _get_response(self, response: str, query: dict[str, str]) -> str: response = await get_fixture(response) keys = { c.ATTR_PLAYER_ID: "{player_id}", @@ -483,9 +483,9 @@ async def _get_response(self, response: str, query: dict) -> str: def assert_called(self) -> None: """Assert that the command was called.""" - assert ( - self.match_count - ), f"Command {self.command} was not called with arguments {self._args}." + assert self.match_count, ( + f"Command {self.command} was not called with arguments {self._args}." + ) class ConnectionLog: diff --git a/tests/conftest.py b/tests/conftest.py index 921f2e1..a0e01ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,21 +1,31 @@ """Test fixtures for pyheos.""" +import dataclasses from collections.abc import AsyncGenerator, Callable, Coroutine from typing import Any import pytest import pytest_asyncio +from syrupy.assertion import SnapshotAssertion from pyheos.group import HeosGroup -from pyheos.heos import Heos, HeosOptions +from pyheos.heos import Heos from pyheos.media import MediaItem, MediaMusicSource +from pyheos.options import HeosOptions from pyheos.player import HeosPlayer from pyheos.types import LineOutLevelType, NetworkType from tests.common import MediaItems, MediaMusicSources +from tests.syrupy import HeosSnapshotExtension from . import MockHeos, MockHeosDevice +@pytest.fixture +def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + """Return snapshot assertion fixture with the Heos extension.""" + return snapshot.use_extension(HeosSnapshotExtension) + + @pytest_asyncio.fixture(name="mock_device") async def mock_device_fixture() -> AsyncGenerator[MockHeosDevice]: """Fixture for mocking a HEOS device connection.""" @@ -43,7 +53,7 @@ async def heos_fixture(mock_device: MockHeosDevice) -> AsyncGenerator[Heos]: @pytest.fixture -def handler() -> Callable: +def handler() -> Callable[..., Any]: """Fixture handler to mock in the dispatcher.""" def target(*args: Any, **kwargs: Any) -> None: @@ -56,7 +66,7 @@ def target(*args: Any, **kwargs: Any) -> None: @pytest.fixture -def async_handler() -> Callable[..., Coroutine]: +def async_handler() -> Callable[..., Coroutine[Any, Any, None]]: """Fixture async handler to mock in the dispatcher.""" async def target(*args: Any, **kwargs: Any) -> None: @@ -70,65 +80,47 @@ async def target(*args: Any, **kwargs: Any) -> None: @pytest.fixture def media_music_source(heos: MockHeos) -> MediaMusicSource: - source = MediaMusicSources.FAVORITES.clone() - source.heos = heos - return source + return dataclasses.replace(MediaMusicSources.FAVORITES, heos=heos) @pytest.fixture def media_music_source_unavailable(heos: MockHeos) -> MediaMusicSource: - source = MediaMusicSources.PANDORA.clone() - source.heos = heos - return source + return dataclasses.replace(MediaMusicSources.PANDORA, heos=heos) @pytest.fixture def media_music_source_tidal(heos: MockHeos) -> MediaMusicSource: - source = MediaMusicSources.TIDAL.clone() - source.heos = heos - return source + return dataclasses.replace(MediaMusicSources.TIDAL, heos=heos) @pytest.fixture def media_item_album(heos: MockHeos) -> MediaItem: - source = MediaItems.ALBUM.clone() - source.heos = heos - return source + return dataclasses.replace(MediaItems.ALBUM, heos=heos) @pytest.fixture def media_item_song(heos: MockHeos) -> MediaItem: - source = MediaItems.SONG.clone() - source.heos = heos - return source + return dataclasses.replace(MediaItems.SONG, heos=heos) @pytest.fixture(name="media_item_input") def media_item_input_fixture(heos: MockHeos) -> MediaItem: - source = MediaItems.INPUT.clone() - source.heos = heos - return source + return dataclasses.replace(MediaItems.INPUT, heos=heos) @pytest.fixture def media_item_station(heos: MockHeos) -> MediaItem: - source = MediaItems.STATION.clone() - source.heos = heos - return source + return dataclasses.replace(MediaItems.STATION, heos=heos) @pytest.fixture def media_item_playlist(heos: MockHeos) -> MediaItem: - source = MediaItems.PLAYLIST.clone() - source.heos = heos - return source + return dataclasses.replace(MediaItems.PLAYLIST, heos=heos) @pytest.fixture def media_item_device(heos: MockHeos) -> MediaItem: - source = MediaItems.DEVICE.clone() - source.heos = heos - return source + return dataclasses.replace(MediaItems.DEVICE, heos=heos) @pytest_asyncio.fixture(name="player") diff --git a/tests/fixtures/player.get_players.json b/tests/fixtures/player.get_players.json index 9d85716..47f5d9b 100644 --- a/tests/fixtures/player.get_players.json +++ b/tests/fixtures/player.get_players.json @@ -7,6 +7,7 @@ "payload": [{ "name": "Back Patio", "pid": 1, + "gid": 2, "model": "HEOS Drive", "version": "1.493.180", "ip": "127.0.0.1", diff --git a/tests/fixtures/player.get_players_no_groups.json b/tests/fixtures/player.get_players_no_groups.json new file mode 100644 index 0000000..6459ce2 --- /dev/null +++ b/tests/fixtures/player.get_players_no_groups.json @@ -0,0 +1,27 @@ +{ + "heos": { + "command": "player/get_players", + "result": "success", + "message": "" + }, + "payload": [{ + "name": "Back Patio", + "pid": 1, + "model": "HEOS Drive", + "version": "1.493.180", + "ip": "127.0.0.1", + "network": "wired", + "lineout": 2, + "control": 2, + "serial": "B1A2C3K" + }, { + "name": "Front Porch", + "pid": 2, + "model": "HEOS Drive", + "version": "1.493.180", + "ip": "127.0.0.2", + "network": "wifi", + "lineout": 1 + } + ] +} \ No newline at end of file diff --git a/tests/snapshots/test_group.ambr b/tests/snapshots/test_group.ambr new file mode 100644 index 0000000..d34eac3 --- /dev/null +++ b/tests/snapshots/test_group.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_refresh + dict({ + 'group_id': -263109739, + 'is_muted': False, + 'lead_player_id': -263109739, + 'member_player_ids': list([ + 845195621, + ]), + 'name': 'Zone 1 + Zone 2', + 'volume': 42, + }) +# --- +# name: test_refresh_no_base_update + dict({ + 'group_id': 1, + 'is_muted': False, + 'lead_player_id': 1, + 'member_player_ids': list([ + 2, + ]), + 'name': 'Back Patio + Front Porch', + 'volume': 42, + }) +# --- diff --git a/tests/snapshots/test_heos.ambr b/tests/snapshots/test_heos.ambr new file mode 100644 index 0000000..0ddcfb2 --- /dev/null +++ b/tests/snapshots/test_heos.ambr @@ -0,0 +1,996 @@ +# serializer version: 1 +# name: test_browse_media_item + dict({ + 'container_id': 'LIBALBUM-134788273', + 'count': 14, + 'items': list([ + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788274', + 'name': 'Alone Again', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788275', + 'name': 'Too Late', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788276', + 'name': 'Hardest To Love', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788277', + 'name': 'Scared To Live', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788278', + 'name': 'Snowchild', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788279', + 'name': 'Escape From LA', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788280', + 'name': 'Heartless', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788281', + 'name': 'Faith', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788282', + 'name': 'Blinding Lights', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788283', + 'name': 'In Your Eyes', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788284', + 'name': 'Save Your Tears', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788285', + 'name': 'Repeat After Me (Interlude)', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788286', + 'name': 'After Hours', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'After Hours', + 'album_id': '134788273', + 'artist': 'The Weeknd', + 'browsable': False, + 'container_id': 'LIBALBUM-134788273', + 'image_url': 'http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/640x640.jpg', + 'media_id': '134788287', + 'name': 'Until I Bleed Out', + 'playable': True, + 'source_id': 10, + 'type': , + }), + ]), + 'options': list([ + ]), + 'returned': 14, + 'source_id': 10, + }) +# --- +# name: test_browse_media_music_source + dict({ + 'container_id': None, + 'count': 3, + 'items': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': 'http://mediaserver-cont-ch1-1-v4v6.pandora.com/images/public/devicead/t/r/a/m/daartpralbumart_500W_500H.jpg', + 'media_id': '3790855220637622543', + 'name': 'Thumbprint Radio', + 'playable': True, + 'source_id': 1028, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': 'http://cdn-radiotime-logos.tunein.com/s7332q.png', + 'media_id': 's7332', + 'name': '99.5 | Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 1028, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': 'http://mediaserver-cont-dc6-2-v4v6.pandora.com/images/public/int/4/0/8/2/00602577432804_500W_500H.jpg', + 'media_id': '1935916853323522319', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1028, + 'type': , + }), + ]), + 'options': list([ + dict({ + 'context': 'browse', + 'id': 20, + 'name': 'Remove from HEOS Favorites', + }), + ]), + 'returned': 3, + 'source_id': 1028, + }) +# --- +# name: test_get_favorites + dict({ + 1: dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': 'http://mediaserver-cont-ch1-1-v4v6.pandora.com/images/public/devicead/t/r/a/m/daartpralbumart_500W_500H.jpg', + 'media_id': '3790855220637622543', + 'name': 'Thumbprint Radio', + 'playable': True, + 'source_id': 1028, + 'type': , + }), + 2: dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': 'http://cdn-radiotime-logos.tunein.com/s7332q.png', + 'media_id': 's7332', + 'name': '99.5 | Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 1028, + 'type': , + }), + 3: dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': 'http://mediaserver-cont-dc6-2-v4v6.pandora.com/images/public/int/4/0/8/2/00602577432804_500W_500H.jpg', + 'media_id': '1935916853323522319', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1028, + 'type': , + }), + }) +# --- +# name: test_get_group_info_by_id + dict({ + 'group_id': -263109739, + 'is_muted': False, + 'lead_player_id': -263109739, + 'member_player_ids': list([ + 845195621, + ]), + 'name': 'Zone 1 + Zone 2', + 'volume': 42, + }) +# --- +# name: test_get_group_info_by_id_already_loaded_refresh + dict({ + 'group_id': -263109739, + 'is_muted': False, + 'lead_player_id': -263109739, + 'member_player_ids': list([ + 845195621, + ]), + 'name': 'Zone 1 + Zone 2', + 'volume': 42, + }) +# --- +# name: test_get_groups + dict({ + 1: dict({ + 'group_id': 1, + 'is_muted': False, + 'lead_player_id': 1, + 'member_player_ids': list([ + 2, + ]), + 'name': 'Back Patio + Front Porch', + 'volume': 42, + }), + }) +# --- +# name: test_get_input_sources + list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/cable_sat', + 'name': 'Theater Receiver - CBL/SAT', + 'playable': True, + 'source_id': 546978854, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/dvd', + 'name': 'Theater Receiver - DVD', + 'playable': True, + 'source_id': 546978854, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/bluray', + 'name': 'Theater Receiver - Blu-ray', + 'playable': True, + 'source_id': 546978854, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/game', + 'name': 'Theater Receiver - Game', + 'playable': True, + 'source_id': 546978854, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux1', + 'name': 'Theater Receiver - AUX1', + 'playable': True, + 'source_id': 546978854, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux2', + 'name': 'Theater Receiver - AUX2', + 'playable': True, + 'source_id': 546978854, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/mediaplayer', + 'name': 'Theater Receiver - Media Player', + 'playable': True, + 'source_id': 546978854, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/hdradio', + 'name': 'Theater Receiver - HD Radio', + 'playable': True, + 'source_id': 546978854, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/tvaudio', + 'name': 'Theater Receiver - TV Audio', + 'playable': True, + 'source_id': 546978854, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/phono', + 'name': 'Theater Receiver - Phono', + 'playable': True, + 'source_id': 546978854, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/line_in_1', + 'name': 'HEOS Drive - Line In 1', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/line_in_2', + 'name': 'HEOS Drive - Line In 2', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/line_in_3', + 'name': 'HEOS Drive - Line In 3', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/line_in_4', + 'name': 'HEOS Drive - Line In 4', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/optical_in_1', + 'name': 'HEOS Drive - Optical In 1', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/optical_in_2', + 'name': 'HEOS Drive - Optical In 2', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/coax_in_1', + 'name': 'HEOS Drive - Coaxial In 1', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/coax_in_2', + 'name': 'HEOS Drive - Coaxial In 2', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + ]) +# --- +# name: test_get_music_sources + dict({ + 1: dict({ + 'available': True, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png', + 'name': 'Pandora', + 'service_username': 'test@test.com', + 'source_id': 1, + 'type': , + }), + 2: dict({ + 'available': False, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/rhapsody.png', + 'name': 'Rhapsody', + 'service_username': None, + 'source_id': 2, + 'type': , + }), + 3: dict({ + 'available': False, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/tunein.png', + 'name': 'TuneIn', + 'service_username': None, + 'source_id': 3, + 'type': , + }), + 5: dict({ + 'available': False, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/deezer.png', + 'name': 'Deezer', + 'service_username': None, + 'source_id': 5, + 'type': , + }), + 6: dict({ + 'available': False, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/napster.png', + 'name': 'Napster', + 'service_username': None, + 'source_id': 6, + 'type': , + }), + 7: dict({ + 'available': False, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/iheartradio.png', + 'name': 'iHeartRadio', + 'service_username': None, + 'source_id': 7, + 'type': , + }), + 8: dict({ + 'available': False, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/siriusxm.png', + 'name': 'SiriusXM', + 'service_username': None, + 'source_id': 8, + 'type': , + }), + 9: dict({ + 'available': False, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/soundcloud.png', + 'name': 'SoundCloud', + 'service_username': None, + 'source_id': 9, + 'type': , + }), + 10: dict({ + 'available': False, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/tidal.png', + 'name': 'Tidal', + 'service_username': None, + 'source_id': 10, + 'type': , + }), + 13: dict({ + 'available': True, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/amazon.png', + 'name': 'Amazon', + 'service_username': 'Test User', + 'source_id': 13, + 'type': , + }), + 1024: dict({ + 'available': True, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/musicsource_logo_servers.png', + 'name': 'Local Music', + 'service_username': None, + 'source_id': 1024, + 'type': , + }), + 1025: dict({ + 'available': True, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/musicsource_logo_playlists.png', + 'name': 'Playlists', + 'service_username': None, + 'source_id': 1025, + 'type': , + }), + 1026: dict({ + 'available': True, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/musicsource_logo_history.png', + 'name': 'History', + 'service_username': None, + 'source_id': 1026, + 'type': , + }), + 1027: dict({ + 'available': True, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/musicsource_logo_aux.png', + 'name': 'AUX Input', + 'service_username': None, + 'source_id': 1027, + 'type': , + }), + 1028: dict({ + 'available': True, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/musicsource_logo_favorites.png', + 'name': 'Favorites', + 'service_username': None, + 'source_id': 1028, + 'type': , + }), + }) +# --- +# name: test_get_now_playing_media + dict({ + 'album': 'Album', + 'album_id': '123456', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://cont-5.p-cdn.us/images/public/int/6/1/1/9/050087149116_500W_500H.jpg', + 'media_id': '4256592506324148495', + 'options': list([ + dict({ + 'context': 'play', + 'id': 11, + 'name': 'Thumbs Up', + }), + dict({ + 'context': 'play', + 'id': 12, + 'name': 'Thumbs Down', + }), + dict({ + 'context': 'play', + 'id': 19, + 'name': 'Add to HEOS Favorites', + }), + ]), + 'queue_id': 1, + 'song': "Disney (Children's) Radio", + 'source_id': 13, + 'station': "Disney (Children's) Radio", + 'supported_controls': list([ + , + , + , + , + , + ]), + 'type': , + }) +# --- +# name: test_get_players + dict({ + 1: dict({ + 'available': True, + 'control': , + 'group_id': 2, + 'ip_address': '127.0.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': 'Album', + 'album_id': '123456', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://cont-5.p-cdn.us/images/public/int/6/1/1/9/050087149116_500W_500H.jpg', + 'media_id': '4256592506324148495', + 'options': list([ + dict({ + 'context': 'play', + 'id': 11, + 'name': 'Thumbs Up', + }), + dict({ + 'context': 'play', + 'id': 12, + 'name': 'Thumbs Down', + }), + dict({ + 'context': 'play', + 'id': 19, + 'name': 'Add to HEOS Favorites', + }), + ]), + 'queue_id': 1, + 'song': "Disney (Children's) Radio", + 'source_id': 13, + 'station': "Disney (Children's) Radio", + 'supported_controls': list([ + , + , + , + , + , + ]), + 'type': , + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': 'B1A2C3K', + 'shuffle': False, + 'state': , + 'version': '1.493.180', + 'volume': 36, + }), + 2: dict({ + 'available': True, + 'control': , + 'group_id': 2, + 'ip_address': '127.0.0.2', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Front Porch', + 'network': , + 'now_playing_media': dict({ + 'album': 'Album', + 'album_id': '123456', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://cont-5.p-cdn.us/images/public/int/6/1/1/9/050087149116_500W_500H.jpg', + 'media_id': '4256592506324148495', + 'options': list([ + dict({ + 'context': 'play', + 'id': 11, + 'name': 'Thumbs Up', + }), + dict({ + 'context': 'play', + 'id': 12, + 'name': 'Thumbs Down', + }), + dict({ + 'context': 'play', + 'id': 19, + 'name': 'Add to HEOS Favorites', + }), + ]), + 'queue_id': 1, + 'song': "Disney (Children's) Radio", + 'source_id': 13, + 'station': "Disney (Children's) Radio", + 'supported_controls': list([ + , + , + , + , + , + ]), + 'type': , + }), + 'playback_error': None, + 'player_id': 2, + 'repeat': , + 'serial': None, + 'shuffle': False, + 'state': , + 'version': '1.493.180', + 'volume': 36, + }), + }) +# --- +# name: test_get_playlists + list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': True, + 'container_id': '171566', + 'image_url': '', + 'media_id': None, + 'name': 'Rockin Songs', + 'playable': True, + 'source_id': 1025, + 'type': , + }), + ]) +# --- +# name: test_player_now_playing_changed_event[changed_state] + dict({ + 'album': "I've Been Waiting (Single) (Explicit)", + 'album_id': '1', + 'artist': 'Lil Peep & ILoveMakonnen', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://media/url', + 'media_id': '2PxuY99Qty', + 'options': list([ + dict({ + 'context': 'play', + 'id': 11, + 'name': 'Thumbs Up', + }), + dict({ + 'context': 'play', + 'id': 12, + 'name': 'Thumbs Down', + }), + dict({ + 'context': 'play', + 'id': 20, + 'name': 'Remove from HEOS Favorites', + }), + ]), + 'queue_id': 1, + 'song': "I've Been Waiting (feat. Fall Out Boy)", + 'source_id': 1, + 'station': "Today's Hits Radio", + 'supported_controls': list([ + , + , + , + , + ]), + 'type': , + }) +# --- +# name: test_player_now_playing_changed_event[current_state] + dict({ + 'album': 'Album', + 'album_id': '123456', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://cont-5.p-cdn.us/images/public/int/6/1/1/9/050087149116_500W_500H.jpg', + 'media_id': '4256592506324148495', + 'options': list([ + dict({ + 'context': 'play', + 'id': 11, + 'name': 'Thumbs Up', + }), + dict({ + 'context': 'play', + 'id': 12, + 'name': 'Thumbs Down', + }), + dict({ + 'context': 'play', + 'id': 19, + 'name': 'Add to HEOS Favorites', + }), + ]), + 'queue_id': 1, + 'song': "Disney (Children's) Radio", + 'source_id': 13, + 'station': "Disney (Children's) Radio", + 'supported_controls': list([ + , + , + , + , + , + ]), + 'type': , + }) +# --- +# name: test_validate_connection + dict({ + 'connected_to_preferred_host': True, + 'host': dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'serial': 'B1A2C3K', + 'version': '1.493.180', + }), + 'hosts': list([ + dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'serial': 'B1A2C3K', + 'version': '1.493.180', + }), + dict({ + 'ip_address': '127.0.0.2', + 'model': 'HEOS Drive', + 'name': 'Front Porch', + 'network': , + 'serial': None, + 'version': '1.493.180', + }), + ]), + 'is_signed_in': True, + 'preferred_hosts': list([ + dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'serial': 'B1A2C3K', + 'version': '1.493.180', + }), + ]), + 'signed_in_username': 'example@example.com', + }) +# --- diff --git a/tests/snapshots/test_heos_browse.ambr b/tests/snapshots/test_heos_browse.ambr new file mode 100644 index 0000000..e9d825c --- /dev/null +++ b/tests/snapshots/test_heos_browse.ambr @@ -0,0 +1,1302 @@ +# serializer version: 1 +# name: test_get_music_source_by_id + dict({ + 'available': True, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png', + 'name': 'Pandora', + 'service_username': 'email@email.com', + 'source_id': 1, + 'type': , + }) +# --- +# name: test_get_search_criteria + list([ + dict({ + 'container_id': None, + 'criteria_id': 1, + 'name': 'Artist', + 'playable': False, + 'wildcard': False, + }), + dict({ + 'container_id': None, + 'criteria_id': 2, + 'name': 'Album', + 'playable': False, + 'wildcard': False, + }), + dict({ + 'container_id': 'SEARCHED_TRACKS-', + 'criteria_id': 3, + 'name': 'Track', + 'playable': True, + 'wildcard': False, + }), + dict({ + 'container_id': None, + 'criteria_id': 6, + 'name': 'Playlist', + 'playable': False, + 'wildcard': False, + }), + ]) +# --- +# name: test_multi_search + dict({ + 'count': 74, + 'criteria_ids': list([ + 0, + 1, + 2, + 3, + ]), + 'errors': list([ + dict({ + 'count': None, + 'criteria_id': 0, + 'error_number': 2, + 'returned': None, + 'source_id': 13, + }), + dict({ + 'count': None, + 'criteria_id': 0, + 'error_number': -1061, + 'returned': None, + 'source_id': 8, + }), + ]), + 'items': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': 'ZEDD', + 'browsable': True, + 'container_id': 'LIBALBUM-401193042', + 'image_url': 'http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/160x160.jpg', + 'media_id': None, + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': 'ZEDD', + 'browsable': True, + 'container_id': 'LIBALBUM-401192835', + 'image_url': 'http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/160x160.jpg', + 'media_id': None, + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-R66030', + 'name': 'Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-R696162', + 'name': 'X-Rays', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-R400422', + 'name': 'Tangerine Kitty', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-R4004275', + 'name': 'Ferrari Simmons & RaySean', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-R172591', + 'name': 'Ray Stevens (Holiday)', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-R5180582', + 'name': 'Dr. Hook & Ray Sawyer', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-R153854', + 'name': 'X-Ray Spex', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S133862497', + 'name': 'Tangerine Rays by Zedd, Bea Miller & ellis', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S142439068', + 'name': 'Tangerine Rays by Zedd, Bea Miller & ellis', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S142439069', + 'name': 'Tangerine Rays (Instrumental) by Zedd, Bea Miller & ellis', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S142439070', + 'name': 'Tangerine Rays (Acapella) by Zedd, Bea Miller & ellis', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S136888654', + 'name': 'Tangerine Rays (8-Bit Bea Miller, Ellis & Zedd Emulation) by 8-Bit Arcade', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S32964', + 'name': 'Tangerine (Remaster) by Led Zeppelin', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S5786562', + 'name': 'Tangerine Sky by Kottonmouth Kings', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S164056', + 'name': 'Tangerine by Eliane Elias', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S5615318', + 'name': 'Tangerine by Herb Alpert & The Tijuana Brass', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S46560612', + 'name': 'TANGERINE DREAM by Snoh Aalegra', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S7989950', + 'name': 'Sun Rays Like Stilts by Tommy Guerrero', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S571786', + 'name': 'Tangerine (Remastered) by Les Brown', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S13186508', + 'name': 'Sun Rays Vol. 2 by Chillout Lounge', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S1454325', + 'name': 'Say Goodbye to the Tangerine Sky by Kottonmouth Kings', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S1926771', + 'name': 'Rays of Light by 2002', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S28616466', + 'name': 'Los Santos City Map by Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S48872068', + 'name': 'Tangerine by Glass Animals', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S16481097', + 'name': 'Speed Dragon by Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S1082443', + 'name': 'Tangerine by Mary Louise Knutson', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S11825304', + 'name': 'Identity Proven Matrix by Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S24154834', + 'name': 'First Rays of Light by Celestial Alignment', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S16550589', + 'name': 'Tangerine by Amane', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S1127632', + 'name': 'Metaphor Part One by Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S18892466', + 'name': "Sequent 'C' (Remastered 2018) by Tangerine Dream", + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S30194818', + 'name': 'Tangerine (Live at Whittemore Center Arena) (Durham) (NH) (02.19.96) by Dave Matthews Band', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S15007209', + 'name': 'The Sun Whose Rays (Live At Teatro La Fenice, Venice / 2006) by Keith Jarrett', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S11970982', + 'name': 'Tangerine (feat. Big Sean) by Miley Cyrus', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S5930743', + 'name': 'Morning Rays (Album Version) (feat. Richard Tee) by Tom Scott', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S13653697', + 'name': 'Cosmic Rays by Charlie Parker Quartet', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S2082136', + 'name': 'Tangerine by First Aid Kit', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S1898039', + 'name': 'Seven Rays by 2002', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S28616469', + 'name': 'Stratosfear 2019 by Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S30144', + 'name': 'Your X-Rays Have Just Come Back From The Lab And We Think We Know What Your Problem Is by Jets To Brazil', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S40299096', + 'name': 'Tangerine by Barii', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S1127647', + 'name': 'Tangines On And Running by Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S38892893', + 'name': 'Father And Son (Resurrection 2) by Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S28616468', + 'name': 'Yellowstone Park 2019 by Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S8963484', + 'name': 'X-Rays by Jackie Mason', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S6870549', + 'name': 'Italian X Rays by Steve Miller Band', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S5811178', + 'name': 'Tangerine (Remastered 2001) by Joe Pass', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S35125374', + 'name': 'Tyger 2013 by Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S6442778', + 'name': "The First Rays of This Forever's Light by Matt Borghi", + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S13653699', + 'name': 'Cosmic Rays (Alternate Take) by Charlie Parker Quartet', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S36767691', + 'name': 'Tangerine Sour by Emancipator & 9 Theory', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S1098124', + 'name': 'Moon Rays (2007 Digital Remaster/Rudy Van Gelder Edition) by Horace Silver Quintet', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S16550591', + 'name': 'Tangerine by Amane', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S30475082', + 'name': 'Tangerine by Lou Donaldson', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S17131911', + 'name': 'Morgenstern, Pt. 4 by Schiller & Tangerine Dream', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S1609452', + 'name': 'Ceremony of the Seven Rays by Deborah Van Dyke', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'CREATE_STATION-S9747275', + 'name': 'Tangerine by Bob Brookmeyer & Stan Getz Quintet', + 'playable': True, + 'source_id': 1, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401192835', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg', + 'media_id': '401192836', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Telos', + 'album_id': '383020262', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/28fe4513/7d90/4b62/b3df/2cb09ec35477/640x640.jpg', + 'media_id': '383020264', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401192835', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg', + 'media_id': '401192838', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Telos', + 'album_id': '383020285', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/36dc43cb/aa36/44cf/bb54/aca54d5991b6/640x640.jpg', + 'media_id': '383020288', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Telos', + 'album_id': '383978026', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/fec0bead/36dd/4357/8e3d/d0af125bb913/640x640.jpg', + 'media_id': '383978029', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401193042', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg', + 'media_id': '401193043', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401192835', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg', + 'media_id': '401192837', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401193042', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg', + 'media_id': '401193045', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401193042', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg', + 'media_id': '401193044', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Pristine Karaoke, Vol. 170', + 'album_id': '389676760', + 'artist': 'Backing Business', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/4efa15cf/1a95/423a/8b05/f656281da36e/640x640.jpg', + 'media_id': '389676834', + 'name': 'Tangerine Rays (Karaoke Version Originally Performed by Bea Miller, Ellis & Zedd)', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Jazz for Improving Concentration in Spring', + 'album_id': '355503098', + 'artist': 'Tangerine Jazz', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg', + 'media_id': '355503127', + 'name': 'Study Alcove Gentle Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'サマー・ブリーズとコーヒータイム', + 'album_id': '376883744', + 'artist': 'Tangerine Jazz', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/7c7f3b86/24ea/459d/8640/a139dcd28f7e/640x640.jpg', + 'media_id': '376883749', + 'name': 'Gathering Summer Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Summer Bossa Time at the Beachside', + 'album_id': '377809861', + 'artist': 'Tangerine Jazz', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/3bbfc24b/9939/4a2d/b538/4f034f4cf305/640x640.jpg', + 'media_id': '377809865', + 'name': 'Gathering Summer Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Jazz for Improving Concentration in Spring', + 'album_id': '355503098', + 'artist': 'Tangerine Jazz', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg', + 'media_id': '355503109', + 'name': 'First Rays Symmetry', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Instrumental BGM Matching a Relaxed New Life', + 'album_id': '355149701', + 'artist': 'Tangerine Jazz', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg', + 'media_id': '355149731', + 'name': 'Morning Rays and Fresh Starts', + 'playable': True, + 'source_id': 10, + 'type': , + }), + ]), + 'returned': 74, + 'search': 'Tangerine Rays', + 'source_ids': list([ + 1, + 4, + 8, + 13, + 10, + ]), + 'statistics': list([ + dict({ + 'count': 2, + 'criteria_id': 2, + 'error_number': None, + 'returned': 2, + 'source_id': 10, + }), + dict({ + 'count': 0, + 'criteria_id': 1, + 'error_number': None, + 'returned': 0, + 'source_id': 10, + }), + dict({ + 'count': 57, + 'criteria_id': 0, + 'error_number': None, + 'returned': 57, + 'source_id': 1, + }), + dict({ + 'count': 15, + 'criteria_id': 3, + 'error_number': None, + 'returned': 15, + 'source_id': 10, + }), + ]), + }) +# --- +# name: test_retrieve_metadata + dict({ + 'container_id': '123456', + 'count': 1, + 'metadata': list([ + dict({ + 'album_id': '7890', + 'images': list([ + dict({ + 'image_url': 'http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg', + 'width': 640, + }), + dict({ + 'image_url': 'http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/320x320.jpg', + 'width': 320, + }), + ]), + }), + ]), + 'returned': 1, + 'source_id': 6, + }) +# --- +# name: test_search + dict({ + 'count': 15, + 'criteria_id': 3, + 'items': list([ + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401192835', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg', + 'media_id': '401192836', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Telos', + 'album_id': '383020262', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/28fe4513/7d90/4b62/b3df/2cb09ec35477/640x640.jpg', + 'media_id': '383020264', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401192835', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg', + 'media_id': '401192838', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Telos', + 'album_id': '383020285', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/36dc43cb/aa36/44cf/bb54/aca54d5991b6/640x640.jpg', + 'media_id': '383020288', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Telos', + 'album_id': '383978026', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/fec0bead/36dd/4357/8e3d/d0af125bb913/640x640.jpg', + 'media_id': '383978029', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401193042', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg', + 'media_id': '401193043', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401192835', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg', + 'media_id': '401192837', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401193042', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg', + 'media_id': '401193045', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Tangerine Rays', + 'album_id': '401193042', + 'artist': 'ZEDD', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg', + 'media_id': '401193044', + 'name': 'Tangerine Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Pristine Karaoke, Vol. 170', + 'album_id': '389676760', + 'artist': 'Backing Business', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/4efa15cf/1a95/423a/8b05/f656281da36e/640x640.jpg', + 'media_id': '389676834', + 'name': 'Tangerine Rays (Karaoke Version Originally Performed by Bea Miller, Ellis & Zedd)', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Jazz for Improving Concentration in Spring', + 'album_id': '355503098', + 'artist': 'Tangerine Jazz', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg', + 'media_id': '355503127', + 'name': 'Study Alcove Gentle Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'サマー・ブリーズとコーヒータイム', + 'album_id': '376883744', + 'artist': 'Tangerine Jazz', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/7c7f3b86/24ea/459d/8640/a139dcd28f7e/640x640.jpg', + 'media_id': '376883749', + 'name': 'Gathering Summer Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Summer Bossa Time at the Beachside', + 'album_id': '377809861', + 'artist': 'Tangerine Jazz', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/3bbfc24b/9939/4a2d/b538/4f034f4cf305/640x640.jpg', + 'media_id': '377809865', + 'name': 'Gathering Summer Rays', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Jazz for Improving Concentration in Spring', + 'album_id': '355503098', + 'artist': 'Tangerine Jazz', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg', + 'media_id': '355503109', + 'name': 'First Rays Symmetry', + 'playable': True, + 'source_id': 10, + 'type': , + }), + dict({ + 'album': 'Instrumental BGM Matching a Relaxed New Life', + 'album_id': '355149701', + 'artist': 'Tangerine Jazz', + 'browsable': False, + 'container_id': None, + 'image_url': 'http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg', + 'media_id': '355149731', + 'name': 'Morning Rays and Fresh Starts', + 'playable': True, + 'source_id': 10, + 'type': , + }), + ]), + 'returned': 15, + 'search': 'Tangerine Rays', + 'source_id': 10, + }) +# --- diff --git a/tests/snapshots/test_media.ambr b/tests/snapshots/test_media.ambr new file mode 100644 index 0000000..ae9dea2 --- /dev/null +++ b/tests/snapshots/test_media.ambr @@ -0,0 +1,263 @@ +# serializer version: 1 +# name: test_browse_result_from_data + dict({ + 'container_id': None, + 'count': 1, + 'items': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': True, + 'container_id': '171566', + 'image_url': '', + 'media_id': None, + 'name': 'Rockin Songs', + 'playable': True, + 'source_id': 1025, + 'type': , + }), + ]), + 'options': list([ + ]), + 'returned': 1, + 'source_id': 1025, + }) +# --- +# name: test_media_item_browse + dict({ + 'container_id': None, + 'count': 8, + 'items': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/line_in_1', + 'name': 'HEOS Drive - Line In 1', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/line_in_2', + 'name': 'HEOS Drive - Line In 2', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/line_in_3', + 'name': 'HEOS Drive - Line In 3', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/line_in_4', + 'name': 'HEOS Drive - Line In 4', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/optical_in_1', + 'name': 'HEOS Drive - Optical In 1', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/optical_in_2', + 'name': 'HEOS Drive - Optical In 2', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/coax_in_1', + 'name': 'HEOS Drive - Coaxial In 1', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/coax_in_2', + 'name': 'HEOS Drive - Coaxial In 2', + 'playable': True, + 'source_id': -263109739, + 'type': , + }), + ]), + 'options': list([ + ]), + 'returned': 8, + 'source_id': -263109739, + }) +# --- +# name: test_media_item_from_data + dict({ + 'album': 'Future Friends', + 'album_id': '78374740', + 'artist': 'Superfruit', + 'browsable': False, + 'container_id': 'My Music', + 'image_url': 'http://resources.wimpmusic.com/images/7e7bacc1/3e75/4761/a822/9342239edfa0/640x640.jpg', + 'media_id': '78374741', + 'name': 'Imaginary Parties', + 'playable': True, + 'source_id': 1, + 'type': , + }) +# --- +# name: test_media_item_from_data_container + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': True, + 'container_id': '94467912-bd40-4d2f-ad25-7b8423f7b05a', + 'image_url': '', + 'media_id': None, + 'name': 'Video', + 'playable': False, + 'source_id': 123456789, + 'type': , + }) +# --- +# name: test_media_item_from_data_source + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': True, + 'container_id': None, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/musicsource_logo_servers.png', + 'media_id': None, + 'name': 'Plex Media Server', + 'playable': False, + 'source_id': 123456789, + 'type': , + }) +# --- +# name: test_media_music_source_browse + dict({ + 'container_id': None, + 'count': 3, + 'items': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': 'http://mediaserver-cont-ch1-1-v4v6.pandora.com/images/public/devicead/t/r/a/m/daartpralbumart_500W_500H.jpg', + 'media_id': '3790855220637622543', + 'name': 'Thumbprint Radio', + 'playable': True, + 'source_id': 1028, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': 'http://cdn-radiotime-logos.tunein.com/s7332q.png', + 'media_id': 's7332', + 'name': '99.5 | Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 1028, + 'type': , + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': 'http://mediaserver-cont-dc6-2-v4v6.pandora.com/images/public/int/4/0/8/2/00602577432804_500W_500H.jpg', + 'media_id': '1935916853323522319', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1028, + 'type': , + }), + ]), + 'options': list([ + dict({ + 'context': 'browse', + 'id': 20, + 'name': 'Remove from HEOS Favorites', + }), + ]), + 'returned': 3, + 'source_id': 1028, + }) +# --- +# name: test_media_music_source_from_data + dict({ + 'available': True, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png', + 'name': 'Pandora', + 'service_username': 'test@test.com', + 'source_id': 1, + 'type': , + }) +# --- +# name: test_refresh + dict({ + 'available': True, + 'image_url': 'https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png', + 'name': 'Pandora', + 'service_username': 'email@email.com', + 'source_id': 1, + 'type': , + }) +# --- diff --git a/tests/snapshots/test_player.ambr b/tests/snapshots/test_player.ambr new file mode 100644 index 0000000..8643e5b --- /dev/null +++ b/tests/snapshots/test_player.ambr @@ -0,0 +1,458 @@ +# serializer version: 1 +# name: test_from_data[None] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '192.168.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'version': '1.493.180', + 'volume': 0, + }) +# --- +# name: test_from_data[invalid] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '192.168.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'version': '1.493.180', + 'volume': 0, + }) +# --- +# name: test_from_data[wired] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '192.168.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'version': '1.493.180', + 'volume': 0, + }) +# --- +# name: test_get_queue + list([ + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555606', + 'queue_id': 1, + 'song': 'Baby', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555607', + 'queue_id': 2, + 'song': 'Down', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555608', + 'queue_id': 3, + 'song': '22 Break', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555609', + 'queue_id': 4, + 'song': 'Free', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555610', + 'queue_id': 5, + 'song': "Don't Let The Neighbourhood Hear", + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555611', + 'queue_id': 6, + 'song': 'Dinner', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555612', + 'queue_id': 7, + 'song': 'Rollercoaster Baby', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555613', + 'queue_id': 8, + 'song': 'Love Me Now', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555614', + 'queue_id': 9, + 'song': 'You > Me', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555615', + 'queue_id': 10, + 'song': 'Kicking The Doors Down', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555616', + 'queue_id': 11, + 'song': 'Twenty Fourteen', + }), + ]) +# --- +# name: test_get_queue_with_range + list([ + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555606', + 'queue_id': 1, + 'song': 'Baby', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555607', + 'queue_id': 2, + 'song': 'Down', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555608', + 'queue_id': 3, + 'song': '22 Break', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555609', + 'queue_id': 4, + 'song': 'Free', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555610', + 'queue_id': 5, + 'song': "Don't Let The Neighbourhood Hear", + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555611', + 'queue_id': 6, + 'song': 'Dinner', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555612', + 'queue_id': 7, + 'song': 'Rollercoaster Baby', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555613', + 'queue_id': 8, + 'song': 'Love Me Now', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555614', + 'queue_id': 9, + 'song': 'You > Me', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555615', + 'queue_id': 10, + 'song': 'Kicking The Doors Down', + }), + dict({ + 'album': '22 Break', + 'album_id': '199555605', + 'artist': 'Oh Wonder', + 'image_url': 'http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg', + 'media_id': '199555616', + 'queue_id': 11, + 'song': 'Twenty Fourteen', + }), + ]) +# --- +# name: test_get_quick_selects + dict({ + 1: 'Quick Select 1', + 2: 'Quick Select 2', + 3: 'Quick Select 3', + 4: 'Quick Select 4', + 5: 'Quick Select 5', + 6: 'Quick Select 6', + }) +# --- +# name: test_now_playing_media_unavailable + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }) +# --- +# name: test_refresh + dict({ + 'available': True, + 'control': , + 'group_id': -263109739, + 'ip_address': '127.0.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Zone 1', + 'network': , + 'now_playing_media': dict({ + 'album': 'Album', + 'album_id': '123456', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://cont-5.p-cdn.us/images/public/int/6/1/1/9/050087149116_500W_500H.jpg', + 'media_id': '4256592506324148495', + 'options': list([ + dict({ + 'context': 'play', + 'id': 11, + 'name': 'Thumbs Up', + }), + dict({ + 'context': 'play', + 'id': 12, + 'name': 'Thumbs Down', + }), + dict({ + 'context': 'play', + 'id': 19, + 'name': 'Add to HEOS Favorites', + }), + ]), + 'queue_id': 1, + 'song': "Disney (Children's) Radio", + 'source_id': 13, + 'station': "Disney (Children's) Radio", + 'supported_controls': list([ + , + , + , + , + , + ]), + 'type': , + }), + 'playback_error': None, + 'player_id': -263109739, + 'repeat': , + 'serial': '123456789', + 'shuffle': False, + 'state': , + 'version': '3.34.620', + 'volume': 36, + }) +# --- +# name: test_update_from_data + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '192.168.0.2', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drives', + 'name': 'Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 2, + 'repeat': , + 'serial': '0987654321', + 'shuffle': False, + 'state': None, + 'version': '2.0.0', + 'volume': 0, + }) +# --- diff --git a/tests/syrupy.py b/tests/syrupy.py new file mode 100644 index 0000000..4864f56 --- /dev/null +++ b/tests/syrupy.py @@ -0,0 +1,53 @@ +"""Define the pyheos test sryupy plugin.""" + +import dataclasses +from pathlib import Path +from typing import Any + +from syrupy.extensions.amber import AmberDataSerializer, AmberSnapshotExtension +from syrupy.location import PyTestLocation +from syrupy.types import PropertyFilter, PropertyMatcher, PropertyPath, SerializableData + + +class HeosSnapshotSerializer(AmberDataSerializer): + """Heos snapshot serializer for Syrupy.""" + + @classmethod + def _serialize( + cls, + data: SerializableData, + *, + depth: int = 0, + exclude: PropertyFilter | None = None, + include: PropertyFilter | None = None, + matcher: PropertyMatcher | None = None, + path: PropertyPath = (), + visited: set[Any] | None = None, + ) -> str: + """Pre-process data before serializing.""" + if dataclasses.is_dataclass(type(data)): + data = dataclasses.asdict(data) + + return super()._serialize( + data, + depth=depth, + exclude=exclude, + include=include, + matcher=matcher, + path=path, + visited=visited, + ) + + +class HeosSnapshotExtension(AmberSnapshotExtension): + """Heos extension for Syrupy.""" + + VERSION = "1" + + serializer_class: type[AmberDataSerializer] = HeosSnapshotSerializer + + @classmethod + def dirname(cls, *, test_location: PyTestLocation) -> str: + """Return the directory for the snapshot files.""" + test_dir = Path(test_location.filepath).parent + return str(test_dir.joinpath("snapshots")) diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index d5ace16..ba88942 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -9,7 +9,7 @@ from pyheos.dispatch import Dispatcher -async def test_connect(handler: Callable) -> None: +async def test_connect(handler: Callable[..., Any]) -> None: """Tests the connect function.""" # Arrange dispatcher = Dispatcher() @@ -19,7 +19,7 @@ async def test_connect(handler: Callable) -> None: assert handler in dispatcher.signals["TEST"] -async def test_disconnect(handler: Callable) -> None: +async def test_disconnect(handler: Callable[..., Any]) -> None: """Tests the disconnect function.""" # Arrange dispatcher = Dispatcher() @@ -30,7 +30,7 @@ async def test_disconnect(handler: Callable) -> None: assert handler not in dispatcher.signals["TEST"] -async def test_disconnect_all(handler: Callable) -> None: +async def test_disconnect_all(handler: Callable[..., Any]) -> None: """Tests the disconnect all function.""" # Arrange dispatcher = Dispatcher() @@ -46,7 +46,7 @@ async def test_disconnect_all(handler: Callable) -> None: assert handler not in dispatcher.signals["TEST3"] -async def test_already_disconnected(handler: Callable) -> None: +async def test_already_disconnected(handler: Callable[..., Any]) -> None: """Tests that disconnect can be called more than once.""" # Arrange dispatcher = Dispatcher() @@ -58,7 +58,7 @@ async def test_already_disconnected(handler: Callable) -> None: assert handler not in dispatcher.signals["TEST"] -async def test_send_async_handler(async_handler: Callable) -> None: +async def test_send_async_handler(async_handler: Callable[..., Any]) -> None: """Tests sending to async handlers.""" # Arrange dispatcher = Dispatcher() @@ -105,7 +105,7 @@ async def async_handler_exception() -> None: assert "Exception in target callback:" not in caplog.text -async def test_send_async_partial_handler(async_handler: Callable) -> None: +async def test_send_async_partial_handler(async_handler: Callable[..., Any]) -> None: """Tests sending to async handlers.""" # Arrange partial = functools.partial(async_handler) @@ -117,7 +117,7 @@ async def test_send_async_partial_handler(async_handler: Callable) -> None: assert async_handler.fired # type: ignore[attr-defined] -async def test_send(handler: Callable) -> None: +async def test_send(handler: Callable[..., Any]) -> None: """Tests sending to async handlers.""" # Arrange dispatcher = Dispatcher() @@ -130,13 +130,13 @@ async def test_send(handler: Callable) -> None: assert handler.args[0] == args # type: ignore[attr-defined] -async def test_custom_connect_and_send(handler: Callable) -> None: +async def test_custom_connect_and_send(handler: Callable[..., Any]) -> None: """Tests using the custom connect and send implementations.""" # Arrange test_signal = "PREFIX_TEST" stored_target = None - def connect(signal: str, target: Callable) -> Callable: + def connect(signal: str, target: Callable[..., Any]) -> Callable[..., Any]: assert signal == test_signal nonlocal stored_target stored_target = target diff --git a/tests/test_group.py b/tests/test_group.py index fe6503a..c713a77 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1,6 +1,7 @@ """Tests for the group module.""" import pytest +from syrupy.assertion import SnapshotAssertion from pyheos import command as c from pyheos.const import EVENT_GROUP_VOLUME_CHANGED, EVENT_PLAYER_VOLUME_CHANGED @@ -154,29 +155,19 @@ async def test_toggle_mute(group: HeosGroup) -> None: CallCommand("group.get_volume", {c.ATTR_GROUP_ID: -263109739}), CallCommand("group.get_mute", {c.ATTR_GROUP_ID: -263109739}), ) -async def test_refresh(group: HeosGroup) -> None: +async def test_refresh(group: HeosGroup, snapshot: SnapshotAssertion) -> None: """Test refresh, including base, updates the correct information.""" await group.refresh() - - assert group.name == "Zone 1 + Zone 2" - assert group.group_id == -263109739 - assert group.lead_player_id == -263109739 - assert group.member_player_ids == [845195621] - assert group.volume == 42 - assert not group.is_muted + assert group == snapshot @calls_commands( CallCommand("group.get_volume", {c.ATTR_GROUP_ID: 1}), CallCommand("group.get_mute", {c.ATTR_GROUP_ID: 1}), ) -async def test_refresh_no_base_update(group: HeosGroup) -> None: +async def test_refresh_no_base_update( + group: HeosGroup, snapshot: SnapshotAssertion +) -> None: """Test refresh updates the correct information.""" await group.refresh(refresh_base_info=False) - - assert group.name == "Back Patio + Front Porch" - assert group.group_id == 1 - assert group.lead_player_id == 1 - assert group.member_player_ids == [2] - assert group.volume == 42 - assert not group.is_muted + assert group == snapshot diff --git a/tests/test_heos.py b/tests/test_heos.py index 3570f87..d8bf162 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -5,6 +5,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from pyheos import command as c from pyheos.const import ( @@ -21,10 +22,8 @@ EVENT_SHUFFLE_MODE_CHANGED, EVENT_SOURCES_CHANGED, EVENT_USER_CHANGED, - INPUT_CABLE_SAT, MUSIC_SOURCE_AUX_INPUT, MUSIC_SOURCE_FAVORITES, - MUSIC_SOURCE_PANDORA, MUSIC_SOURCE_PLAYLISTS, MUSIC_SOURCE_TUNEIN, ) @@ -37,20 +36,17 @@ HeosError, ) from pyheos.group import HeosGroup -from pyheos.heos import Heos, HeosOptions, PlayerUpdateResult +from pyheos.heos import Heos from pyheos.media import MediaItem, MediaMusicSource -from pyheos.player import CONTROLS_ALL, CONTROLS_FORWARD_ONLY, HeosPlayer +from pyheos.options import HeosOptions +from pyheos.player import HeosPlayer, PlayerUpdateResult from pyheos.types import ( AddCriteriaType, ConnectionState, - LineOutLevelType, - MediaType, - NetworkType, PlayState, RepeatType, SignalHeosEvent, SignalType, - VolumeControlType, ) from tests.common import MediaItems @@ -75,29 +71,12 @@ async def test_init() -> None: @calls_command("player.get_players") -async def test_validate_connection(mock_device: MockHeosDevice) -> None: +async def test_validate_connection( + mock_device: MockHeosDevice, snapshot: SnapshotAssertion +) -> None: """Test get_system_info method returns system info.""" system_info = await Heos.validate_connection("127.0.0.1") - - assert system_info.signed_in_username == "example@example.com" - assert system_info.is_signed_in - assert system_info.host.ip_address == "127.0.0.1" - assert system_info.connected_to_preferred_host is True - assert [system_info.host] == system_info.preferred_hosts - - assert system_info.hosts[0].ip_address == "127.0.0.1" - assert system_info.hosts[0].model == "HEOS Drive" - assert system_info.hosts[0].name == "Back Patio" - assert system_info.hosts[0].network == NetworkType.WIRED - assert system_info.hosts[0].serial == "B1A2C3K" - assert system_info.hosts[0].version == "1.493.180" - - assert system_info.hosts[1].ip_address == "127.0.0.2" - assert system_info.hosts[1].model == "HEOS Drive" - assert system_info.hosts[1].name == "Front Porch" - assert system_info.hosts[1].network == NetworkType.WIFI - assert system_info.hosts[1].serial is None - assert system_info.hosts[1].version == "1.493.180" + assert system_info == snapshot async def test_connect(mock_device: MockHeosDevice) -> None: @@ -401,11 +380,17 @@ async def test_reconnect_during_event(mock_device: MockHeosDevice) -> None: # Assert reconnects once server is back up and fires connected # Force reconnect timeout - await asyncio.sleep(0.5) # type: ignore[unreachable] + reconnect_task = next( # type: ignore[unreachable] + task + for task in heos._connection._running_tasks + if task.get_name() == "Reconnect" + ) + await asyncio.sleep(0.5) await mock_device.start() await connect_signal.wait() assert heos.connection_state == ConnectionState.CONNECTED + await reconnect_task # Ensures task completes, otherwise disconnect cancels it await heos.disconnect() @@ -486,29 +471,11 @@ async def test_reconnect_cancelled(mock_device: MockHeosDevice) -> None: @calls_player_commands() -async def test_get_players(heos: Heos) -> None: +async def test_get_players(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test the get_players method load players.""" - await heos.get_players() - # Assert players loaded - assert len(heos.players) == 2 - player = heos.players[1] - assert player.player_id == 1 - assert player.name == "Back Patio" - assert player.ip_address == "127.0.0.1" - assert player.line_out == LineOutLevelType.FIXED - assert player.control == VolumeControlType.IR - assert player.model == "HEOS Drive" - assert player.network == NetworkType.WIRED - assert player.state == PlayState.STOP - assert player.version == "1.493.180" - assert player.volume == 36 - assert not player.is_muted - assert player.repeat == RepeatType.OFF - assert not player.shuffle - assert player.available - assert player.heos == heos - assert player.group_id is None - assert heos.players[2].group_id == 2 + players = await heos.get_players() + + assert players == snapshot @calls_commands( @@ -640,32 +607,13 @@ async def handler(event: str) -> None: @calls_player_commands() async def test_player_now_playing_changed_event( - mock_device: MockHeosDevice, heos: Heos + mock_device: MockHeosDevice, heos: Heos, snapshot: SnapshotAssertion ) -> None: """Test now playing updates when event is received.""" # assert current state await heos.get_players() player = heos.players[1] - now_playing = player.now_playing_media - assert now_playing.type == "station" - assert now_playing.song == "Disney (Children's) Radio" - assert now_playing.station == "Disney (Children's) Radio" - assert now_playing.album == "Album" - assert now_playing.artist == "Artist" - assert ( - now_playing.image_url - == "http://cont-5.p-cdn.us/images/public/int/6/1/1/9/050087149116_500W_500H.jpg" - ) - assert now_playing.album_id == "123456" - assert now_playing.media_id == "4256592506324148495" - assert now_playing.queue_id == 1 - assert now_playing.source_id == 13 - assert now_playing.supported_controls == CONTROLS_ALL - assert len(now_playing.options) == 3 - option = now_playing.options[2] - assert option.id == 19 - assert option.name == "Add to HEOS Favorites" - assert option.context == "play" + assert player.now_playing_media == snapshot(name="current_state") # Attach dispatch handler signal = asyncio.Event() @@ -692,25 +640,7 @@ async def handler(player_id: int, event: str) -> None: await signal.wait() # Assert state changed command.assert_called() - assert now_playing.album == "I've Been Waiting (Single) (Explicit)" - assert now_playing.type == "station" - assert now_playing.album_id == "1" - assert now_playing.artist == "Lil Peep & ILoveMakonnen" - assert now_playing.image_url == "http://media/url" - assert now_playing.media_id == "2PxuY99Qty" - assert now_playing.queue_id == 1 - assert now_playing.source_id == 1 - assert now_playing.song == "I've Been Waiting (feat. Fall Out Boy)" - assert now_playing.station == "Today's Hits Radio" - assert now_playing.current_position is None - assert now_playing.current_position_updated is None - assert now_playing.duration is None - assert now_playing.supported_controls == CONTROLS_FORWARD_ONLY - assert len(now_playing.options) == 3 - option = now_playing.options[2] - assert option.id == 20 - assert option.name == "Remove from HEOS Favorites" - assert option.context == "play" + assert player.now_playing_media == snapshot(name="changed_state") @calls_player_commands() @@ -1004,11 +934,14 @@ async def handler(event: str, data: dict[str, Any]) -> None: assert heos.music_sources[MUSIC_SOURCE_TUNEIN].available +@calls_player_commands() @calls_group_commands() async def test_groups_changed_event(mock_device: MockHeosDevice, heos: Heos) -> None: """Test groups changed fires dispatcher.""" groups = await heos.get_groups() + players = await heos.get_players() assert len(groups) == 1 + assert all(player.group_id is not None for player in players.values()) signal = asyncio.Event() async def handler(event: str, data: dict[str, Any]) -> None: @@ -1018,14 +951,47 @@ async def handler(event: str, data: dict[str, Any]) -> None: heos.dispatcher.connect(SignalType.CONTROLLER_EVENT, handler) # Write event through mock device - command = mock_device.register( + commands = [ + mock_device.register( + c.COMMAND_GET_GROUPS, None, "group.get_groups_changed", replace=True + ), + mock_device.register( + c.COMMAND_GET_PLAYERS, None, "player.get_players_no_groups", replace=True + ), + ] + await mock_device.write_event("event.groups_changed") + + # Wait until the signal is set + await signal.wait() + map(lambda c: c.assert_called(), commands) + assert not await heos.get_groups() + assert all(player.group_id is None for player in players.values()) + + +@calls_group_commands() +async def test_groups_changed_event_players_not_loaded( + mock_device: MockHeosDevice, heos: Heos +) -> None: + """Test groups changed fires dispatcher and does not load players.""" + groups = await heos.get_groups() + assert len(groups) == 1 + signal = asyncio.Event() + + async def handler(event: str, data: dict[str, Any]) -> None: + assert event == EVENT_GROUPS_CHANGED + signal.set() + + heos.dispatcher.connect(SignalType.CONTROLLER_EVENT, handler) + + # Write event through mock device + get_groups_command = mock_device.register( c.COMMAND_GET_GROUPS, None, "group.get_groups_changed", replace=True ) await mock_device.write_event("event.groups_changed") # Wait until the signal is set await signal.wait() - command.assert_called() + get_groups_command.assert_called() assert not await heos.get_groups() @@ -1130,15 +1096,11 @@ async def handler(event: str, data: dict[str, Any]) -> None: {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}, ) async def test_browse_media_music_source( - heos: Heos, - media_music_source: MediaMusicSource, + heos: Heos, media_music_source: MediaMusicSource, snapshot: SnapshotAssertion ) -> None: """Test browse with an unavailable MediaMusicSource raises.""" result = await heos.browse_media(media_music_source) - assert result.source_id == MUSIC_SOURCE_FAVORITES - assert result.returned == 3 - assert result.count == 3 - assert len(result.items) == 3 + assert result == snapshot async def test_browse_media_music_source_unavailable_rasises( @@ -1158,15 +1120,12 @@ async def test_browse_media_music_source_unavailable_rasises( c.ATTR_RANGE: "0,13", }, ) -async def test_browse_media_item(heos: Heos, media_item_album: MediaItem) -> None: +async def test_browse_media_item( + heos: Heos, media_item_album: MediaItem, snapshot: SnapshotAssertion +) -> None: """Test browse with an not browsable MediaItem raises.""" result = await heos.browse_media(media_item_album, 0, 13) - - assert result.source_id == media_item_album.source_id - assert result.container_id == media_item_album.container_id - assert result.count == 14 - assert result.returned == 14 - assert len(result.items) == 14 + assert result == snapshot async def test_browse_media_item_not_browsable_raises( @@ -1262,19 +1221,10 @@ async def test_play_media_station_missing_media_id_raises( @calls_command("browse.get_music_sources", {}) -async def test_get_music_sources(heos: Heos) -> None: +async def test_get_music_sources(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test the heos connect method.""" sources = await heos.get_music_sources() - assert len(sources) == 15 - pandora = sources[MUSIC_SOURCE_PANDORA] - assert pandora.source_id == MUSIC_SOURCE_PANDORA - assert ( - pandora.image_url - == "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png" - ) - assert pandora.type == MediaType.MUSIC_SERVICE - assert pandora.available - assert pandora.service_username == "test@test.com" + assert sources == snapshot @calls_commands( @@ -1285,53 +1235,30 @@ async def test_get_music_sources(heos: Heos) -> None: CallCommand("browse.browse_theater_receiver", {c.ATTR_SOURCE_ID: 546978854}), CallCommand("browse.browse_heos_drive", {c.ATTR_SOURCE_ID: -263109739}), ) -async def test_get_input_sources(heos: Heos) -> None: +async def test_get_input_sources(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test the get input sources method.""" sources = await heos.get_input_sources() - assert len(sources) == 18 - source = sources[0] - assert source.playable - assert source.type == MediaType.STATION - assert source.name == "Theater Receiver - CBL/SAT" - assert source.media_id == INPUT_CABLE_SAT - assert source.source_id == 546978854 + assert sources == snapshot @calls_command( "browse.browse_favorites", {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}, ) -async def test_get_favorites(heos: Heos) -> None: +async def test_get_favorites(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test the get favorites method.""" sources = await heos.get_favorites() - assert len(sources) == 3 - assert sorted(sources.keys()) == [1, 2, 3] - fav = sources[1] - assert fav.playable - assert fav.name == "Thumbprint Radio" - assert fav.media_id == "3790855220637622543" - assert ( - fav.image_url - == "http://mediaserver-cont-ch1-1-v4v6.pandora.com/images/public/devicead/t/r/a/m/daartpralbumart_500W_500H.jpg" - ) - assert fav.type == MediaType.STATION + assert sources == snapshot @calls_command( "browse.browse_playlists", {c.ATTR_SOURCE_ID: MUSIC_SOURCE_PLAYLISTS}, ) -async def test_get_playlists(heos: Heos) -> None: +async def test_get_playlists(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test the get playlists method.""" sources = await heos.get_playlists() - assert len(sources) == 1 - playlist = sources[0] - assert playlist.playable - assert playlist.container_id == "171566" - assert playlist.name == "Rockin Songs" - assert playlist.image_url == "" - assert playlist.type == MediaType.PLAYLIST - assert playlist.source_id == MUSIC_SOURCE_PLAYLISTS + assert sources == snapshot @calls_command( @@ -1414,18 +1341,10 @@ async def test_sign_in_updates_credential( @calls_group_commands() -async def test_get_groups(heos: Heos) -> None: +async def test_get_groups(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test the get groups method.""" groups = await heos.get_groups() - assert len(groups) == 1 - group = groups[1] - assert group.name == "Back Patio + Front Porch" - assert group.group_id == 1 - assert group.lead_player_id == 1 - assert len(group.member_player_ids) == 1 - assert group.member_player_ids[0] == 2 - assert group.volume == 42 - assert not group.is_muted + assert groups == snapshot @calls_commands( @@ -1433,15 +1352,10 @@ async def test_get_groups(heos: Heos) -> None: CallCommand("group.get_volume", {c.ATTR_GROUP_ID: -263109739}), CallCommand("group.get_mute", {c.ATTR_GROUP_ID: -263109739}), ) -async def test_get_group_info_by_id(heos: Heos) -> None: +async def test_get_group_info_by_id(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test retrieving group info by group id.""" group = await heos.get_group_info(-263109739) - assert group.name == "Zone 1 + Zone 2" - assert group.group_id == -263109739 - assert group.lead_player_id == -263109739 - assert group.member_player_ids == [845195621] - assert group.volume == 42 - assert not group.is_muted + assert group == snapshot @calls_group_commands() @@ -1459,19 +1373,16 @@ async def test_get_group_info_by_id_already_loaded(heos: Heos) -> None: CallCommand("group.get_volume", {c.ATTR_GROUP_ID: -263109739}), CallCommand("group.get_mute", {c.ATTR_GROUP_ID: -263109739}), ) -async def test_get_group_info_by_id_already_loaded_refresh(heos: Heos) -> None: +async def test_get_group_info_by_id_already_loaded_refresh( + heos: Heos, snapshot: SnapshotAssertion +) -> None: """Test retrieving group info by group id for already loaded group updates.""" groups = await heos.get_groups() original_group = groups[1] group = await heos.get_group_info(1, refresh=True) - assert original_group == group - assert group.name == "Zone 1 + Zone 2" - assert group.group_id == -263109739 - assert group.lead_player_id == -263109739 - assert group.member_player_ids == [845195621] - assert group.volume == 42 - assert not group.is_muted + assert original_group is group + assert group == snapshot @pytest.mark.parametrize( @@ -1513,24 +1424,10 @@ async def test_update_group(heos: Heos) -> None: @calls_command("player.get_now_playing_media", {c.ATTR_PLAYER_ID: 1}) -async def test_get_now_playing_media(heos: Heos) -> None: +async def test_get_now_playing_media(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test removing a group.""" media = await heos.get_now_playing_media(1) - - assert media.type == "station" - assert media.song == "Disney (Children's) Radio" - assert media.station == "Disney (Children's) Radio" - assert media.album == "Album" - assert media.artist == "Artist" - assert ( - media.image_url - == "http://cont-5.p-cdn.us/images/public/int/6/1/1/9/050087149116_500W_500H.jpg" - ) - assert media.album_id == "123456" - assert media.media_id == "4256592506324148495" - assert media.queue_id == 1 - assert media.source_id == 13 - assert media.supported_controls == CONTROLS_ALL + assert media == snapshot @calls_command("system.heart_beat") diff --git a/tests/test_heos_browse.py b/tests/test_heos_browse.py index 1e62d3e..c53e0db 100644 --- a/tests/test_heos_browse.py +++ b/tests/test_heos_browse.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from pyheos import command as c from pyheos.const import ( @@ -25,26 +26,18 @@ SERVICE_OPTION_THUMBS_DOWN, SERVICE_OPTION_THUMBS_UP, ) -from pyheos.heos import Heos, HeosOptions +from pyheos.heos import Heos from pyheos.media import MediaMusicSource -from pyheos.types import MediaType +from pyheos.options import HeosOptions from tests import calls_command, value from tests.common import MediaMusicSources @calls_command("browse.get_source_info", {c.ATTR_SOURCE_ID: 123456}) -async def test_get_music_source_by_id(heos: Heos) -> None: +async def test_get_music_source_by_id(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test retrieving music source by id.""" source = await heos.get_music_source_info(123456) - assert source.source_id == 1 - assert source.name == "Pandora" - assert ( - source.image_url - == "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png" - ) - assert source.type == MediaType.MUSIC_SERVICE - assert source.available - assert source.service_username == "email@email.com" + assert source == snapshot @calls_command("browse.get_music_sources") @@ -94,16 +87,10 @@ async def test_get_music_source_info_invalid_parameters_raises( @calls_command("browse.get_search_criteria", {c.ATTR_SOURCE_ID: MUSIC_SOURCE_TIDAL}) -async def test_get_search_criteria(heos: Heos) -> None: +async def test_get_search_criteria(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test retrieving search criteria.""" criteria = await heos.get_search_criteria(MUSIC_SOURCE_TIDAL) - assert len(criteria) == 4 - item = criteria[2] - assert item.name == "Track" - assert item.criteria_id == 3 - assert item.wildcard is False - assert item.container_id == "SEARCHED_TRACKS-" - assert item.playable is True + assert criteria == snapshot @calls_command( @@ -114,17 +101,10 @@ async def test_get_search_criteria(heos: Heos) -> None: c.ATTR_SEARCH: "Tangerine Rays", }, ) -async def test_search(heos: Heos) -> None: +async def test_search(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test the search method.""" - result = await heos.search(MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3) - - assert result.source_id == MUSIC_SOURCE_TIDAL - assert result.criteria_id == 3 - assert result.search == "Tangerine Rays" - assert result.returned == 15 - assert result.count == 15 - assert len(result.items) == 15 + assert result == snapshot @pytest.mark.parametrize( @@ -221,23 +201,10 @@ async def test_delete_playlist(heos: Heos) -> None: c.ATTR_CONTAINER_ID: 123456, }, ) -async def test_retrieve_metadata(heos: Heos) -> None: +async def test_retrieve_metadata(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test deleting a playlist.""" result = await heos.retrieve_metadata(MUSIC_SOURCE_NAPSTER, "123456") - assert result.source_id == MUSIC_SOURCE_NAPSTER - assert result.container_id == "123456" - assert result.returned == 1 - assert result.count == 1 - assert len(result.metadata) == 1 - metadata = result.metadata[0] - assert metadata.album_id == "7890" - assert len(metadata.images) == 2 - image = metadata.images[0] - assert ( - image.image_url - == "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg" - ) - assert image.width == 640 + assert result == snapshot @calls_command( @@ -713,22 +680,14 @@ async def test_set_sevice_option_invalid_add_favorite_raises( c.ATTR_SEARCH_CRITERIA_ID: "0,1,2,3", }, ) -async def test_multi_search(heos: Heos) -> None: +async def test_multi_search(heos: Heos, snapshot: SnapshotAssertion) -> None: """Test the multi-search c.""" result = await heos.multi_search( "Tangerine Rays", [1, 4, 8, 13, 10], [0, 1, 2, 3], ) - - assert result.search == "Tangerine Rays" - assert result.source_ids == [1, 4, 8, 13, 10] - assert result.criteria_ids == [0, 1, 2, 3] - assert result.returned == 74 - assert result.count == 74 - assert len(result.items) == 74 - assert len(result.statistics) == 4 - assert len(result.errors) == 2 + assert result == snapshot async def test_multi_search_invalid_search_rasis() -> None: diff --git a/tests/test_heos_callback.py b/tests/test_heos_callback.py index 6bd459c..0324d48 100644 --- a/tests/test_heos_callback.py +++ b/tests/test_heos_callback.py @@ -2,7 +2,8 @@ from typing import Any -from pyheos.heos import Heos, HeosOptions +from pyheos.heos import Heos +from pyheos.options import HeosOptions from pyheos.types import SignalHeosEvent, SignalType diff --git a/tests/test_media.py b/tests/test_media.py index 4e96dc7..a02db1c 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -4,6 +4,7 @@ from unittest.mock import Mock import pytest +from syrupy.assertion import SnapshotAssertion from pyheos import command as c from pyheos.const import MUSIC_SOURCE_FAVORITES @@ -15,7 +16,7 @@ from tests.common import MediaItems, MediaMusicSources -async def test_media_music_source_from_data() -> None: +async def test_media_music_source_from_data(snapshot: SnapshotAssertion) -> None: """Test creating a media music source from data.""" data = { c.ATTR_NAME: "Pandora", @@ -27,13 +28,8 @@ async def test_media_music_source_from_data() -> None: } source = MediaMusicSource.from_data(data) + assert source == snapshot - assert source.name == data[c.ATTR_NAME] - assert source.image_url == data[c.ATTR_IMAGE_URL] - assert source.type == MediaType.MUSIC_SERVICE - assert source.source_id == data[c.ATTR_SOURCE_ID] - assert source.available - assert source.service_username == data[c.ATTR_SERVICE_USER_NAME] with pytest.raises( AssertionError, match="Heos instance not set", @@ -43,24 +39,14 @@ async def test_media_music_source_from_data() -> None: @calls_command("browse.browse_favorites", {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}) async def test_media_music_source_browse( - media_music_source: MediaMusicSource, + media_music_source: MediaMusicSource, snapshot: SnapshotAssertion ) -> None: """Test browsing a media music source.""" result = await media_music_source.browse() + assert result == snapshot - assert result.returned == 3 - assert result.source_id == MUSIC_SOURCE_FAVORITES - assert len(result.options) == 1 - option = result.options[0] - assert option.context == "browse" - assert option.name == "Remove from HEOS Favorites" - assert option.id == 20 - - # further testing of the result is done in test_browse_result_from_data - - -async def test_browse_result_from_data() -> None: +async def test_browse_result_from_data(snapshot: SnapshotAssertion) -> None: """Test creating a browse result from data.""" heos = Mock(Heos) message = HeosMessage( @@ -84,17 +70,10 @@ async def test_browse_result_from_data() -> None: ) result = BrowseResult._from_message(message, heos) + assert result == snapshot - assert result.returned == 1 - assert result.count == 1 - assert result.source_id == 1025 - assert result.heos == heos - assert len(result.items) == 1 - item = result.items[0] - assert item.heos == heos - -async def test_media_item_from_data() -> None: +async def test_media_item_from_data(snapshot: SnapshotAssertion) -> None: """Test creating a MediaItem from data.""" source_id = 1 container_id = "My Music" @@ -111,18 +90,8 @@ async def test_media_item_from_data() -> None: } source = MediaItem.from_data(data, source_id, container_id) + assert source == snapshot - assert source.name == data[c.ATTR_NAME] - assert source.image_url == data[c.ATTR_IMAGE_URL] - assert source.type == MediaType.SONG - assert source.container_id == container_id - assert source.source_id == source_id - assert source.playable is True - assert source.browsable is False - assert source.album == data[c.ATTR_ALBUM] - assert source.artist == data[c.ATTR_ARTIST] - assert source.album_id == data[c.ATTR_ALBUM_ID] - assert source.media_id == data[c.ATTR_MEDIA_ID] with pytest.raises( AssertionError, match="Heos instance not set", @@ -152,7 +121,7 @@ async def test_media_item_from_data_source_id_not_present_raises() -> None: MediaItem.from_data(data) -async def test_media_item_from_data_source() -> None: +async def test_media_item_from_data_source(snapshot: SnapshotAssertion) -> None: """Test creating a MediaItem from data.""" data = { c.ATTR_NAME: "Plex Media Server", @@ -162,21 +131,10 @@ async def test_media_item_from_data_source() -> None: } source = MediaItem.from_data(data) - - assert source.name == data[c.ATTR_NAME] - assert source.image_url == data[c.ATTR_IMAGE_URL] - assert source.type == MediaType.HEOS_SERVER - assert source.source_id == data[c.ATTR_SOURCE_ID] - assert source.container_id is None - assert source.playable is False - assert source.browsable is True - assert source.album is None - assert source.artist is None - assert source.album_id is None - assert source.media_id is None + assert source == snapshot -async def test_media_item_from_data_container() -> None: +async def test_media_item_from_data_container(snapshot: SnapshotAssertion) -> None: """Test creating a MediaItem from data.""" source_id = 123456789 data = { @@ -188,50 +146,30 @@ async def test_media_item_from_data_container() -> None: } source = MediaItem.from_data(data, source_id) - - assert source.name == data[c.ATTR_NAME] - assert source.image_url == data[c.ATTR_IMAGE_URL] - assert source.type == MediaType.CONTAINER - assert source.container_id == data[c.ATTR_CONTAINER_ID] - assert source.source_id == source_id - assert source.playable is False - assert source.browsable is True - assert source.album is None - assert source.artist is None - assert source.album_id is None - assert source.media_id is None + assert source == snapshot @calls_command( "browse.browse_heos_drive", {c.ATTR_SOURCE_ID: MediaItems.DEVICE.source_id} ) -async def test_media_item_browse(media_item_device: MediaItem) -> None: +async def test_media_item_browse( + media_item_device: MediaItem, snapshot: SnapshotAssertion +) -> None: """Test browsing a media music source.""" result = await media_item_device.browse() - - assert result.container_id is None - assert result.source_id == media_item_device.source_id - assert result.returned == 8 - assert result.count == 8 - assert len(result.items) == 8 + assert result == snapshot @calls_command( "browse.get_source_info", {c.ATTR_SOURCE_ID: MediaMusicSources.FAVORITES.source_id}, ) -async def test_refresh(media_music_source: MediaMusicSource) -> None: +async def test_refresh( + media_music_source: MediaMusicSource, snapshot: SnapshotAssertion +) -> None: """Test refresh updates the data.""" await media_music_source.refresh() - assert media_music_source.source_id == 1 - assert media_music_source.name == "Pandora" - assert ( - media_music_source.image_url - == "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png" - ) - assert media_music_source.type == MediaType.MUSIC_SERVICE - assert media_music_source.available - assert media_music_source.service_username == "email@email.com" + assert media_music_source == snapshot @calls_command( diff --git a/tests/test_player.py b/tests/test_player.py index fdd0000..76947bf 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -3,6 +3,7 @@ import re import pytest +from syrupy.assertion import SnapshotAssertion from pyheos import command as c from pyheos.const import ( @@ -14,26 +15,16 @@ ) from pyheos.media import MediaItem from pyheos.player import HeosPlayer -from pyheos.types import ( - AddCriteriaType, - LineOutLevelType, - NetworkType, - PlayState, - RepeatType, -) +from pyheos.types import AddCriteriaType, PlayState, RepeatType from tests import CallCommand, calls_command, calls_commands, value from tests.common import MediaItems @pytest.mark.parametrize( - ("network", "expected_network"), - [ - (None, NetworkType.UNKNOWN), - ("wired", NetworkType.WIRED), - ("invalid", NetworkType.UNKNOWN), # Invalid network type - ], + "network", + [None, "wired", "invalid"], ) -def test_from_data(network: str | None, expected_network: NetworkType) -> None: +def test_from_data(network: str | None, snapshot: SnapshotAssertion) -> None: """Test the from_data function.""" data = { c.ATTR_NAME: "Back Patio", @@ -46,18 +37,12 @@ def test_from_data(network: str | None, expected_network: NetworkType) -> None: c.ATTR_SERIAL: "1234567890", } player = HeosPlayer._from_data(data, None) - - assert player.name == "Back Patio" - assert player.player_id == 1 - assert player.model == "HEOS Drive" - assert player.version == "1.493.180" - assert player.ip_address == "192.168.0.1" - assert player.network == expected_network - assert player.line_out == LineOutLevelType.VARIABLE - assert player.serial == "1234567890" + assert player == snapshot -async def test_update_from_data(player: HeosPlayer) -> None: +async def test_update_from_data( + player: HeosPlayer, snapshot: SnapshotAssertion +) -> None: """Test the __str__ function.""" data = { c.ATTR_NAME: "Patio", @@ -70,15 +55,7 @@ async def test_update_from_data(player: HeosPlayer) -> None: c.ATTR_SERIAL: "0987654321", } player._update_from_data(data) - - assert player.name == "Patio" - assert player.player_id == 2 - assert player.model == "HEOS Drives" - assert player.version == "2.0.0" - assert player.ip_address == "192.168.0.2" - assert player.network == NetworkType.WIFI - assert player.line_out == LineOutLevelType.UNKNOWN - assert player.serial == "0987654321" + assert player == snapshot @pytest.mark.parametrize("state", (PlayState.PAUSE, PlayState.PLAY, PlayState.STOP)) @@ -227,22 +204,10 @@ async def test_clear_queue(player: HeosPlayer) -> None: @calls_command("player.get_queue", {c.ATTR_PLAYER_ID: 1}) -async def test_get_queue(player: HeosPlayer) -> None: +async def test_get_queue(player: HeosPlayer, snapshot: SnapshotAssertion) -> None: """Test the get queue c.""" result = await player.get_queue() - - assert len(result) == 11 - item = result[0] - assert item.song == "Baby" - assert item.album == "22 Break" - assert item.artist == "Oh Wonder" - assert ( - item.image_url - == "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg" - ) - assert item.queue_id == 1 - assert item.media_id == "199555606" - assert item.album_id == "199555605" + assert result == snapshot @calls_command("player.play_queue", {c.ATTR_PLAYER_ID: 1, c.ATTR_QUEUE_ID: 1}) @@ -288,22 +253,12 @@ async def test_move_queue_item(player: HeosPlayer) -> None: @calls_command("player.get_queue", {c.ATTR_PLAYER_ID: 1, c.ATTR_RANGE: "0,10"}) -async def test_get_queue_with_range(player: HeosPlayer) -> None: +async def test_get_queue_with_range( + player: HeosPlayer, snapshot: SnapshotAssertion +) -> None: """Test the check_update c.""" result = await player.get_queue(0, 10) - - assert len(result) == 11 - item = result[0] - assert item.song == "Baby" - assert item.album == "22 Break" - assert item.artist == "Oh Wonder" - assert ( - item.image_url - == "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg" - ) - assert item.queue_id == 1 - assert item.media_id == "199555606" - assert item.album_id == "199555605" + assert result == snapshot @calls_command( @@ -372,17 +327,12 @@ async def test_set_quick_select(player: HeosPlayer) -> None: @calls_command("player.get_quickselects", {c.ATTR_PLAYER_ID: 1}) -async def test_get_quick_selects(player: HeosPlayer) -> None: +async def test_get_quick_selects( + player: HeosPlayer, snapshot: SnapshotAssertion +) -> None: """Test the play favorite.""" selects = await player.get_quick_selects() - assert selects == { - 1: "Quick Select 1", - 2: "Quick Select 2", - 3: "Quick Select 3", - 4: "Quick Select 4", - 5: "Quick Select 5", - 6: "Quick Select 6", - } + assert selects == snapshot async def test_play_media_unplayable_source( @@ -463,19 +413,12 @@ async def test_add_search_to_queue(player: HeosPlayer) -> None: @calls_command("player.get_now_playing_media_blank", {c.ATTR_PLAYER_ID: 1}) -async def test_now_playing_media_unavailable(player: HeosPlayer) -> None: +async def test_now_playing_media_unavailable( + player: HeosPlayer, snapshot: SnapshotAssertion +) -> None: """Test edge case where now_playing_media returns an empty payload.""" await player.refresh_now_playing_media() - assert player.now_playing_media.supported_controls == [] - assert player.now_playing_media.type is None - assert player.now_playing_media.song is None - assert player.now_playing_media.station is None - assert player.now_playing_media.album is None - assert player.now_playing_media.artist is None - assert player.now_playing_media.image_url is None - assert player.now_playing_media.album_id is None - assert player.now_playing_media.media_id is None - assert player.now_playing_media.options == [] + assert player.now_playing_media == snapshot @calls_commands( @@ -486,16 +429,10 @@ async def test_now_playing_media_unavailable(player: HeosPlayer) -> None: CallCommand("player.get_mute", {c.ATTR_PLAYER_ID: -263109739}), CallCommand("player.get_play_mode", {c.ATTR_PLAYER_ID: -263109739}), ) -async def test_refresh(player: HeosPlayer) -> None: +async def test_refresh(player: HeosPlayer, snapshot: SnapshotAssertion) -> None: """Test refresh, including base, updates the correct information.""" await player.refresh() - - assert player.name == "Zone 1" - assert player.player_id == -263109739 - assert player.model == "HEOS Drive" - assert player.version == "3.34.620" - assert player.ip_address == "127.0.0.1" - assert player.serial == "123456789" + assert player == snapshot @calls_commands(