diff --git a/docs/release-history.rst b/docs/release-history.rst index 5ab90711..d02e459f 100644 --- a/docs/release-history.rst +++ b/docs/release-history.rst @@ -58,6 +58,7 @@ Added * Added :py:meth:`.MusifyCollection.outer_difference` method to cover the logic previously handled by the mislabelled :py:meth:`.MusifyCollection.outer_difference` method * :py:class:`.RemoteDataWrangler` and its implementations now handle URL objects from the ``yarl`` package +* :py:meth:`.RemoteAPI.follow_playlist` method Changed ------- @@ -93,6 +94,8 @@ Changed when creating playlists to avoid creating many duplicate playlists which could have lead to playlist creation explosion in repeated uses. The processor also accounts for any items that may have existed in the playlist before it was run and discounts them from any matches. +* :py:class:`.RemoteItemChecker` also uses the new :py:meth:`.RemoteAPI.follow_playlist` method + when creating playlists to ensure that a user is following the playlists it creates to avoid 'ghost playlist' issue. * :py:meth:`.SpotifyAPI.create_playlist` now returns the full response rather than just the URL of the playlist. Fixed diff --git a/musify/api/request.py b/musify/api/request.py index 54e7f569..e8f8ddbf 100644 --- a/musify/api/request.py +++ b/musify/api/request.py @@ -147,7 +147,8 @@ async def request(self, method: str, url: str | URL, **kwargs) -> dict[str, Any] if response is None: raise APIError("No response received") - if response.ok and (data := await self._response_as_json(response)): + if response.ok: + data = await self._response_as_json(response) break await self._log_response(response=response, method=method, url=url) @@ -228,7 +229,7 @@ async def _log_response(self, response: ClientResponse, method: str, url: str | self.log( method=f"\33[91m{method.upper()}", url=url, - messages=[ + message=[ f"Status code: {response.status}", "Response text and headers follow:\n" f"Response text:\n\t{(await response.text()).replace("\n", "\n\t")}\n" diff --git a/musify/libraries/remote/core/api.py b/musify/libraries/remote/core/api.py index 2d7a7dae..f1c37a38 100644 --- a/musify/libraries/remote/core/api.py +++ b/musify/libraries/remote/core/api.py @@ -17,7 +17,7 @@ from musify.libraries.remote.core import RemoteResponse from musify.libraries.remote.core.enum import RemoteIDType, RemoteObjectType from musify.libraries.remote.core.processors.wrangle import RemoteDataWrangler -from musify.libraries.remote.core.types import APIInputValue +from musify.libraries.remote.core.types import APIInputValueSingle, APIInputValueMulti from musify.log.logger import MusifyLogger from musify.types import UnitSequence, JSON, UnitList from musify.utils import align_string, to_collection @@ -244,7 +244,7 @@ def print_item( @abstractmethod async def print_collection( self, - value: str | Mapping[str, Any] | RemoteResponse | None = None, + value: APIInputValueSingle[RemoteResponse] | None = None, kind: RemoteIDType | None = None, limit: int = 20, ) -> None: @@ -266,7 +266,7 @@ async def print_collection( raise NotImplementedError @abstractmethod - async def get_playlist_url(self, playlist: str | Mapping[str, Any] | RemoteResponse) -> str: + async def get_playlist_url(self, playlist: APIInputValueSingle[RemoteResponse]) -> URL: """ Determine the type of the given ``playlist`` and return its API URL. If type cannot be determined, attempt to find the playlist in the @@ -332,7 +332,7 @@ async def extend_items( @abstractmethod async def get_items( self, - values: APIInputValue, + values: APIInputValueMulti[RemoteResponse], kind: RemoteObjectType | None = None, limit: int = 50, extend: bool = True, @@ -367,7 +367,9 @@ async def get_items( raise NotImplementedError @abstractmethod - async def get_tracks(self, values: APIInputValue, limit: int = 50, *args, **kwargs) -> list[dict[str, Any]]: + async def get_tracks( + self, values: APIInputValueMulti[RemoteResponse], limit: int = 50, *args, **kwargs + ) -> list[dict[str, Any]]: """ Wrapper for :py:meth:`get_items` which only returns Track type responses. See :py:meth:`get_items` for more info. @@ -394,28 +396,6 @@ async def get_user_items( ########################################################################### ## Playlist specific endpoints ########################################################################### - async def get_or_create_playlist(self, name: str, *args, **kwargs) -> dict[str, Any]: - """ - Attempt to find the playlist with the given ``name`` and return it. - Otherwise, create a new playlist. - - Any given args and kwargs are passed directly onto :py:meth:`create_playlist` - when a matching loaded playlist is not found. - - When a playlist is created, persist the response back to the loaded playlists in this object. - - :param name: The case-sensitive name of the playlist to get/create. - :return: API JSON response for the loaded/created playlist. - """ - if not self.user_playlist_data: - await self.load_user_playlists() - - response = self.user_playlist_data.get(name) - if response: - return response - - return await self.create_playlist(name, *args, **kwargs) - @abstractmethod async def create_playlist(self, name: str, *args, **kwargs) -> dict[str, Any]: """ @@ -429,8 +409,8 @@ async def create_playlist(self, name: str, *args, **kwargs) -> dict[str, Any]: @abstractmethod async def add_to_playlist( self, - playlist: str | Mapping[str, Any] | RemoteResponse, - items: Collection[str], + playlist: APIInputValueSingle[RemoteResponse], + items: Sequence[str], limit: int = 50, skip_dupes: bool = True ) -> int: @@ -453,8 +433,43 @@ async def add_to_playlist( """ raise NotImplementedError + async def get_or_create_playlist(self, name: str, *args, **kwargs) -> dict[str, Any]: + """ + Attempt to find the playlist with the given ``name`` and return it. + Otherwise, create a new playlist. + + Any given args and kwargs are passed directly onto :py:meth:`create_playlist` + when a matching loaded playlist is not found. + + When a playlist is created, persist the response back to the loaded playlists in this object. + + :param name: The case-sensitive name of the playlist to get/create. + :return: API JSON response for the loaded/created playlist. + """ + if not self.user_playlist_data: + await self.load_user_playlists() + + response = self.user_playlist_data.get(name) + if response: + return response + + return await self.create_playlist(name, *args, **kwargs) + + async def follow_playlist(self, playlist: APIInputValueSingle[RemoteResponse], *args, **kwargs) -> URL: + """ + ``PUT`` - Follow a given playlist. + + :param playlist: One of the following to identify the playlist to clear: + - playlist URL/URI/ID, + - the name of the playlist in the current user's playlists, + - the API response of a playlist. + - a RemoteResponse object representing a remote playlist. + :return: API URL for playlist. + """ + raise NotImplementedError + @abstractmethod - async def delete_playlist(self, playlist: str | Mapping[str, Any] | RemoteResponse) -> str: + async def delete_playlist(self, playlist: APIInputValueSingle[RemoteResponse]) -> URL: """ ``DELETE`` - Unfollow/delete a given playlist. WARNING: This function will destructively modify your remote playlists. @@ -471,7 +486,7 @@ async def delete_playlist(self, playlist: str | Mapping[str, Any] | RemoteRespon @abstractmethod async def clear_from_playlist( self, - playlist: str | Mapping[str, Any] | RemoteResponse, + playlist: APIInputValueSingle[RemoteResponse], items: Collection[str] | None = None, limit: int = 100 ) -> int: diff --git a/musify/libraries/remote/core/base.py b/musify/libraries/remote/core/base.py index 667ab9ff..f4e39937 100644 --- a/musify/libraries/remote/core/base.py +++ b/musify/libraries/remote/core/base.py @@ -4,7 +4,6 @@ These define the foundations of any remote object or item. """ from abc import ABCMeta, abstractmethod -from collections.abc import Mapping from typing import Any, Self, AsyncContextManager from yarl import URL @@ -13,6 +12,7 @@ from musify.core.base import MusifyItem from musify.libraries.remote.core import RemoteResponse from musify.libraries.remote.core.api import RemoteAPI +from musify.libraries.remote.core.types import APIInputValueSingle class RemoteObject[T: (RemoteAPI | None)](RemoteResponse, AsyncContextManager, metaclass=ABCMeta): @@ -93,7 +93,7 @@ def _check_for_api(self) -> None: @classmethod @abstractmethod async def load( - cls, value: str | Mapping[str, Any] | RemoteResponse, api: RemoteAPI, *args, **kwargs + cls, value: APIInputValueSingle[RemoteResponse], api: RemoteAPI, *args, **kwargs ) -> Self: """ Generate a new object of this class, diff --git a/musify/libraries/remote/core/object.py b/musify/libraries/remote/core/object.py index a7c552d3..018c5eeb 100644 --- a/musify/libraries/remote/core/object.py +++ b/musify/libraries/remote/core/object.py @@ -6,10 +6,10 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from collections.abc import Iterable, Mapping +from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime -from typing import Self, Literal, Any +from typing import Self, Literal from musify.api.exception import APIError from musify.core.base import MusifyItem @@ -19,6 +19,7 @@ from musify.libraries.remote.core.api import RemoteAPI from musify.libraries.remote.core.base import RemoteObject, RemoteItem from musify.libraries.remote.core.exception import RemoteError +from musify.libraries.remote.core.types import APIInputValueSingle from musify.utils import get_most_common_values @@ -57,7 +58,7 @@ def _total(self) -> int: @classmethod @abstractmethod async def load( - cls, value: str | Mapping[str, Any] | Self, api: RemoteAPI, items: Iterable[T] = (), *args, **kwargs + cls, value: APIInputValueSingle[Self], api: RemoteAPI, items: Iterable[T] = (), *args, **kwargs ) -> Self: """ Generate a new object, calling all required endpoints to get a complete set of data for this item type. diff --git a/musify/libraries/remote/core/processors/check.py b/musify/libraries/remote/core/processors/check.py index d58a24d4..3b6596b1 100644 --- a/musify/libraries/remote/core/processors/check.py +++ b/musify/libraries/remote/core/processors/check.py @@ -77,6 +77,7 @@ class RemoteItemChecker(InputProcessor): "allow_karaoke", "_playlist_originals", "_playlist_check_collections", + "_started", "_skip", "_quit", "_remaining", @@ -121,6 +122,8 @@ def __init__( #: Map of playlist names to the collection of items added for check playlists self._playlist_check_collections: dict[str, MusifyCollection] = {} + #: Whether a check was started + self._started = False #: When true, skip the current loop and eventually safely quit check self._skip = False #: When true, safely quit check @@ -147,9 +150,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: async def _check_api(self) -> None: """Check if the API token has expired and refresh as necessary""" - if not await self.api.handler.authoriser.test_token(): # check if token has expired - self.logger.info_extra("\33[93mAPI token has expired, re-authorising... \33[0m") - await self.api.authorise() + await self.api.authorise() async def _create_playlist(self, collection: MusifyCollection[MusifyItemSettable]) -> None: """Create a temporary playlist, store its URL for later unfollowing, and add all given URIs.""" @@ -160,6 +161,7 @@ async def _create_playlist(self, collection: MusifyCollection[MusifyItemSettable return response = await self.api.get_or_create_playlist(collection.name, public=False) + await self.api.follow_playlist(response) await self.api.extend_items(response=response, kind=RemoteObjectType.PLAYLIST, key=RemoteObjectType.TRACK) playlist: RemotePlaylist = self.factory.playlist(response=response) @@ -175,6 +177,9 @@ async def _delete_playlists(self) -> None: # assume all empty original playlists were temp playlists and delete them, restore the others delete_count = sum(1 for pl in self._playlist_originals.values() if len(pl) == 0) restore_count = sum(1 for pl in self._playlist_originals.values() if len(pl) > 0) + if not delete_count + restore_count: + return + self.logger.info_extra( f"\33[93mDeleting {delete_count} temporary playlists and restoring {restore_count} playlists... \33[0m" ) @@ -217,6 +222,7 @@ async def check[T: MusifyItemSettable]( pages_total = (total // self.interval) + (total % self.interval > 0) bar = self.logger.get_iterator(iter(collections), desc="Creating temp playlists", unit="playlists") + self._started = True self._skip = False self._quit = False @@ -239,12 +245,17 @@ async def check[T: MusifyItemSettable]( if self._quit or self._skip: # quit check break - result = self._finalise() if not self._quit else None + result = await self.close() self.logger.debug("Checking items: DONE\n") return result - def _finalise(self) -> ItemCheckResult: - """Log results and prepare the :py:class:`ItemCheckResult` object""" + async def close(self) -> ItemCheckResult | None: + """Close the checker, deleting/syncing all active playlists and returning the result of the check.""" + await self._delete_playlists() + if self._quit or not self._started: + self._reset() + return + self.logger.print() self.logger.report( f"\33[1;96mCHECK TOTALS \33[0m| " @@ -258,15 +269,17 @@ def _finalise(self) -> ItemCheckResult: switched=self._final_switched, unavailable=self._final_unavailable, skipped=self._final_skipped ) - self._skip = True + self._reset() + return result + + def _reset(self): + self._started = False self._remaining.clear() self._switched.clear() self._final_switched = [] self._final_unavailable = [] self._final_skipped = [] - return result - ########################################################################### ## Pause to check items in current temp playlists ########################################################################### @@ -288,6 +301,7 @@ async def _pause(self, page: int, total: int) -> None: "Print position, item name, URI, and URL from given link (useful to check current status of playlist)", "": "Once you have checked all playlist's items, continue on and check for any switches by the user", + "l": "List the names of the temporary playlists created", "s": "Check for changes on current playlists, but skip any remaining checks", "q": "Delete current temporary playlists and quit check", "h": "Show this dialogue again", @@ -312,6 +326,10 @@ async def _pause(self, page: int, total: int) -> None: self._skip = current_input.casefold() == 's' or self._skip break + elif current_input.casefold() == 'l': + for name in self._playlist_check_collections: + self.logger.print_message(f"\33[97m- \33[91m{name}\33[0m") + elif pl_name: # print originally added items items = [item for item in self._playlist_check_collections[pl_name] if item.has_uri] max_width = get_max_width(items) diff --git a/musify/libraries/remote/core/processors/wrangle.py b/musify/libraries/remote/core/processors/wrangle.py index 92fb81db..fbe8359e 100644 --- a/musify/libraries/remote/core/processors/wrangle.py +++ b/musify/libraries/remote/core/processors/wrangle.py @@ -3,14 +3,13 @@ """ from abc import ABCMeta, abstractmethod from collections.abc import Mapping -from typing import Any from yarl import URL from musify.libraries.remote.core import RemoteResponse from musify.libraries.remote.core.enum import RemoteIDType, RemoteObjectType from musify.libraries.remote.core.exception import RemoteObjectTypeError -from musify.libraries.remote.core.types import APIInputValue +from musify.libraries.remote.core.types import APIInputValueSingle, APIInputValueMulti class RemoteDataWrangler(metaclass=ABCMeta): @@ -66,7 +65,9 @@ def validate_id_type(cls, value: str, kind: RemoteIDType = RemoteIDType.ALL) -> raise NotImplementedError @classmethod - def get_item_type(cls, values: APIInputValue, kind: RemoteObjectType | None = None) -> RemoteObjectType: + def get_item_type( + cls, values: APIInputValueMulti[RemoteResponse], kind: RemoteObjectType | None = None + ) -> RemoteObjectType: """ Determine the remote object type of ``values``. @@ -105,7 +106,7 @@ def get_item_type(cls, values: APIInputValue, kind: RemoteObjectType | None = No @staticmethod @abstractmethod def _get_item_type( - value: str | Mapping[str, Any] | RemoteResponse, kind: RemoteObjectType | None = None + value: APIInputValueSingle[RemoteResponse], kind: RemoteObjectType | None = None ) -> RemoteObjectType | None: """ Determine the remote object type of the given ``value`` and return its type. @@ -126,7 +127,7 @@ def _get_item_type( raise NotImplementedError @classmethod - def validate_item_type(cls, values: APIInputValue, kind: RemoteObjectType) -> None: + def validate_item_type(cls, values: APIInputValueMulti[RemoteResponse], kind: RemoteObjectType) -> None: """ Check that the given ``values`` are a type of item given by ``kind`` or a simple ID. @@ -177,7 +178,7 @@ def convert( @classmethod @abstractmethod - def extract_ids(cls, values: APIInputValue, kind: RemoteObjectType | None = None) -> list[str]: + def extract_ids(cls, values: APIInputValueMulti[RemoteResponse], kind: RemoteObjectType | None = None) -> list[str]: """ Extract a list of IDs from input ``values``. diff --git a/musify/libraries/remote/core/types.py b/musify/libraries/remote/core/types.py index 77be2ded..f3affca6 100644 --- a/musify/libraries/remote/core/types.py +++ b/musify/libraries/remote/core/types.py @@ -2,16 +2,17 @@ All type hints to use throughout the module. """ from collections.abc import MutableMapping -from typing import Any +from typing import Any, Mapping from yarl import URL from musify.libraries.remote.core import RemoteResponse from musify.types import UnitMutableSequence, UnitSequence -type APIInputValue = ( - UnitMutableSequence[str] | +type APIInputValueSingle[T: RemoteResponse] = str | URL | Mapping[str, Any] | T +type APIInputValueMulti[T: RemoteResponse] = ( + UnitSequence[str] | UnitMutableSequence[URL] | UnitMutableSequence[MutableMapping[str, Any]] | - UnitSequence[RemoteResponse] + UnitSequence[T] ) diff --git a/musify/libraries/remote/spotify/api/item.py b/musify/libraries/remote/spotify/api/item.py index 3ce8001e..03a44ce5 100644 --- a/musify/libraries/remote/spotify/api/item.py +++ b/musify/libraries/remote/spotify/api/item.py @@ -14,7 +14,7 @@ from musify.libraries.remote.core import RemoteResponse from musify.libraries.remote.core.enum import RemoteIDType, RemoteObjectType from musify.libraries.remote.core.exception import RemoteObjectTypeError -from musify.libraries.remote.core.types import APIInputValue +from musify.libraries.remote.core.types import APIInputValueMulti from musify.libraries.remote.spotify.api.base import SpotifyAPIBase from musify.utils import limit_value @@ -271,7 +271,7 @@ async def extend_items( async def get_items( self, - values: APIInputValue, + values: APIInputValueMulti[RemoteResponse], kind: RemoteObjectType | None = None, limit: int = 50, extend: bool = True, @@ -407,7 +407,7 @@ async def get_user_items( ########################################################################### async def extend_tracks( self, - values: APIInputValue, + values: APIInputValueMulti[RemoteResponse], features: bool = False, analysis: bool = False, limit: int = 50, @@ -497,7 +497,7 @@ def map_key(value: str) -> str: async def get_tracks( self, - values: APIInputValue, + values: APIInputValueMulti[RemoteResponse], features: bool = False, analysis: bool = False, limit: int = 50, @@ -547,7 +547,7 @@ async def get_tracks( ## Artists GET endpoints methods ########################################################################### async def get_artist_albums( - self, values: APIInputValue, types: Collection[str] = (), limit: int = 50, + self, values: APIInputValueMulti[RemoteResponse], types: Collection[str] = (), limit: int = 50, ) -> dict[str, list[dict[str, Any]]]: """ ``GET: /artists/{ID}/albums`` - Get all albums associated with the given artist/s. diff --git a/musify/libraries/remote/spotify/api/playlist.py b/musify/libraries/remote/spotify/api/playlist.py index 33f765e6..72280dc1 100644 --- a/musify/libraries/remote/spotify/api/playlist.py +++ b/musify/libraries/remote/spotify/api/playlist.py @@ -2,14 +2,18 @@ Implements endpoints for manipulating playlists with the Spotify API. """ from abc import ABCMeta -from collections.abc import Collection, Mapping +from collections.abc import Sequence, Mapping from itertools import batched from typing import Any +from yarl import URL + from musify import PROGRAM_NAME, PROGRAM_URL +from musify.api.exception import APIError from musify.libraries.remote.core import RemoteResponse from musify.libraries.remote.core.enum import RemoteIDType, RemoteObjectType from musify.libraries.remote.core.exception import RemoteIDTypeError +from musify.libraries.remote.core.types import APIInputValueSingle from musify.libraries.remote.spotify.api.base import SpotifyAPIBase from musify.utils import limit_value @@ -24,7 +28,7 @@ async def load_user_playlists(self) -> None: responses = await self.get_user_items(kind=RemoteObjectType.PLAYLIST) self.user_playlist_data = {response["name"]: response for response in responses} - async def get_playlist_url(self, playlist: str | Mapping[str, Any] | RemoteResponse) -> str: + async def get_playlist_url(self, playlist: APIInputValueSingle[RemoteResponse]) -> URL: """ Determine the type of the given ``playlist`` and return its API URL. If type cannot be determined, attempt to find the playlist in the @@ -47,24 +51,28 @@ async def get_playlist_url(self, playlist: str | Mapping[str, Any] | RemoteRespo if isinstance(playlist, Mapping): if self.url_key in playlist: - return playlist[self.url_key] + url = playlist[self.url_key] elif self.id_key in playlist: - return self.wrangler.convert( + url = self.wrangler.convert( playlist[self.id_key], kind=RemoteObjectType.PLAYLIST, type_in=RemoteIDType.ID, type_out=RemoteIDType.URL ) elif "uri" in playlist: - return self.wrangler.convert( + url = self.wrangler.convert( playlist["uri"], kind=RemoteObjectType.PLAYLIST, type_in=RemoteIDType.URI, type_out=RemoteIDType.URL ) + else: + raise APIError(f"Could not determine URL from given input: {playlist}") + + return URL(url) try: - return self.wrangler.convert(playlist, kind=RemoteObjectType.PLAYLIST, type_out=RemoteIDType.URL) + url = self.wrangler.convert(playlist, kind=RemoteObjectType.PLAYLIST, type_out=RemoteIDType.URL) except RemoteIDTypeError: if not self.user_playlist_data: await self.load_user_playlists() @@ -75,7 +83,9 @@ async def get_playlist_url(self, playlist: str | Mapping[str, Any] | RemoteRespo value=playlist ) - return self.user_playlist_data[playlist][self.url_key] + url = self.user_playlist_data[playlist][self.url_key] + + return URL(url) ########################################################################### ## POST endpoints @@ -104,13 +114,26 @@ async def create_playlist( url = response[self.url_key] self.user_playlist_data[name] = response + # response from creating playlist gives back incorrect user info on 'owner' key, fix it + response["owner"]["display_name"] = self.user_name + response["owner"][self.id_key] = self.user_id + response["owner"]["uri"] = self.wrangler.convert( + self.user_id, kind=RemoteObjectType.USER, type_in=RemoteIDType.ID, type_out=RemoteIDType.URI + ) + response["owner"][self.url_key] = self.wrangler.convert( + self.user_id, kind=RemoteObjectType.USER, type_in=RemoteIDType.ID, type_out=RemoteIDType.URL + ) + response["owner"]["external_urls"][self.source.lower()] = self.wrangler.convert( + self.user_id, kind=RemoteObjectType.USER, type_in=RemoteIDType.ID, type_out=RemoteIDType.URL_EXT + ) + self.handler.log("DONE", url, message=f"Created playlist: {name!r} -> {url}") return response async def add_to_playlist( self, - playlist: str | Mapping[str, Any] | RemoteResponse, - items: Collection[str], + playlist: APIInputValueSingle[RemoteResponse], + items: Sequence[str], limit: int = 100, skip_dupes: bool = True ) -> int: @@ -157,10 +180,18 @@ async def add_to_playlist( self.handler.log("DONE", url, message=f"Added {len(uri_list):>6} items to playlist: {url}") return len(uri_list) + ########################################################################### + ## PUT endpoints + ########################################################################### + async def follow_playlist(self, playlist: APIInputValueSingle[RemoteResponse], *args, **kwargs) -> URL: + url = URL(f"{await self.get_playlist_url(playlist)}/followers") + await self.handler.put(url) + return url + ########################################################################### ## DELETE endpoints ########################################################################### - async def delete_playlist(self, playlist: str | Mapping[str, Any] | RemoteResponse) -> str: + async def delete_playlist(self, playlist: APIInputValueSingle[RemoteResponse]) -> URL: """ ``DELETE: /playlists/{playlist_id}/followers`` - Unfollow a given playlist. WARNING: This function will destructively modify your remote playlists. @@ -172,14 +203,14 @@ async def delete_playlist(self, playlist: str | Mapping[str, Any] | RemoteRespon - a RemoteResponse object representing a remote playlist. :return: API URL for playlist. """ - url = f"{await self.get_playlist_url(playlist)}/followers" + url = URL(f"{await self.get_playlist_url(playlist)}/followers") await self.handler.delete(url) return url async def clear_from_playlist( self, - playlist: str | Mapping[str, Any] | RemoteResponse, - items: Collection[str] | None = None, + playlist: APIInputValueSingle[RemoteResponse], + items: Sequence[str] | None = None, limit: int = 100 ) -> int: """ diff --git a/musify/libraries/remote/spotify/object.py b/musify/libraries/remote/spotify/object.py index 54181303..45cfcaf4 100644 --- a/musify/libraries/remote/spotify/object.py +++ b/musify/libraries/remote/spotify/object.py @@ -13,6 +13,7 @@ from musify.libraries.remote.core.enum import RemoteIDType, RemoteObjectType from musify.libraries.remote.core.object import RemoteCollectionLoader, RemoteTrack from musify.libraries.remote.core.object import RemotePlaylist, RemoteAlbum, RemoteArtist +from musify.libraries.remote.core.types import APIInputValueSingle from musify.libraries.remote.spotify.api import SpotifyAPI from musify.libraries.remote.spotify.base import SpotifyObject, SpotifyItem from musify.libraries.remote.spotify.exception import SpotifyCollectionError @@ -203,7 +204,7 @@ def refresh(self, skip_checks: bool = False) -> None: @classmethod async def load( cls, - value: str | Mapping[str, Any] | RemoteResponse, + value: APIInputValueSingle[Self], api: SpotifyAPI, features: bool = False, analysis: bool = False, @@ -329,7 +330,7 @@ def _merge_items_to_response( return uri_matched, uri_missing @classmethod - async def _load_new(cls, value: str | Mapping[str, Any] | RemoteResponse, api: SpotifyAPI, *args, **kwargs) -> Self: + async def _load_new(cls, value: APIInputValueSingle[Self], api: SpotifyAPI, *args, **kwargs) -> Self: """ Sets up a new object of the current class for the given ``value`` by calling ``__new__`` and adding just enough attributes to the object to get :py:meth:`reload` to run. @@ -349,7 +350,7 @@ async def _load_new(cls, value: str | Mapping[str, Any] | RemoteResponse, api: S @classmethod async def load( cls, - value: str | Mapping[str, Any] | RemoteResponse, + value: APIInputValueSingle[Self], api: SpotifyAPI, items: Iterable[T] = (), leave_bar: bool = True, diff --git a/musify/libraries/remote/spotify/processors.py b/musify/libraries/remote/spotify/processors.py index 378c89be..e87c09ca 100644 --- a/musify/libraries/remote/spotify/processors.py +++ b/musify/libraries/remote/spotify/processors.py @@ -9,9 +9,9 @@ from musify.exception import MusifyEnumError from musify.libraries.core.collection import MusifyCollection from musify.libraries.remote.core import RemoteResponse -from musify.libraries.remote.core.api import APIInputValue from musify.libraries.remote.core.enum import RemoteIDType, RemoteObjectType from musify.libraries.remote.core.exception import RemoteError, RemoteIDTypeError, RemoteObjectTypeError +from musify.libraries.remote.core.types import APIInputValueSingle, APIInputValueMulti from musify.libraries.remote.core.processors.wrangle import RemoteDataWrangler from musify.libraries.remote.spotify import SOURCE_NAME from musify.utils import to_collection @@ -69,7 +69,7 @@ def validate_id_type(cls, value: str | URL, kind: RemoteIDType = RemoteIDType.AL @classmethod def _get_item_type( - cls, value: str | URL | Mapping[str, Any] | RemoteResponse, kind: RemoteObjectType | None = None + cls, value: APIInputValueSingle[RemoteResponse], kind: RemoteObjectType | None = None ) -> RemoteObjectType | None: if isinstance(value, RemoteResponse): return cls._get_item_type_from_response(value) @@ -192,8 +192,8 @@ def _get_id_from_uri(cls, value: str) -> tuple[RemoteObjectType, str]: return kind, id_ @classmethod - def extract_ids(cls, values: APIInputValue, kind: RemoteObjectType | None = None) -> list[str]: - def extract_id(value: str | URL | Mapping[str, Any] | RemoteResponse) -> str: + def extract_ids(cls, values: APIInputValueMulti[RemoteResponse], kind: RemoteObjectType | None = None) -> list[str]: + def extract_id(value: APIInputValueSingle[RemoteResponse]) -> str: """Extract an ID from a given ``value``""" if isinstance(value, str | URL): return cls.convert(value, kind=kind, type_out=RemoteIDType.ID) diff --git a/tests/libraries/remote/core/processors/check.py b/tests/libraries/remote/core/processors/check.py index be3033b7..e291ce1e 100644 --- a/tests/libraries/remote/core/processors/check.py +++ b/tests/libraries/remote/core/processors/check.py @@ -137,8 +137,9 @@ async def test_delete_temp_playlists( # assert await api_mock.get_requests(method="POST", url=re.compile(str(playlist.url))) @staticmethod - def test_finalise(checker: RemoteItemChecker): - checker._skip = False + async def test_finalise(checker: RemoteItemChecker): + + checker._started = True checker._remaining.extend(random_tracks(3)) checker._switched.extend(random_tracks(2)) @@ -146,9 +147,9 @@ def test_finalise(checker: RemoteItemChecker): checker._final_unavailable = unavailable = random_tracks(2) checker._final_skipped = skipped = random_tracks(3) - result = checker._finalise() + result = await checker.close() - assert checker._skip + assert not checker._started assert not checker._remaining assert not checker._switched assert not checker._final_switched diff --git a/tests/libraries/remote/spotify/api/mock.py b/tests/libraries/remote/spotify/api/mock.py index e381b888..cb844a08 100644 --- a/tests/libraries/remote/spotify/api/mock.py +++ b/tests/libraries/remote/spotify/api/mock.py @@ -407,13 +407,11 @@ def setup_playlist_operations_mock(self) -> None: self.post( url=re.compile(playlist["href"] + "/tracks"), payload={"snapshot_id": str(uuid4())}, repeat=True ) - self.delete(url=re.compile(playlist["href"]), payload={"snapshot_id": str(uuid4())}, repeat=True) + self.put(url=re.compile(playlist["href"] + "/followers"), repeat=True) + self.delete(url=re.compile(playlist["href"] + "/followers"), repeat=True) self.delete( url=re.compile(playlist["href"] + "/tracks"), payload={"snapshot_id": str(uuid4())}, repeat=True ) - self.delete( - url=re.compile(playlist["href"] + "/followers"), payload={"snapshot_id": str(uuid4())}, repeat=True - ) def callback(_: str, json: dict[str, Any], **__) -> CallbackResult: """Process body and generate playlist response data""" @@ -430,13 +428,17 @@ def callback(_: str, json: dict[str, Any], **__) -> CallbackResult: self.get(url=re.compile(payload["href"]), payload=payload, repeat=True) self.post(url=re.compile(payload["href"] + "/tracks"), payload={"snapshot_id": str(uuid4())}, repeat=True) + self.put(url=re.compile(payload["href"] + "/followers"), repeat=True) + self.delete(url=re.compile(payload["href"] + "/followers"), repeat=True) self.delete(url=re.compile(payload["href"] + "/tracks"), payload={"snapshot_id": str(uuid4())}, repeat=True) - self.delete( - url=re.compile(payload["href"] + "/followers"), payload={"snapshot_id": str(uuid4())}, repeat=True - ) + + # for some reason, spotify returns initial response for created playlists with different owner IDs + payload = deepcopy(payload) + payload["owner"] = self.generate_owner() return CallbackResult(method="POST", payload=payload) + # add new request matches when creating new playlists url = f"{self.url_api}/users/{self.user_id}/playlists" self.post(url=url, callback=callback, repeat=True) diff --git a/tests/libraries/remote/spotify/api/test_playlist.py b/tests/libraries/remote/spotify/api/test_playlist.py index 1712ef6e..1a03a5cc 100644 --- a/tests/libraries/remote/spotify/api/test_playlist.py +++ b/tests/libraries/remote/spotify/api/test_playlist.py @@ -52,10 +52,10 @@ async def _get_payloads_from_url_base(cls, url: str | URL, api_mock: SpotifyMock ## Basic functionality ########################################################################### async def test_get_playlist_url(self, playlist_unique: dict[str, Any], api: SpotifyAPI, api_mock: SpotifyMock): - assert await api.get_playlist_url(playlist=playlist_unique) == playlist_unique["href"] - assert await api.get_playlist_url(playlist=playlist_unique["name"]) == playlist_unique["href"] + assert await api.get_playlist_url(playlist=playlist_unique) == URL(playlist_unique["href"]) + assert await api.get_playlist_url(playlist=playlist_unique["name"]) == URL(playlist_unique["href"]) pl_object = SpotifyPlaylist(playlist_unique, skip_checks=True) - assert await api.get_playlist_url(playlist=pl_object) == playlist_unique["href"] + assert await api.get_playlist_url(playlist=pl_object) == URL(playlist_unique["href"]) with pytest.raises(RemoteIDTypeError): await api.get_playlist_url("does not exist") @@ -76,6 +76,12 @@ async def test_create_playlist(self, api: SpotifyAPI, api_mock: SpotifyMock): assert body["collaborative"] and result["collaborative"] assert result[api.url_key].removeprefix(f"{api.url}/playlists/").strip("/") + assert result["owner"]["display_name"] == api.user_name + assert result["owner"][api.id_key] == api.user_id + assert api.user_id in result["owner"]["uri"] + assert api.user_id in result["owner"][api.url_key] + assert api.user_id in result["owner"]["external_urls"][api.source.lower()] + async def test_add_to_playlist_input_validation_and_skips(self, api: SpotifyAPI, api_mock: SpotifyMock): url = f"{api.url}/playlists/{random_id()}" for kind in ALL_ITEM_TYPES: @@ -166,6 +172,21 @@ async def test_add_to_playlist_with_skip(self, playlist: dict[str, Any], api: Sp uris.extend(payload["uris"]) assert len(uris) == len(id_list_new) + ########################################################################### + ## PUT playlist operations + ########################################################################### + async def test_follow_playlist(self, playlist_unique: dict[str, Any], api: SpotifyAPI, api_mock: SpotifyMock): + result = await api.follow_playlist( + random_id_type(id_=playlist_unique["id"], wrangler=api.wrangler, kind=RemoteObjectType.PLAYLIST) + ) + assert result == URL(playlist_unique["href"] + "/followers") + + result = await api.follow_playlist(playlist_unique) + assert result == URL(playlist_unique["href"] + "/followers") + + result = await api.follow_playlist(SpotifyPlaylist(playlist_unique, skip_checks=True)) + assert result == URL(playlist_unique["href"] + "/followers") + ########################################################################### ## DELETE playlist operations ########################################################################### @@ -173,13 +194,13 @@ async def test_delete_playlist(self, playlist_unique: dict[str, Any], api: Spoti result = await api.delete_playlist( random_id_type(id_=playlist_unique["id"], wrangler=api.wrangler, kind=RemoteObjectType.PLAYLIST) ) - assert result == playlist_unique["href"] + "/followers" + assert result == URL(playlist_unique["href"] + "/followers") result = await api.delete_playlist(playlist_unique) - assert result == playlist_unique["href"] + "/followers" + assert result == URL(playlist_unique["href"] + "/followers") result = await api.delete_playlist(SpotifyPlaylist(playlist_unique, skip_checks=True)) - assert result == playlist_unique["href"] + "/followers" + assert result == URL(playlist_unique["href"] + "/followers") async def test_clear_from_playlist_input_validation_and_skips(self, api: SpotifyAPI, api_mock: SpotifyMock): url = f"{api.url}/playlists/{random_id()}" diff --git a/tests/libraries/remote/spotify/object/test_playlist.py b/tests/libraries/remote/spotify/object/test_playlist.py index 04704307..1d5eaca6 100644 --- a/tests/libraries/remote/spotify/object/test_playlist.py +++ b/tests/libraries/remote/spotify/object/test_playlist.py @@ -326,6 +326,10 @@ async def test_create_playlist(self, api: SpotifyAPI, api_mock: SpotifyMock): assert not pl.public assert pl.collaborative + # should be set in api.create_playlist method + assert pl.owner_id == api.user_id == api_mock.user_id + assert pl.writeable + async def test_delete_playlist(self, response_valid: dict[str, Any], api: SpotifyAPI, api_mock: SpotifyMock): names = [pl["name"] for pl in api_mock.user_playlists] response = next(deepcopy(pl) for pl in api_mock.user_playlists if names.count(pl["name"]) == 1) diff --git a/tests/libraries/remote/spotify/test_processors.py b/tests/libraries/remote/spotify/test_processors.py index 1dc32486..f6d36e8f 100644 --- a/tests/libraries/remote/spotify/test_processors.py +++ b/tests/libraries/remote/spotify/test_processors.py @@ -354,7 +354,8 @@ def matcher(self) -> ItemMatcher: return ItemMatcher() @pytest.fixture - def checker(self, matcher: ItemMatcher, api: SpotifyAPI) -> RemoteItemChecker: + def checker(self, matcher: ItemMatcher, api: SpotifyAPI, token_file_path: str) -> RemoteItemChecker: + api.handler.authoriser.token_file_path = token_file_path return RemoteItemChecker(matcher=matcher, object_factory=SpotifyObjectFactory(api=api)) @pytest.fixture(scope="class")