From c6e9fe17ca58d24a123bf222422e8fa019299f87 Mon Sep 17 00:00:00 2001 From: Bradlee Speice Date: Mon, 13 Sep 2021 22:27:52 -0400 Subject: [PATCH] Add followed artist support --- poetry.lock | 4 +-- pyproject.toml | 1 + spotify_actions/artist.py | 45 ++++++++++++++++++++++++ spotify_actions/combinator.py | 29 ---------------- spotify_actions/playlist.py | 12 +++---- spotify_actions/user.py | 65 ++++++++++++++++++++++++++++++++--- spotify_actions/util.py | 23 ++++++++++++- 7 files changed, 137 insertions(+), 42 deletions(-) create mode 100644 spotify_actions/artist.py delete mode 100644 spotify_actions/combinator.py diff --git a/poetry.lock b/poetry.lock index d5b2534..2d8f7ba 100644 --- a/poetry.lock +++ b/poetry.lock @@ -193,7 +193,7 @@ toml = ">=0.7.1" name = "pyyaml" version = "5.4.1" description = "YAML parser and emitter for Python" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" @@ -346,7 +346,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "deade799321bffd9f9db8e7e77a17dca75b04473b6d0133c5adb929c8fc2057e" +content-hash = "adf01d1ee8cf03745f2ba22cebfe7740d0460f4cbe120e5a6c9404563fc382e8" [metadata.files] appdirs = [ diff --git a/pyproject.toml b/pyproject.toml index 9b53490..8cbc167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ license = "MIT" python = "^3.7" spotipy = "^2.18.0" spotify-model = {path = "../spotify_model", develop = true} +PyYAML = "^5.4.1" [tool.poetry.dev-dependencies] pre-commit = "^2.13.0" diff --git a/spotify_actions/artist.py b/spotify_actions/artist.py new file mode 100644 index 0000000..55e7dcd --- /dev/null +++ b/spotify_actions/artist.py @@ -0,0 +1,45 @@ +""" +Selectors for querying artist information +""" + +from argparse import ArgumentParser +from functools import partial +from typing import Iterable + +from spotify_model import Paging, SearchAlbum +from spotipy import Spotify + +from .util import exhaust, read_credentials_server + + +def artist_albums(client: Spotify, artist_ids: Iterable[str]) -> Iterable[SearchAlbum]: + """ + https://developer.spotify.com/documentation/web-api/reference/#category-artists + """ + + def _artist_albums(artist_id: str, limit: int, offset: int) -> Paging: + return Paging(**client.artist_albums(artist_id, limit, offset)) + + for artist_id in artist_ids: + albums_function = partial(_artist_albums, artist_id) + + for album in exhaust(albums_function): + yield SearchAlbum(**album) + + +def main() -> None: + "Simple runner for quickly retrieving artist info" + parser = ArgumentParser() + parser.add_argument("-c", "--credentials", required=True) + parser.add_argument("artist_ids", nargs="+") + cmdline = parser.parse_args() + + client = read_credentials_server(cmdline.credentials) + + print("Artist albums:") + for album in artist_albums(client, cmdline.artist_ids): + print(album) + + +if __name__ == "__main__": + main() diff --git a/spotify_actions/combinator.py b/spotify_actions/combinator.py deleted file mode 100644 index 3859ce2..0000000 --- a/spotify_actions/combinator.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Join objects from multiple sources into a single stream -""" -from functools import partial -from typing import Iterable, TypeVar - -T = TypeVar("T") # pylint: disable=invalid-name - - -def combinator_take(items: Iterable[T], count: int) -> Iterable[T]: - "Retrieve the first `count` items from an iterator" - observed = 0 - for item in items: - observed += 1 - yield item - - if observed >= count: - break - - -combinator_first = partial(combinator_take, count=1) - - -def combinator_join(*args: Iterable[T]) -> Iterable[T]: - "Join the results of many album producers by exhausting all albums from each producer" - - for arg in args: - for item in arg: - yield item diff --git a/spotify_actions/playlist.py b/spotify_actions/playlist.py index 898a06d..402affd 100644 --- a/spotify_actions/playlist.py +++ b/spotify_actions/playlist.py @@ -7,11 +7,11 @@ from typing import Iterable from spotify_model import Paging, PlaylistTrack, SimplifiedPlaylist, SimplifiedTrack from spotipy import Spotify -from .user import user_current +from .user import current_user from .util import chunk, exhaust -def playlist_create(client: Spotify, user_id: str, name: str) -> SimplifiedPlaylist: +def playlist_current_user_create(client: Spotify, user_id: str, name: str) -> SimplifiedPlaylist: """ Create a playlist for a user @@ -54,11 +54,11 @@ def playlist_current_user_assure( yield playlist if not found: - current_user_id = user_current(client).spotify_id - yield playlist_create(client, current_user_id, name) + current_user_id = current_user(client).spotify_id + yield playlist_current_user_create(client, current_user_id, name) -def playlist_replace( +def playlist_current_user_replace( client: Spotify, playlist_id: str, tracks: Iterable[SimplifiedTrack], chunk_size: int = 100 ) -> None: """ @@ -77,7 +77,7 @@ def playlist_replace( client.playlist_add_items(playlist_id, track_chunk) -def playlist_tracks(client: Spotify, playlist_ids: Iterable[str]) -> Iterable[SimplifiedTrack]: +def playlist_tracks(client: Spotify, playlist_ids: Iterable[str]) -> Iterable[PlaylistTrack]: """ Given a playlist_id, fetch all songs currently in the playlist """ diff --git a/spotify_actions/user.py b/spotify_actions/user.py index 47589eb..7b1a30c 100644 --- a/spotify_actions/user.py +++ b/spotify_actions/user.py @@ -1,11 +1,68 @@ """ Actions related to user information """ +from argparse import ArgumentParser +from typing import Iterable, Optional -from spotify_model import PrivateUser +from spotify_model import Artist, CursorPaging, PublicUser from spotipy import Spotify +from .util import exhaust_cursor, read_credentials_oauth -def user_current(client: Spotify) -> PrivateUser: - "Get all details of the current user" - return PrivateUser(**client.current_user()) + +def current_user(client: Spotify) -> PublicUser: + """ + Get all details of the current user + + Required scopes: + - user-read-private + + Optional scopes: + - user-read-email + + https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-current-users-profile + """ + return PublicUser(**client.current_user()) + + +def current_user_followed_artists(client: Spotify) -> Iterable[Artist]: + """ + Required scopes: + - user-follow-read + + https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-followed + """ + + def _followed_artists(limit: int, after: Optional[str]) -> CursorPaging: + # This one is a little different; rather than using an offset counter, + # we inspect the response to get the "last" + return CursorPaging(**client.current_user_followed_artists(limit=limit, after=after)["artists"]) + + for artist in exhaust_cursor(_followed_artists): + yield Artist(**artist) + + +def main() -> None: + "Simple runner for retrieving current user information" + parser = ArgumentParser() + parser.add_argument("-c", "--credentials", required=True) + parser.add_argument("-r", "--redirect-uri", required=True) + cmdline = parser.parse_args() + + client = read_credentials_oauth( + cmdline.credentials, + redirect_uri=cmdline.redirect_uri, + scopes=["user-read-private", "user-follow-read"], + ) + + print("Current user:") + print(current_user(client)) + + print() + print("Current user followed artists:") + for artist in current_user_followed_artists(client): + print(artist) + + +if __name__ == "__main__": + main() diff --git a/spotify_actions/util.py b/spotify_actions/util.py index 6a4a946..5f75a29 100644 --- a/spotify_actions/util.py +++ b/spotify_actions/util.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Protocol, TypeVar import yaml -from spotify_model import Paging, ReleaseDatePrecision +from spotify_model import CursorPaging, Paging, ReleaseDatePrecision from spotipy import Spotify, SpotifyClientCredentials, SpotifyOAuth DEFAULT_LIMIT = 50 @@ -65,6 +65,27 @@ def exhaust( yield item +class CursorPaginated(Protocol): + "Protocol definition for functions that will be provided to the `exhaust_cursor` handler" + + def __call__(self, limit: int, after: Optional[str]) -> CursorPaging: + ... + + +def exhaust_cursor(function: CursorPaginated, limit: int = DEFAULT_LIMIT) -> Iterable[Dict[str, Any]]: + """Exhaust all items provided by a paging object""" + + response = function(limit=limit, after=None) + + for item in response.items: + yield item + + while response.next_href is not None: + response = function(limit=limit, after=response.cursors.after) + for item in response.items: + yield item + + def chunk(items: Iterable[T], size: int) -> Iterable[List[T]]: "Split an iterable into smaller chunks"