Skip to content

Commit

Permalink
add follow_playlist + add single api input type + fix owner output on…
Browse files Browse the repository at this point in the history
… create_playlist
  • Loading branch information
geo-martino committed May 30, 2024
1 parent a2adfe7 commit 9083af0
Show file tree
Hide file tree
Showing 17 changed files with 201 additions and 100 deletions.
3 changes: 3 additions & 0 deletions docs/release-history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions musify/api/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
77 changes: 46 additions & 31 deletions musify/libraries/remote/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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]:
"""
Expand All @@ -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:
Expand All @@ -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.
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions musify/libraries/remote/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions musify/libraries/remote/core/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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.
Expand Down
36 changes: 27 additions & 9 deletions musify/libraries/remote/core/processors/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class RemoteItemChecker(InputProcessor):
"allow_karaoke",
"_playlist_originals",
"_playlist_check_collections",
"_started",
"_skip",
"_quit",
"_remaining",
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand All @@ -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)

Expand All @@ -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"
)
Expand Down Expand Up @@ -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

Expand All @@ -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| "
Expand All @@ -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
###########################################################################
Expand All @@ -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)",
"<Return/Enter>":
"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",
Expand All @@ -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)
Expand Down
Loading

0 comments on commit 9083af0

Please sign in to comment.