diff --git a/src/knuckles/bookmarks.py b/src/knuckles/bookmarks.py index 46a03ff..cdebde1 100644 --- a/src/knuckles/bookmarks.py +++ b/src/knuckles/bookmarks.py @@ -2,6 +2,7 @@ from .api import Api from .models.bookmark import Bookmark +from .models.play_queue import PlayQueue if TYPE_CHECKING: from .subsonic import Subsonic @@ -65,7 +66,7 @@ def create_bookmark( "createBookmark", {"id": id, "position": position, "comment": comment} ) - # Fake the structure given by the songs in the API. + # Fake the song structure given by in the API. return Bookmark(self.subsonic, {"id": id}, position=position, comment=comment) def update_bookmark( @@ -73,7 +74,7 @@ def update_bookmark( ) -> Bookmark: """Method that internally calls the create_bookmark method as creating and updating a bookmark uses the same endpoint. Useful for having - more self descriptive code. + more self-descriptive code. :param id: The ID of the song of the bookmark. :type id: str @@ -99,3 +100,47 @@ def delete_bookmark(self, id: str) -> "Subsonic": self.api.request("deleteBookmark", {"id": id}) return self.subsonic + + def get_play_queue(self) -> PlayQueue: + """Calls the "getPlayQueue" endpoint of the API. + + :return: The play queue of the authenticated user. + :rtype: PlayQueue + """ + + response = self.api.request("getPlayQueue")["playQueue"] + + return PlayQueue(self.subsonic, **response) + + def save_play_queue( + self, + song_ids: list[str], + current_song_id: str | None = None, + position: int | None = None, + ) -> PlayQueue: + """Calls the "savePlayQueue" endpoint of the API. + + :param song_ids: A list with all the IDs of the songs to add. + :type song_ids: list[str] + :param current_song_id: The ID of the current song in the queue, + defaults to None. + :type current_song_id: str | None, optional + :param position: The position in seconds of the current song, + defaults to None. + :type position: int | None, optional + :return: The new saved play queue. + :rtype: PlayQueue + """ + + self.api.request( + "savePlayQueue", + {"id": song_ids, "current": current_song_id, "position": position}, + ) + + # TODO This approach is expensive, a better one is preferred + # Fake the song structure given by in the API. + songs = [] + for song_id in song_ids: + songs.append({"id": song_id}) + + return PlayQueue(self.subsonic, songs, current_song_id, position) diff --git a/src/knuckles/models/play_queue.py b/src/knuckles/models/play_queue.py new file mode 100644 index 0000000..9b9601a --- /dev/null +++ b/src/knuckles/models/play_queue.py @@ -0,0 +1,66 @@ +from typing import TYPE_CHECKING, Any, Self + +from dateutil import parser + +from .song import Song +from .user import User + +if TYPE_CHECKING: + from ..subsonic import Subsonic + + +class PlayQueue: + """Representation of all the data related to a play queue in Subsonic.""" + + def __init__( + self, + subsonic: "Subsonic", + entry: list[dict[str, Any]], + current: str | None = None, + position: int | None = None, + username: str | None = None, + changed: str | None = None, + changedBy: str | None = None, + ) -> None: + self.__subsonic = subsonic + self.current = Song(self.__subsonic, current) if current else None + self.position = position + self.user = User(username) if username else None + self.changed = parser.parse(changed) if changed else None + self.changed_by = changedBy + self.songs = ( + [Song(self.__subsonic, **song) for song in entry] if entry else None + ) + + def generate(self) -> "PlayQueue": + """Return a new play queue with all the data updated from the API, + using the endpoint that return the most information possible. + + Useful for making copies with updated data or updating the object itself + with immutability, e.g., foo = foo.generate(). + + :return: A new share object with all the data updated. + :rtype: PlayQueue + """ + + get_play_queue = self.__subsonic.bookmarks.get_play_queue() + + return get_play_queue + + def save(self) -> Self: + """Calls the "savePlayQueue" endpoint of the API. + + Saves the play queue using the parameters in the object. + + :return: _description_ + :rtype: Self + """ + + # TODO This should raise an exception? + song_ids: list[str] = [song.id for song in self.songs] if self.songs else [] + + self.__subsonic.bookmarks.save_play_queue( + song_ids, self.current.id if self.current else None, self.position + ) + + return self diff --git a/src/knuckles/models/playlist.py b/src/knuckles/models/playlist.py index 5e8a8da..260f154 100644 --- a/src/knuckles/models/playlist.py +++ b/src/knuckles/models/playlist.py @@ -103,7 +103,7 @@ def create(self) -> "Playlist": new_playlist = self.__subsonic.playlists.create_playlist( # Ignore the None type error as the server - # should return a Error Code 10 in response + # should return an Error Code 10 in response self.name, # type: ignore[arg-type] self.comment, self.public, diff --git a/tests/api/test_bookmarks.py b/tests/api/test_bookmarks.py index 58c0239..3ed4962 100644 --- a/tests/api/test_bookmarks.py +++ b/tests/api/test_bookmarks.py @@ -2,9 +2,10 @@ import responses from dateutil import parser -from knuckles import Subsonic from responses import Response +from knuckles import Subsonic + @responses.activate def test_get_bookmarks( @@ -77,3 +78,40 @@ def test_delete_bookmark( response = subsonic.bookmarks.delete_bookmark(song["id"]) assert type(response) == Subsonic + + +@responses.activate +def test_get_play_queue( + subsonic: Subsonic, + mock_get_play_queue: Response, + username: str, + song: dict[str, Any], + play_queue: dict[str, Any], + client: str, +) -> None: + responses.add(mock_get_play_queue) + + response = subsonic.bookmarks.get_play_queue() + + assert response.user.username == username + assert response.current.id == song["id"] + assert response.changed == parser.parse(play_queue["changed"]) + assert response.changed_by == client + assert type(response.songs) is list + assert response.songs[0].id == song["id"] + + +@responses.activate +def test_save_play_queue( + subsonic: Subsonic, + mock_save_play_queue: Response, + song: dict[str, Any], + play_queue: dict[str, Any], +) -> None: + responses.add(mock_save_play_queue) + + response = subsonic.bookmarks.save_play_queue( + [song["id"]], song["id"], play_queue["position"] + ) + + assert response.current.id == song["id"] diff --git a/tests/mocks/bookmarks.py b/tests/mocks/bookmarks.py index 1fbb177..4d501b4 100644 --- a/tests/mocks/bookmarks.py +++ b/tests/mocks/bookmarks.py @@ -41,6 +41,35 @@ def mock_create_bookmark( @pytest.fixture def mock_delete_bookmark( - mock_generator: MockGenerator, song: dict[str, Any], bookmark: dict[str, Any] + mock_generator: MockGenerator, song: dict[str, Any] ) -> Response: return mock_generator("deleteBookmark", {"id": song["id"]}) + + +@pytest.fixture +def play_queue(song: dict[str, Any], username: str, client: str) -> dict[str, Any]: + return { + "entry": [song], + "current": song["id"], + "position": 18283, + "username": username, + "changed": "2023-06-19T08:04:36.238963605Z", + "changedBy": client, + } + + +@pytest.fixture +def mock_get_play_queue( + mock_generator: MockGenerator, play_queue: dict[str, Any] +) -> Response: + return mock_generator("getPlayQueue", {}, {"playQueue": play_queue}) + + +@pytest.fixture +def mock_save_play_queue( + mock_generator: MockGenerator, song: dict[str, Any], play_queue: dict[str, Any] +) -> Response: + return mock_generator( + "savePlayQueue", + {"id": song["id"], "current": song["id"], "position": play_queue["position"]}, + ) diff --git a/tests/models/test_song.py b/tests/models/test_song.py index 42f3037..7da05e0 100644 --- a/tests/models/test_song.py +++ b/tests/models/test_song.py @@ -3,9 +3,9 @@ import pytest import responses -from knuckles import Song, Subsonic from responses import Response +from knuckles import Song, Subsonic from tests.conftest import MockGenerator @@ -17,7 +17,7 @@ def test_generate( ) -> None: responses.add(mock_get_song) - requested_song: Song = subsonic.browsing.get_song(song["id"]) + requested_song = subsonic.browsing.get_song(song["id"]) requested_song.title = "Foo" requested_song = requested_song.generate() @@ -34,7 +34,7 @@ def test_song_star( responses.add(mock_get_song) responses.add(mock_star_song) - requested_song: Song = subsonic.browsing.get_song(song["id"]) + requested_song = subsonic.browsing.get_song(song["id"]) assert type(requested_song.star()) is Song diff --git a/tests/models/test_song_queue.py b/tests/models/test_song_queue.py new file mode 100644 index 0000000..f103bf3 --- /dev/null +++ b/tests/models/test_song_queue.py @@ -0,0 +1,36 @@ +from typing import Any + +import responses +from knuckles import Subsonic +from knuckles.models.play_queue import PlayQueue +from responses import Response + + +@responses.activate +def test_generate( + subsonic: Subsonic, + mock_get_play_queue: Response, + username: dict[str, Any], +) -> None: + responses.add(mock_get_play_queue) + + requested_queue = subsonic.bookmarks.get_play_queue() + requested_queue.username = "Foo" + requested_queue = requested_queue.generate() + + assert requested_queue.user.username == username + + +@responses.activate +def test_save( + subsonic: Subsonic, + mock_get_play_queue: Response, + mock_save_play_queue: Response, +) -> None: + responses.add(mock_get_play_queue) + responses.add(mock_save_play_queue) + + requested_queue = subsonic.bookmarks.get_play_queue() + requested_queue = requested_queue.save() + + assert type(requested_queue) is PlayQueue diff --git a/tests/models/test_user.py b/tests/models/test_user.py index 6f23081..6aa9128 100644 --- a/tests/models/test_user.py +++ b/tests/models/test_user.py @@ -73,7 +73,7 @@ def test_user_change_password( response = subsonic.user_management.get_user(user["username"]) response = response.change_password(new_password) - assert type(response) == User + assert type(response) is User def test_user_without_api_access(user: dict[str, Any]) -> None: