From b4103f5bf8f430b5a96da40b1b03cfa32cd02549 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 7 Jan 2025 20:04:35 -0600 Subject: [PATCH] Add Player Queue Commands (#65) * Add Get Queue * Add play queue * add remove_from_queue * add save queue * Add move queue item * Test save playlsit name too long --- pyheos/command/__init__.py | 5 ++ pyheos/command/player.py | 78 +++++++++++++++++-- pyheos/const.py | 2 + pyheos/heos.py | 65 ++++++++++++++-- pyheos/media.py | 26 +++++++ pyheos/player.py | 37 ++++++++- tests/fixtures/player.get_queue.json | 1 + tests/fixtures/player.move_queue_item.json | 1 + tests/fixtures/player.play_queue.json | 1 + tests/fixtures/player.remove_from_queue.json | 1 + tests/fixtures/player.save_queue.json | 1 + tests/test_player.py | 79 ++++++++++++++++++++ 12 files changed, 280 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/player.get_queue.json create mode 100644 tests/fixtures/player.move_queue_item.json create mode 100644 tests/fixtures/player.play_queue.json create mode 100644 tests/fixtures/player.remove_from_queue.json create mode 100644 tests/fixtures/player.save_queue.json diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index dee7224..a2271de 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -25,7 +25,12 @@ COMMAND_TOGGLE_MUTE: Final = "player/toggle_mute" COMMAND_GET_PLAY_MODE: Final = "player/get_play_mode" COMMAND_SET_PLAY_MODE: Final = "player/set_play_mode" +COMMAND_GET_QUEUE: Final = "player/get_queue" +COMMAND_REMOVE_FROM_QUEUE: Final = "player/remove_from_queue" COMMAND_CLEAR_QUEUE: Final = "player/clear_queue" +COMMAND_PLAY_QUEUE: Final = "player/play_queue" +COMMAND_SAVE_QUEUE: Final = "player/save_queue" +COMMAND_MOVE_QUEUE_ITEM: Final = "player/move_queue_item" COMMAND_PLAY_NEXT: Final = "player/play_next" COMMAND_PLAY_PREVIOUS: Final = "player/play_previous" COMMAND_PLAY_QUICK_SELECT: Final = "player/play_quickselect" diff --git a/pyheos/command/player.py b/pyheos/command/player.py index 8865c27..14e5d7c 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -2,15 +2,10 @@ Define the player command module. This module creates HEOS player commands. - -Commands not currently implemented: - 4.2.15 Get Queue - 4.2.16 Play Queue Item - 4.2.17 Remove Item(s) from Queue - 4.2.18 Save Queue as Playlist - 4.2.20 Move Queue """ +from typing import Any + from pyheos import command, const from pyheos.message import HeosCommand @@ -177,6 +172,58 @@ def set_play_mode( }, ) + @staticmethod + def get_queue( + player_id: int, range_start: int | None = None, range_end: int | None = None + ) -> HeosCommand: + """Get the queue for the current player. + + References: + 4.2.15 Get Queue + """ + params: dict[str, Any] = {const.ATTR_PLAYER_ID: player_id} + if isinstance(range_start, int) and isinstance(range_end, int): + params[const.ATTR_RANGE] = f"{range_start},{range_end}" + return HeosCommand(command.COMMAND_GET_QUEUE, params) + + @staticmethod + def play_queue(player_id: int, queue_id: int) -> HeosCommand: + """Play a queue item. + + References: + 4.2.16 Play Queue Item""" + return HeosCommand( + command.COMMAND_PLAY_QUEUE, + {const.ATTR_PLAYER_ID: player_id, const.ATTR_QUEUE_ID: queue_id}, + ) + + @staticmethod + def remove_from_queue(player_id: int, queue_ids: list[int]) -> HeosCommand: + """Remove an item from the queue. + + References: + 4.2.17 Remove Item(s) from Queue""" + return HeosCommand( + command.COMMAND_REMOVE_FROM_QUEUE, + { + const.ATTR_PLAYER_ID: player_id, + const.ATTR_QUEUE_ID: ",".join(map(str, queue_ids)), + }, + ) + + @staticmethod + def save_queue(player_id: int, name: str) -> HeosCommand: + """Save the queue as a playlist. + + References: + 4.2.18 Save Queue as Playlist""" + if len(name) >= 128: + raise ValueError("'name' must be less than or equal to 128 characters") + return HeosCommand( + command.COMMAND_SAVE_QUEUE, + {const.ATTR_PLAYER_ID: player_id, const.ATTR_NAME: name}, + ) + @staticmethod def clear_queue(player_id: int) -> HeosCommand: """Clear the queue. @@ -187,6 +234,23 @@ def clear_queue(player_id: int) -> HeosCommand: command.COMMAND_CLEAR_QUEUE, {const.ATTR_PLAYER_ID: player_id} ) + @staticmethod + def move_queue_item( + player_id: int, source_queue_ids: list[int], destination_queue_id: int + ) -> HeosCommand: + """Move one or more items in the queue. + + References: + 4.2.20 Move Queue""" + return HeosCommand( + command.COMMAND_MOVE_QUEUE_ITEM, + { + const.ATTR_PLAYER_ID: player_id, + const.ATTR_SOURCE_QUEUE_ID: ",".join(map(str, source_queue_ids)), + const.ATTR_DESTINATION_QUEUE_ID: destination_queue_id, + }, + ) + @staticmethod def play_next(player_id: int) -> HeosCommand: """Play next. diff --git a/pyheos/const.py b/pyheos/const.py index d867053..72c0f83 100644 --- a/pyheos/const.py +++ b/pyheos/const.py @@ -19,6 +19,7 @@ ATTR_CONTAINER_ID: Final = "cid" ATTR_COUNT: Final = "count" ATTR_CURRENT_POSITION: Final = "cur_pos" +ATTR_DESTINATION_QUEUE_ID: Final = "dqid" ATTR_DURATION: Final = "duration" ATTR_ENABLE: Final = "enable" ATTR_ERROR: Final = "error" @@ -56,6 +57,7 @@ ATTR_SONG: Final = "song" ATTR_SOURCE_ID: Final = "sid" ATTR_SOURCE_PLAYER_ID: Final = "spid" +ATTR_SOURCE_QUEUE_ID: Final = "sqid" ATTR_SIGNED_OUT: Final = "signed_out" ATTR_SIGNED_IN: Final = "signed_in" ATTR_STATE: Final = "state" diff --git a/pyheos/heos.py b/pyheos/heos.py index 314f2ef..fdfd22b 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -20,11 +20,7 @@ callback_wrapper, ) from pyheos.error import CommandError, CommandFailedError -from pyheos.media import ( - BrowseResult, - MediaItem, - MediaMusicSource, -) +from pyheos.media import BrowseResult, MediaItem, MediaMusicSource, QueueItem from pyheos.message import HeosMessage from pyheos.system import HeosHost, HeosSystem @@ -717,6 +713,48 @@ async def player_set_play_mode( PlayerCommands.set_play_mode(player_id, repeat, shuffle) ) + async def player_get_queue( + self, + player_id: int, + range_start: int | None = None, + range_end: int | None = None, + ) -> list[QueueItem]: + """Get the queue for the current player. + + References: + 4.2.15 Get Queue + """ + result = await self._connection.command( + PlayerCommands.get_queue(player_id, range_start, range_end) + ) + payload = cast(list[dict[str, str]], result.payload) + return [QueueItem.from_data(data) for data in payload] + + async def player_play_queue(self, player_id: int, queue_id: int) -> None: + """Play a queue item. + + References: + 4.2.16 Play Queue Item""" + await self._connection.command(PlayerCommands.play_queue(player_id, queue_id)) + + async def player_remove_from_queue( + self, player_id: int, queue_ids: list[int] + ) -> None: + """Remove an item from the queue. + + References: + 4.2.17 Remove Item(s) from Queue""" + await self._connection.command( + PlayerCommands.remove_from_queue(player_id, queue_ids) + ) + + async def player_save_queue(self, player_id: int, name: str) -> None: + """Save the queue as a playlist. + + References: + 4.2.18 Save Queue as Playlist""" + await self._connection.command(PlayerCommands.save_queue(player_id, name)) + async def player_clear_queue(self, player_id: int) -> None: """Clear the queue. @@ -724,6 +762,19 @@ async def player_clear_queue(self, player_id: int) -> None: 4.2.19 Clear Queue""" await self._connection.command(PlayerCommands.clear_queue(player_id)) + async def player_move_queue_item( + self, player_id: int, source_queue_ids: list[int], destination_queue_id: int + ) -> None: + """Move one or more items in the queue. + + References: + 4.2.20 Move Queue""" + await self._connection.command( + PlayerCommands.move_queue_item( + player_id, source_queue_ids, destination_queue_id + ) + ) + async def player_play_next(self, player_id: int) -> None: """Play next. @@ -760,7 +811,7 @@ async def player_play_quick_select( PlayerCommands.play_quick_select(player_id, quick_select_id) ) - async def get_player_quick_selects(self, player_id: int) -> dict[int, str]: + async def player_get_quick_selects(self, player_id: int) -> dict[int, str]: """Get quick selects. References: @@ -773,7 +824,7 @@ async def get_player_quick_selects(self, player_id: int) -> dict[int, str]: for data in cast(list[dict], result.payload) } - async def check_update(self, player_id: int) -> bool: + async def player_check_update(self, player_id: int) -> bool: """Check for a firmware update. Args: diff --git a/pyheos/media.py b/pyheos/media.py index 5ea4a3f..9b2dd4b 100644 --- a/pyheos/media.py +++ b/pyheos/media.py @@ -11,6 +11,32 @@ from . import Heos +@dataclass +class QueueItem: + """Define an item in the queue.""" + + queue_id: int + song: str + album: str + artist: str + image_url: str + media_id: str + album_id: str + + @classmethod + def from_data(cls, data: dict[str, str]) -> "QueueItem": + """Create a new instance from the provided data.""" + return cls( + queue_id=int(data[const.ATTR_QUEUE_ID]), + song=data[const.ATTR_SONG], + album=data[const.ATTR_ALBUM], + artist=data[const.ATTR_ARTIST], + image_url=data[const.ATTR_IMAGE_URL], + media_id=data[const.ATTR_MEDIA_ID], + album_id=data[const.ATTR_ALBUM_ID], + ) + + @dataclass(init=False) class Media: """ diff --git a/pyheos/player.py b/pyheos/player.py index bc499cd..1f0fe29 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Optional, cast from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper -from pyheos.media import MediaItem +from pyheos.media import MediaItem, QueueItem from pyheos.message import HeosMessage from . import const @@ -317,11 +317,42 @@ async def set_play_mode(self, repeat: const.RepeatType, shuffle: bool) -> None: assert self.heos, "Heos instance not set" await self.heos.player_set_play_mode(self.player_id, repeat, shuffle) + async def get_queue( + self, range_start: int | None = None, range_end: int | None = None + ) -> list[QueueItem]: + """Get the queue of the player.""" + assert self.heos, "Heos instance not set" + return await self.heos.player_get_queue(self.player_id, range_start, range_end) + + async def play_queue(self, queue_id: int) -> None: + """Play the queue of the player.""" + assert self.heos, "Heos instance not set" + await self.heos.player_play_queue(self.player_id, queue_id) + + async def remove_from_queue(self, queue_ids: list[int]) -> None: + """Remove the specified queue items from the queue.""" + assert self.heos, "Heos instance not set" + await self.heos.player_remove_from_queue(self.player_id, queue_ids) + async def clear_queue(self) -> None: """Clear the queue of the player.""" assert self.heos, "Heos instance not set" await self.heos.player_clear_queue(self.player_id) + async def save_queue(self, name: str) -> None: + """Save the queue as a playlist.""" + assert self.heos, "Heos instance not set" + await self.heos.player_save_queue(self.player_id, name) + + async def move_queue_item( + self, source_queue_ids: list[int], destination_queue_id: int + ) -> None: + """Move one or more items in the queue.""" + assert self.heos, "Heos instance not set" + await self.heos.player_move_queue_item( + self.player_id, source_queue_ids, destination_queue_id + ) + async def play_next(self) -> None: """Clear the queue of the player.""" assert self.heos, "Heos instance not set" @@ -389,7 +420,7 @@ async def set_quick_select(self, quick_select_id: int) -> None: async def get_quick_selects(self) -> dict[int, str]: """Get a list of quick selects.""" assert self.heos, "Heos instance not set" - return await self.heos.get_player_quick_selects(self.player_id) + return await self.heos.player_get_quick_selects(self.player_id) async def check_update(self) -> bool: """Check for a firmware update. @@ -397,4 +428,4 @@ async def check_update(self) -> bool: Returns: True if an update is available, otherwise False.""" assert self.heos, "Heos instance not set" - return await self.heos.check_update(self.player_id) + return await self.heos.player_check_update(self.player_id) diff --git a/tests/fixtures/player.get_queue.json b/tests/fixtures/player.get_queue.json new file mode 100644 index 0000000..ef33e22 --- /dev/null +++ b/tests/fixtures/player.get_queue.json @@ -0,0 +1 @@ +{"heos": {"command": "player/get_queue", "result": "success", "message": "pid={player_id}&returned=11&count=11"}, "payload": [{"song": "Baby", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 1, "mid": "199555606", "album_id": "199555605"}, {"song": "Down", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 2, "mid": "199555607", "album_id": "199555605"}, {"song": "22 Break", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 3, "mid": "199555608", "album_id": "199555605"}, {"song": "Free", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 4, "mid": "199555609", "album_id": "199555605"}, {"song": "Don't Let The Neighbourhood Hear", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 5, "mid": "199555610", "album_id": "199555605"}, {"song": "Dinner", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 6, "mid": "199555611", "album_id": "199555605"}, {"song": "Rollercoaster Baby", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 7, "mid": "199555612", "album_id": "199555605"}, {"song": "Love Me Now", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 8, "mid": "199555613", "album_id": "199555605"}, {"song": "You > Me", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 9, "mid": "199555614", "album_id": "199555605"}, {"song": "Kicking The Doors Down", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 10, "mid": "199555615", "album_id": "199555605"}, {"song": "Twenty Fourteen", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 11, "mid": "199555616", "album_id": "199555605"}]} \ No newline at end of file diff --git a/tests/fixtures/player.move_queue_item.json b/tests/fixtures/player.move_queue_item.json new file mode 100644 index 0000000..0919e5c --- /dev/null +++ b/tests/fixtures/player.move_queue_item.json @@ -0,0 +1 @@ +{"heos": {"command": "player/move_queue_item", "result": "success", "message": "pid={player_id}&sqid=2,3,4&dqid=1"}} \ No newline at end of file diff --git a/tests/fixtures/player.play_queue.json b/tests/fixtures/player.play_queue.json new file mode 100644 index 0000000..820f5a2 --- /dev/null +++ b/tests/fixtures/player.play_queue.json @@ -0,0 +1 @@ +{"heos": {"command": "player/play_queue", "result": "success", "message": "pid={player_id}&qid=1"}} \ No newline at end of file diff --git a/tests/fixtures/player.remove_from_queue.json b/tests/fixtures/player.remove_from_queue.json new file mode 100644 index 0000000..9a4b222 --- /dev/null +++ b/tests/fixtures/player.remove_from_queue.json @@ -0,0 +1 @@ +{"heos": {"command": "player/remove_from_queue", "result": "success", "message": "pid={player_id}&qid=10"}} \ No newline at end of file diff --git a/tests/fixtures/player.save_queue.json b/tests/fixtures/player.save_queue.json new file mode 100644 index 0000000..246c9b8 --- /dev/null +++ b/tests/fixtures/player.save_queue.json @@ -0,0 +1 @@ +{"heos": {"command": "player/save_queue", "result": "success", "message": "pid={player_id}&name=Test"}} \ No newline at end of file diff --git a/tests/test_player.py b/tests/test_player.py index 814d5a7..992633f 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -207,6 +207,85 @@ async def test_clear_queue(player: HeosPlayer) -> None: await player.clear_queue() +@calls_command("player.get_queue", {const.ATTR_PLAYER_ID: 1}) +async def test_get_queue(player: HeosPlayer) -> None: + """Test the get queue command.""" + 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" + + +@calls_command("player.play_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_QUEUE_ID: 1}) +async def test_play_queue(player: HeosPlayer) -> None: + """Test the play_queue command.""" + await player.play_queue(1) + + +@calls_command( + "player.remove_from_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_QUEUE_ID: "1,2,3"} +) +async def test_remove_from_queue(player: HeosPlayer) -> None: + """Test the play_queue command.""" + await player.remove_from_queue([1, 2, 3]) + + +@calls_command("player.save_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_NAME: "Test"}) +async def test_save_queue(player: HeosPlayer) -> None: + """Test the save_queue command.""" + await player.save_queue("Test") + + +async def test_save_queue_too_long_raises(player: HeosPlayer) -> None: + """Test the save_queue command.""" + with pytest.raises( + ValueError, match="'name' must be less than or equal to 128 characters" + ): + await player.save_queue("S" * 129) + + +@calls_command( + "player.move_queue_item", + { + const.ATTR_PLAYER_ID: 1, + const.ATTR_SOURCE_QUEUE_ID: "2,3,4", + const.ATTR_DESTINATION_QUEUE_ID: 1, + }, +) +async def test_move_queue_item(player: HeosPlayer) -> None: + """Test the move_queue_item command.""" + await player.move_queue_item([2, 3, 4], 1) + + +@calls_command("player.get_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_RANGE: "0,10"}) +async def test_get_queue_with_range(player: HeosPlayer) -> None: + """Test the check_update command.""" + 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" + + @calls_command( "browse.play_input", {