├── spotipy ├── py.typed ├── types │ ├── common.py │ ├── errors.py │ └── http.py ├── objects │ ├── common.py │ ├── followers.py │ ├── __init__.py │ ├── image.py │ ├── copyright.py │ ├── category.py │ ├── restrictions.py │ ├── device.py │ ├── artist.py │ ├── show.py │ ├── recommendation.py │ ├── search.py │ ├── user.py │ ├── base.py │ ├── playlist.py │ ├── album.py │ ├── playback.py │ ├── episode.py │ ├── credentials.py │ └── track.py ├── enums.py ├── __init__.py ├── utilities.py ├── values.py ├── errors.py ├── client.py └── http.py ├── docs ├── pages │ ├── contribution.rst │ ├── usage.rst │ ├── reference.rst │ └── installation.rst ├── static │ └── custom.css ├── index.rst ├── Makefile ├── make.bat ├── extensions │ └── resource_links.py └── conf.py ├── .gitignore ├── CHANGELOG.md ├── .readthedocs.yaml ├── README.md ├── LICENSE ├── .github └── workflows │ └── verify_types.yaml └── pyproject.toml /spotipy/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/pages/contribution.rst: -------------------------------------------------------------------------------- 1 | Contribution Guide 2 | ================== 3 | -------------------------------------------------------------------------------- /docs/pages/usage.rst: -------------------------------------------------------------------------------- 1 | .. py:currentmodule:: spotipy 2 | 3 | 4 | Usage 5 | ===== 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.log* 3 | 4 | .idea/ 5 | tests/ 6 | dist/ 7 | _build/ 8 | build/ 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.2.0 2 | 3 | ### New Features 4 | - n/a 5 | 6 | ### Changes 7 | - n/a 8 | 9 | ### Bug Fixes 10 | - n/a 11 | 12 | ### Notes 13 | - n/a 14 | -------------------------------------------------------------------------------- /docs/static/custom.css: -------------------------------------------------------------------------------- 1 | .sig-param::before { 2 | content: "\a"; 3 | white-space: pre; 4 | } 5 | 6 | dt em.sig-param:last-of-type::after { 7 | content: "\a"; 8 | white-space: pre; 9 | } 10 | -------------------------------------------------------------------------------- /spotipy/types/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from ..objects.credentials import ClientCredentials, UserCredentials 4 | 5 | 6 | __all__ = ( 7 | "AnyCredentials", 8 | ) 9 | 10 | 11 | AnyCredentials = ClientCredentials | UserCredentials 12 | -------------------------------------------------------------------------------- /docs/pages/reference.rst: -------------------------------------------------------------------------------- 1 | .. py:currentmodule:: spotipy 2 | 3 | 4 | Reference 5 | ========= 6 | 7 | Clients 8 | ------- 9 | placeholder text 10 | 11 | Client 12 | ~~~~~~ 13 | .. autoclass:: Client 14 | :members: 15 | 16 | HTTPClient 17 | ~~~~~~~~~~ 18 | .. autoclass:: HTTPClient 19 | :members: 20 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-20.04 5 | tools: 6 | python: "3.10" 7 | jobs: 8 | post_install: 9 | - pip install poetry 10 | - poetry config virtualenvs.create false 11 | - poetry install --with docs 12 | 13 | sphinx: 14 | configuration: docs/conf.py 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spoti.py 2 | An async wrapper for the [Spotify Web API](https://developer.spotify.com/documentation/web-api/). 3 | 4 | ## Installation 5 | ```shell 6 | pip install spoti-py 7 | ``` 8 | 9 | ## Support 10 | - [Documentation](https://spoti-py.readthedocs.io/) 11 | - [GitHub](https://github.com/Axelware/spoti-py) 12 | - [Discord](https://discord.com/invite/w9f6NkQbde) 13 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. py:currentmodule:: spotipy 2 | 3 | 4 | Welcome to spoti.py 5 | =================== 6 | An async wrapper for the Spotify Web API. 7 | 8 | 9 | .. toctree:: 10 | :hidden: 11 | :caption: Introduction 12 | 13 | pages/installation 14 | pages/contribution 15 | 16 | 17 | .. toctree:: 18 | :hidden: 19 | :caption: API 20 | 21 | pages/usage 22 | pages/reference 23 | -------------------------------------------------------------------------------- /spotipy/objects/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | from typing import Any 5 | 6 | 7 | __all__ = ( 8 | "ExternalURLs", 9 | "ExternalIDs", 10 | "ReleaseDatePrecision" 11 | ) 12 | 13 | ExternalURLs = dict[str, Any] 14 | ExternalIDs = dict[str, Any] 15 | 16 | 17 | class ReleaseDatePrecision(enum.Enum): 18 | YEAR = "year" 19 | MONTH = "month" 20 | DAY = "day" 21 | -------------------------------------------------------------------------------- /spotipy/types/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | 6 | __all__ = ( 7 | "AuthenticationErrorData", 8 | "HTTPErrorKeyData", 9 | "HTTPErrorData", 10 | ) 11 | 12 | 13 | class AuthenticationErrorData(TypedDict): 14 | error: str 15 | error_description: str 16 | 17 | 18 | class HTTPErrorKeyData(TypedDict): 19 | status: int 20 | message: str 21 | 22 | 23 | class HTTPErrorData(TypedDict): 24 | error: HTTPErrorKeyData 25 | -------------------------------------------------------------------------------- /spotipy/objects/followers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | 6 | __all__ = ( 7 | "FollowersData", 8 | "Followers", 9 | ) 10 | 11 | 12 | class FollowersData(TypedDict): 13 | href: None 14 | total: int 15 | 16 | 17 | class Followers: 18 | 19 | def __init__(self, data: FollowersData) -> None: 20 | self.href: None = data["href"] 21 | self.total: int = data["total"] 22 | 23 | def __repr__(self) -> str: 24 | return f"" 25 | -------------------------------------------------------------------------------- /spotipy/objects/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base import * 4 | 5 | from .album import * 6 | from .artist import * 7 | from .category import * 8 | from .common import * 9 | from .copyright import * 10 | from .credentials import * 11 | from .device import * 12 | from .episode import * 13 | from .followers import * 14 | from .image import * 15 | from .playback import * 16 | from .playlist import * 17 | from .recommendation import * 18 | from .restrictions import * 19 | from .search import * 20 | from .show import * 21 | from .track import * 22 | from .user import * 23 | -------------------------------------------------------------------------------- /spotipy/objects/image.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | 6 | __all__ = ( 7 | "ImageData", 8 | "Image", 9 | ) 10 | 11 | 12 | class ImageData(TypedDict): 13 | url: str 14 | width: int 15 | height: int 16 | 17 | 18 | class Image: 19 | 20 | def __init__(self, data: ImageData) -> None: 21 | self.url: str = data["url"] 22 | self.width: int = data["width"] 23 | self.height: int = data["height"] 24 | 25 | def __repr__(self) -> str: 26 | return f"" 27 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /spotipy/types/http.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal, TypedDict 4 | 5 | from typing_extensions import NotRequired 6 | 7 | from ..objects.base import PagingObjectData 8 | from ..objects.playlist import SimplePlaylistData 9 | 10 | 11 | __all__ = ( 12 | "HTTPMethod", 13 | "Headers", 14 | "FeaturedPlaylistsData", 15 | ) 16 | 17 | 18 | HTTPMethod = Literal["GET", "POST", "DELETE", "PATCH", "PUT"] 19 | 20 | 21 | Headers = TypedDict( 22 | "Headers", 23 | { 24 | "Authorization": str, 25 | "Content-Type": NotRequired[str] 26 | } 27 | ) 28 | 29 | 30 | class FeaturedPlaylistsData(TypedDict): 31 | message: str 32 | playlists: PagingObjectData[SimplePlaylistData] 33 | -------------------------------------------------------------------------------- /spotipy/objects/copyright.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | from typing import TypedDict, Literal 5 | 6 | 7 | __all__ = ( 8 | "CopyrightType", 9 | "CopyrightData", 10 | "Copyright", 11 | ) 12 | 13 | 14 | class CopyrightType(enum.Enum): 15 | NORMAL = "C" 16 | PERFORMANCE = "P" 17 | 18 | 19 | class CopyrightData(TypedDict): 20 | text: str 21 | type: Literal["C", "P"] 22 | 23 | 24 | class Copyright: 25 | 26 | def __init__(self, data: CopyrightData) -> None: 27 | self.text: str = data["text"] 28 | self.type: CopyrightType = CopyrightType(data["type"]) 29 | 30 | def __repr__(self) -> str: 31 | return f"" 32 | -------------------------------------------------------------------------------- /spotipy/objects/category.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | from .image import Image, ImageData 6 | 7 | 8 | __all__ = ( 9 | "CategoryData", 10 | "Category", 11 | ) 12 | 13 | 14 | class CategoryData(TypedDict): 15 | href: str 16 | icons: list[ImageData] 17 | id: str 18 | name: str 19 | 20 | 21 | class Category: 22 | 23 | def __init__(self, data: CategoryData) -> None: 24 | self.href: str = data["href"] 25 | self.icons: list[Image] = [Image(image) for image in data["icons"]] 26 | self.id: str = data["id"] 27 | self.name: str = data["name"] 28 | 29 | def __repr__(self) -> str: 30 | return f"" 31 | -------------------------------------------------------------------------------- /spotipy/objects/restrictions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | from typing import TypedDict, Literal 5 | 6 | 7 | __all__ = ( 8 | "RestrictionsReason", 9 | "RestrictionsData", 10 | "Restrictions" 11 | ) 12 | 13 | 14 | class RestrictionsReason(enum.Enum): 15 | MARKET = "market" 16 | PRODUCT = "product" 17 | EXPLICIT = "explicit" 18 | 19 | 20 | class RestrictionsData(TypedDict): 21 | reason: Literal["market", "product", "explicit"] 22 | 23 | 24 | class Restrictions: 25 | 26 | def __init__(self, data: RestrictionsData) -> None: 27 | self.reason: RestrictionsReason = RestrictionsReason(data["reason"]) 28 | 29 | def __repr__(self) -> str: 30 | return f"" 31 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /spotipy/enums.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | 5 | 6 | __all__ = ( 7 | "IncludeGroup", 8 | "SearchType", 9 | "RepeatMode", 10 | "TimeRange", 11 | ) 12 | 13 | 14 | class IncludeGroup(enum.Enum): 15 | ALBUM = "album" 16 | SINGLE = "single" 17 | APPEARS_ON = "appears_on" 18 | COMPILATION = "compilation" 19 | ALL = f"{ALBUM},{SINGLE},{APPEARS_ON},{COMPILATION}" 20 | 21 | 22 | class SearchType(enum.Enum): 23 | ALBUM = "album" 24 | ARTIST = "artist" 25 | PLAYLIST = "playlist" 26 | TRACK = "track" 27 | SHOW = "show" 28 | EPISODE = "episode" 29 | All = f"{ALBUM},{ARTIST},{PLAYLIST},{TRACK},{SHOW},{EPISODE}" 30 | 31 | 32 | class RepeatMode(enum.Enum): 33 | TRACK = "track" 34 | CONTEXT = "context" 35 | OFF = "off" 36 | 37 | 38 | class TimeRange(enum.Enum): 39 | LONG_TERM = "long_term" 40 | MEDIUM_TERM = "medium_term" 41 | SHORT_TERM = "short_term" 42 | -------------------------------------------------------------------------------- /spotipy/objects/device.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | 6 | __all__ = ( 7 | "DeviceData", 8 | "Device", 9 | ) 10 | 11 | 12 | class DeviceData(TypedDict): 13 | id: str 14 | is_active: bool 15 | is_private_session: bool 16 | is_restricted: bool 17 | name: str 18 | type: str 19 | volume_percent: int 20 | 21 | 22 | class Device: 23 | 24 | def __init__(self, data: DeviceData) -> None: 25 | self.id: str = data["id"] 26 | self.is_active: bool = data["is_active"] 27 | self.is_private_session: bool = data["is_private_session"] 28 | self.is_restricted: bool = data["is_restricted"] 29 | self.name: str = data["name"] 30 | self.type: str = data["type"] 31 | self.volume_percent: int = data["volume_percent"] 32 | 33 | def __repr__(self) -> str: 34 | return f"" 35 | -------------------------------------------------------------------------------- /spotipy/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Final, Literal, NamedTuple 5 | 6 | from .client import * 7 | from .errors import * 8 | from .http import * 9 | from .objects import * 10 | from .utilities import * 11 | from .values import * 12 | 13 | 14 | class VersionInfo(NamedTuple): 15 | major: int 16 | minor: int 17 | micro: int 18 | releaselevel: Literal["alpha", "beta", "candidate", "final"] 19 | serial: int 20 | 21 | 22 | version_info: Final[VersionInfo] = VersionInfo(major=0, minor=2, micro=0, releaselevel="final", serial=0) 23 | 24 | __title__: Final[str] = "spotipy" 25 | __author__: Final[str] = "Axelancerr" 26 | __copyright__: Final[str] = "Copyright 2021-present Axelancerr" 27 | __license__: Final[str] = "MIT" 28 | __version__: Final[str] = "0.2.0" 29 | __maintainer__: Final[str] = "Aaron Hennessey" 30 | __source__: Final[str] = "https://github.com/Axelware/spoti-py" 31 | 32 | logging.getLogger("spotipy") 33 | -------------------------------------------------------------------------------- /spotipy/utilities.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import json 5 | from collections.abc import Callable 6 | from typing import Any 7 | 8 | import aiohttp 9 | 10 | 11 | __all__ = ( 12 | "to_json", 13 | "from_json", 14 | "json_or_text", 15 | "limit_value", 16 | ) 17 | 18 | 19 | to_json: Callable[[dict[str, Any]], str] = json.dumps 20 | from_json: Callable[[str], dict[str, Any]] = json.loads 21 | 22 | 23 | async def json_or_text(response: aiohttp.ClientResponse) -> dict[str, Any] | str: 24 | 25 | text = await response.text(encoding="utf-8") 26 | 27 | with contextlib.suppress(KeyError): 28 | if response.headers["content-type"] in ["application/json", "application/json; charset=utf-8"]: 29 | return from_json(text) 30 | 31 | return text 32 | 33 | 34 | def limit_value(name: str, value: int, minimum: int, maximum: int) -> None: 35 | if value < minimum or value > maximum: 36 | raise ValueError(f"'{name}' must be more than {minimum} and less than {maximum}") 37 | -------------------------------------------------------------------------------- /docs/pages/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Stable 5 | ------ 6 | .. tab:: Latest 7 | 8 | .. code-block:: shell 9 | 10 | pip install -U spoti-py 11 | 12 | .. tab:: Specific Version 13 | 14 | .. code-block:: shell 15 | 16 | pip install -U spoti-py==0.2.0 17 | 18 | .. tab:: Force Reinstall 19 | 20 | .. code-block:: shell 21 | 22 | pip install --force-reinstall spoti-py 23 | 24 | 25 | Development 26 | ----------- 27 | .. tab:: Main 28 | 29 | .. code-block:: shell 30 | 31 | pip install -U git+https://github.com/Axelware/spoti-py.git@main 32 | 33 | .. tab:: Branch 34 | 35 | .. code-block:: shell 36 | 37 | pip install -U git+https://github.com/Axelware/spoti-py.git@v0.1.x 38 | 39 | .. tab:: Tag 40 | 41 | .. code-block:: shell 42 | 43 | pip install -U git+https://github.com/Axelware/spoti-py.git@v0.2.0 44 | 45 | 46 | .. tab:: Commit 47 | 48 | .. code-block:: shell 49 | 50 | pip install -U git+https://github.com/Axelware/spoti-py.git@d519593 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present Axelancerr/Axel/Aaron Hennessey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /spotipy/objects/artist.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base import BaseObject, BaseObjectData 4 | from .common import ExternalURLs 5 | from .followers import Followers, FollowersData 6 | from .image import Image, ImageData 7 | 8 | 9 | __all__ = ( 10 | "SimpleArtistData", 11 | "SimpleArtist", 12 | "ArtistData", 13 | "Artist", 14 | ) 15 | 16 | 17 | class SimpleArtistData(BaseObjectData): 18 | external_urls: ExternalURLs 19 | 20 | 21 | class SimpleArtist(BaseObject): 22 | 23 | def __init__(self, data: SimpleArtistData) -> None: 24 | super().__init__(data) 25 | self.external_urls: ExternalURLs = data["external_urls"] 26 | 27 | @property 28 | def url(self) -> str | None: 29 | return self.external_urls.get("spotify") 30 | 31 | 32 | class ArtistData(SimpleArtistData): 33 | followers: FollowersData 34 | genres: list[str] 35 | images: list[ImageData] 36 | popularity: int 37 | 38 | 39 | class Artist(SimpleArtist): 40 | 41 | def __init__(self, data: ArtistData) -> None: 42 | super().__init__(data) 43 | 44 | self.external_urls: ExternalURLs = data["external_urls"] 45 | self.followers: Followers = Followers(data["followers"]) 46 | self.genres: list[str] = data["genres"] 47 | self.images: list[Image] = [Image(image) for image in data["images"]] 48 | self.popularity: int = data["popularity"] 49 | -------------------------------------------------------------------------------- /.github/workflows/verify_types.yaml: -------------------------------------------------------------------------------- 1 | name: Verify Types 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | types: 11 | - opened 12 | - synchronize 13 | 14 | jobs: 15 | 16 | job: 17 | name: "Verify Types @ Python v${{ matrix.python-version }}" 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | python-version: [ "3.10" ] 24 | 25 | steps: 26 | 27 | # git 28 | 29 | - name: "Initialise Environment" 30 | uses: actions/checkout@v3 31 | with: 32 | fetch-depth: 0 33 | 34 | # node 35 | 36 | - name: "Setup Node v18" 37 | uses: actions/setup-node@v3 38 | with: 39 | node-version: 18 40 | 41 | - name: "Install Pyright" 42 | run: | 43 | npm install -g pyright@latest 44 | 45 | # python 46 | 47 | - name: "Setup Python v${{ matrix.python-version }}" 48 | uses: actions/setup-python@v4 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | 52 | - name: "Install Poetry" 53 | run: | 54 | pipx install poetry 55 | 56 | - name: "Install Dependencies" 57 | run: | 58 | poetry env use ${{ matrix.python-version }} 59 | poetry install 60 | 61 | - name: "Verify types" 62 | run: | 63 | poetry run pyright 64 | poetry run pyright --ignoreexternal --lib --verifytypes spotipy 65 | -------------------------------------------------------------------------------- /spotipy/objects/show.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base import BaseObject, BaseObjectData 4 | from .common import ExternalURLs 5 | from .copyright import Copyright, CopyrightData 6 | from .image import Image, ImageData 7 | 8 | 9 | __all__ = ( 10 | "ShowData", 11 | "Show", 12 | ) 13 | 14 | 15 | class ShowData(BaseObjectData): 16 | available_markets: list[str] 17 | copyrights: list[CopyrightData] 18 | description: str 19 | explicit: bool 20 | external_urls: ExternalURLs 21 | html_description: str 22 | images: list[ImageData] 23 | is_externally_hosted: bool 24 | languages: list[str] 25 | media_type: str 26 | publisher: str 27 | total_episodes: int 28 | 29 | 30 | class Show(BaseObject): 31 | 32 | def __init__(self, data: ShowData) -> None: 33 | super().__init__(data) 34 | 35 | self.available_markets: list[str] = data["available_markets"] 36 | self.copyrights: list[Copyright] = [Copyright(copyright) for copyright in data["copyrights"]] 37 | self.description: str = data["description"] 38 | self.explicit: bool = data["explicit"] 39 | self.external_urls: ExternalURLs = data["external_urls"] 40 | self.html_description: str = data["html_description"] 41 | self.images: list[Image] = [Image(image) for image in data["images"]] 42 | self.is_externally_hosted: bool = data["is_externally_hosted"] 43 | self.languages: list[str] = data["languages"] 44 | self.media_type: str = data["media_type"] 45 | self.publisher: str = data["publisher"] 46 | self.total_episodes: int = data["total_episodes"] 47 | 48 | @property 49 | def url(self) -> str | None: 50 | return self.external_urls.get("spotify") 51 | -------------------------------------------------------------------------------- /spotipy/objects/recommendation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | from typing import TypedDict, Literal 5 | 6 | from .track import Track, TrackData 7 | 8 | 9 | __all__ = ( 10 | "RecommendationSeedType", 11 | "RecommendationsSeedData", 12 | "RecommendationsSeed", 13 | "RecommendationsData", 14 | "Recommendations" 15 | ) 16 | 17 | 18 | class RecommendationSeedType(enum.Enum): 19 | ARTIST = "ARTIST" 20 | TRACK = "TRACK" 21 | GENRE = "GENRE" 22 | 23 | 24 | class RecommendationsSeedData(TypedDict): 25 | initialPoolSize: int 26 | afterFilteringSize: int 27 | afterRelinkingSize: int 28 | href: str | None 29 | id: str 30 | type: Literal["ARTIST", "TRACK", "GENRE"] 31 | 32 | 33 | class RecommendationsSeed: 34 | 35 | def __init__(self, data: RecommendationsSeedData) -> None: 36 | self.initial_pool_size: int = data["initialPoolSize"] 37 | self.after_filtering_size: int = data["afterFilteringSize"] 38 | self.after_relinking_size: int = data["afterRelinkingSize"] 39 | self.href: str | None = data["href"] 40 | self.id: str = data["id"] 41 | self.type: RecommendationSeedType = RecommendationSeedType(data["type"]) 42 | 43 | def __repr__(self) -> str: 44 | return f"" 45 | 46 | 47 | class RecommendationsData(TypedDict): 48 | tracks: list[TrackData] 49 | seeds: list[RecommendationsSeedData] 50 | 51 | 52 | class Recommendations: 53 | 54 | def __init__(self, data: RecommendationsData) -> None: 55 | self.tracks: list[Track] = [Track(data) for data in data["tracks"]] 56 | self.seeds: list[RecommendationsSeed] = [RecommendationsSeed(data) for data in data["seeds"]] 57 | 58 | def __repr__(self) -> str: 59 | return f"" 60 | -------------------------------------------------------------------------------- /spotipy/objects/search.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | from .album import SimpleAlbum, SimpleAlbumData 6 | from .artist import Artist, ArtistData 7 | from .base import PagingObject, PagingObjectData 8 | from .playlist import SimplePlaylist, SimplePlaylistData 9 | from .track import Track, TrackData 10 | from .show import ShowData, Show 11 | from .episode import SimpleEpisodeData, SimpleEpisode 12 | 13 | 14 | __all__ = ( 15 | "SearchResultData", 16 | "SearchResult", 17 | ) 18 | 19 | 20 | class SearchResultData(TypedDict): 21 | albums: PagingObjectData[SimpleAlbumData] 22 | artists: PagingObjectData[ArtistData] 23 | playlists: PagingObjectData[SimplePlaylistData] 24 | tracks: PagingObjectData[TrackData] 25 | shows: PagingObjectData[ShowData] 26 | episodes: PagingObjectData[SimpleEpisodeData] 27 | 28 | 29 | class SearchResult: 30 | 31 | def __init__(self, data: SearchResultData) -> None: 32 | 33 | self.albums: list[SimpleAlbum] = [SimpleAlbum(album) for album in PagingObject(data["albums"]).items] 34 | self.artists: list[Artist] = [Artist(artist) for artist in PagingObject(data["artists"]).items] 35 | self.playlists: list[SimplePlaylist] = [SimplePlaylist(playlist) for playlist in PagingObject(data["playlists"]).items] 36 | self.tracks: list[Track] = [Track(track) for track in PagingObject(data["tracks"]).items] 37 | self.shows: list[Show] = [Show(show) for show in PagingObject(data["shows"]).items] 38 | self.episodes: list[SimpleEpisode] = [SimpleEpisode(episode) for episode in PagingObject(data["episodes"]).items] 39 | 40 | def __repr__(self) -> str: 41 | return f"" 44 | -------------------------------------------------------------------------------- /spotipy/values.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | __all__ = ( 5 | "SCOPES", 6 | "VALID_RECOMMENDATION_SEED_KWARGS", 7 | ) 8 | 9 | 10 | SCOPES: list[str] = [ 11 | "ugc-image-upload", 12 | 13 | "playlist-modify-private", 14 | "playlist-read-private", 15 | "playlist-modify-public", 16 | "playlist-read-collaborative", 17 | 18 | "user-read-private", 19 | "user-read-email", 20 | 21 | "user-read-playback-state", 22 | "user-modify-playback-state", 23 | "user-read-currently-playing", 24 | 25 | "user-library-modify", 26 | "user-library-read", 27 | 28 | "user-read-playback-position", 29 | "user-read-recently-played", 30 | "user-top-read", 31 | 32 | "app-remote-control", 33 | "streaming", 34 | 35 | "user-follow-modify", 36 | "user-follow-read", 37 | ] 38 | 39 | VALID_RECOMMENDATION_SEED_KWARGS: list[str] = [ 40 | "min_acousticness", 41 | "max_acousticness", 42 | "target_acousticness", 43 | "min_danceability", 44 | "max_danceability", 45 | "target_danceability", 46 | "min_duration_ms", 47 | "max_duration_ms", 48 | "target_duration_ms", 49 | "min_energy", 50 | "max_energy", 51 | "target_energy", 52 | "min_instrumentalness", 53 | "max_instrumentalness", 54 | "target_instrumentalness", 55 | "min_key", 56 | "max_key", 57 | "target_key", 58 | "min_liveness", 59 | "max_liveness", 60 | "target_liveness", 61 | "min_loudness", 62 | "max_loudness", 63 | "target_loudness", 64 | "min_mode", 65 | "max_mode", 66 | "target_mode", 67 | "min_popularity", 68 | "max_popularity", 69 | "target_popularity", 70 | "min_speechiness", 71 | "max_speechiness", 72 | "target_speechiness", 73 | "min_tempo", 74 | "max_tempo", 75 | "target_tempo", 76 | "min_time_signature", 77 | "max_time_signature", 78 | "target_time_signature", 79 | "min_valence", 80 | "max_valence", 81 | "target_valence" 82 | ] 83 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | 6 | [tool.poetry] 7 | name = "spoti-py" 8 | version = "0.2.0" 9 | description = "An async wrapper for the Spotify Web API." 10 | license = "MIT" 11 | authors = ["Axel "] 12 | readme = "README.md" 13 | homepage = "https://github.com/Axelware/spoti-py" 14 | repository = "https://github.com/Axelware/spoti-py" 15 | documentation = "https://spoti-py.readthedocs.io/en/latest/" 16 | keywords = ["spotify", "api", "wrapper", "async"] 17 | include = ["CHANGELOG.md", "LICENSE"] 18 | classifiers = [ 19 | "Framework :: AsyncIO", 20 | "Topic :: Software Development", 21 | "Topic :: Software Development :: Libraries", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | "Development Status :: 4 - Beta", 24 | "Programming Language :: Python :: Implementation :: CPython", 25 | "Operating System :: OS Independent", 26 | "Environment :: Other Environment", 27 | "Intended Audience :: Developers", 28 | "Natural Language :: English", 29 | "Typing :: Typed" 30 | ] 31 | packages = [ 32 | { include = "spotipy" }, 33 | { include = "spotipy/**/*.py" }, 34 | { include = "spotipy/**/*.typed" }, 35 | ] 36 | 37 | 38 | [tool.poetry.dependencies] 39 | python = "^3.10.0" 40 | aiohttp = "^3.8.0" 41 | typing_extensions = "^4.3.0" 42 | 43 | 44 | [tool.poetry.group.docs] 45 | optional = true 46 | 47 | [tool.poetry.group.docs.dependencies] 48 | sphinx = "5.1.1" 49 | sphinxcontrib-trio = "1.1.2" 50 | sphinx-copybutton = "0.5.0" 51 | sphinx-inline-tabs = "2022.1.2b11" 52 | furo = "2022.6.21" 53 | 54 | 55 | [tool.poetry.urls] 56 | "Issue Tracker" = "https://github.com/Axelware/spoti-py/issues" 57 | "Discord" = "https://discord.com/invite/w9f6NkQbde" 58 | 59 | 60 | [tool.pyright] 61 | include = ["spotipy"] 62 | pythonVersion = "3.10" 63 | typeCheckingMode = "strict" 64 | useLibraryCodeForTypes = true 65 | 66 | reportPrivateUsage = false 67 | reportUnknownMemberType = false 68 | -------------------------------------------------------------------------------- /spotipy/objects/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | from typing_extensions import NotRequired 6 | 7 | from .base import BaseObject, BaseObjectData 8 | from .common import ExternalURLs 9 | from .followers import Followers, FollowersData 10 | from .image import Image, ImageData 11 | 12 | 13 | __all__ = ( 14 | "ExplicitContentSettingsData", 15 | "ExplicitContentSettings", 16 | "UserData", 17 | "User" 18 | ) 19 | 20 | 21 | class ExplicitContentSettingsData(TypedDict): 22 | filter_enabled: bool 23 | filter_locked: bool 24 | 25 | 26 | class ExplicitContentSettings: 27 | 28 | def __init__(self, data: ExplicitContentSettingsData) -> None: 29 | self.filter_enabled: bool = data["filter_enabled"] 30 | self.filter_locked: bool = data["filter_locked"] 31 | 32 | def __repr__(self) -> str: 33 | return f" None: 51 | super().__init__(data) 52 | 53 | self.country: str | None = data.get("country") 54 | self.display_name: str | None = data.get("display_name") 55 | self.email: str | None = data.get("email") 56 | self.explicit_content_settings: ExplicitContentSettings | None = ExplicitContentSettings(explicit_content) if (explicit_content := data.get("explicit_content")) else None 57 | self.external_urls: ExternalURLs = data["external_urls"] 58 | self.followers: Followers | None = Followers(followers) if (followers := data.get("followers")) else None 59 | self.images: list[Image] | None = [Image(image) for image in images] if (images := data.get("images")) else None 60 | self.product: str | None = data.get("product") 61 | 62 | def __repr__(self) -> str: 63 | return f"" 64 | 65 | @property 66 | def url(self) -> str | None: 67 | return self.external_urls.get("spotify") 68 | -------------------------------------------------------------------------------- /spotipy/objects/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypeVar, Generic 4 | 5 | from typing_extensions import NotRequired, TypedDict 6 | 7 | 8 | __all__ = ( 9 | "BaseObjectData", 10 | "BaseObject", 11 | "PagingObjectData", 12 | "PagingObject", 13 | "AlternativePagingObjectData", 14 | "AlternativePagingObject", 15 | ) 16 | 17 | 18 | T = TypeVar("T") 19 | 20 | 21 | class BaseObjectData(TypedDict): 22 | href: str 23 | id: str 24 | name: NotRequired[str] 25 | type: str 26 | uri: str 27 | 28 | 29 | class BaseObject: 30 | 31 | def __init__(self, data: BaseObjectData) -> None: 32 | self.href: str = data["href"] 33 | self.id: str = data["id"] 34 | self.name: str | None = data.get("name") 35 | self.type: str = data["type"] 36 | self.uri: str = data["uri"] 37 | 38 | def __repr__(self) -> str: 39 | return f"" 40 | 41 | 42 | class PagingObjectData(TypedDict, Generic[T]): 43 | href: str 44 | items: list[T] 45 | limit: int 46 | next: str | None 47 | offset: int 48 | previous: str | None 49 | total: int 50 | 51 | 52 | class PagingObject(Generic[T]): 53 | 54 | def __init__(self, data: PagingObjectData[T]) -> None: 55 | self.href: str = data["href"] 56 | self.items: list[T] = data["items"] 57 | self.limit: int = data["limit"] 58 | self.next: str | None = data["next"] 59 | self.offset: int = data["offset"] 60 | self.previous: str | None = data["previous"] 61 | self.total: int = data["total"] 62 | 63 | def __repr__(self) -> str: 64 | return f"" 65 | 66 | 67 | class AlternativePagingObjectData(TypedDict, Generic[T]): 68 | href: str 69 | items: list[T] 70 | limit: int 71 | next: str | None 72 | before: NotRequired[str] 73 | after: str 74 | 75 | 76 | class AlternativePagingObject(Generic[T]): 77 | 78 | def __init__(self, data: AlternativePagingObjectData[T]) -> None: 79 | self.href: str = data["href"] 80 | self.items: list[T] = data["items"] 81 | self.limit: int = data["limit"] 82 | self.next: str | None = data["next"] 83 | self.before: str | None = data.get("before") 84 | self.after: str = data["after"] 85 | 86 | def __repr__(self) -> str: 87 | return f"" 88 | -------------------------------------------------------------------------------- /spotipy/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import aiohttp 4 | 5 | from .types.errors import HTTPErrorData, AuthenticationErrorData 6 | 7 | 8 | __all__ = ( 9 | "SpotipyError", 10 | "AuthenticationError", 11 | "HTTPError", 12 | "BadRequest", 13 | "Unauthorized", 14 | "Forbidden", 15 | "NotFound", 16 | "RequestEntityTooLarge", 17 | "SpotifyServerError", 18 | "HTTPErrorMapping" 19 | ) 20 | 21 | 22 | class SpotipyError(Exception): 23 | pass 24 | 25 | 26 | class AuthenticationError(SpotipyError): 27 | 28 | def __init__( 29 | self, 30 | response: aiohttp.ClientResponse, 31 | data: AuthenticationErrorData 32 | ) -> None: 33 | self._response: aiohttp.ClientResponse = response 34 | self._error: str = data["error"] 35 | self._description: str = data["error_description"] 36 | 37 | super().__init__(self._description) 38 | 39 | @property 40 | def response(self) -> aiohttp.ClientResponse: 41 | return self._response 42 | 43 | @property 44 | def error(self) -> str: 45 | return self._error 46 | 47 | @property 48 | def description(self) -> str: 49 | return self._description 50 | 51 | 52 | class HTTPError(SpotipyError): 53 | 54 | def __init__( 55 | self, 56 | response: aiohttp.ClientResponse, 57 | data: HTTPErrorData | str 58 | ) -> None: 59 | self._response: aiohttp.ClientResponse = response 60 | self._status: int = response.status 61 | self._message: str = data["error"]["message"] if isinstance(data, dict) else (data or "") 62 | 63 | super().__init__(f"{self._status} - {response.reason}: {self._message}") 64 | 65 | @property 66 | def response(self) -> aiohttp.ClientResponse: 67 | return self._response 68 | 69 | @property 70 | def status(self) -> int: 71 | return self._status 72 | 73 | @property 74 | def message(self) -> str: 75 | return self._message 76 | 77 | 78 | class BadRequest(HTTPError): 79 | pass 80 | 81 | 82 | class Unauthorized(HTTPError): 83 | pass 84 | 85 | 86 | class Forbidden(HTTPError): 87 | pass 88 | 89 | 90 | class NotFound(HTTPError): 91 | pass 92 | 93 | 94 | class RequestEntityTooLarge(HTTPError): 95 | pass 96 | 97 | 98 | class SpotifyServerError(HTTPError): 99 | pass 100 | 101 | 102 | HTTPErrorMapping: dict[int, type[HTTPError]] = { 103 | 400: BadRequest, 104 | 401: Unauthorized, 105 | 403: Forbidden, 106 | 404: NotFound, 107 | } 108 | -------------------------------------------------------------------------------- /docs/extensions/resource_links.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://github.com/Rapptz/discord.py/blob/0bcb0d0e3ce395d42a5b1dae61b0090791ee018d/docs/extensions/resourcelinks.py 3 | 4 | The MIT License (MIT) 5 | 6 | Copyright (c) 2015-present Rapptz 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a 9 | copy of this software and associated documentation files (the "Software"), 10 | to deal in the Software without restriction, including without limitation 11 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 12 | and/or sell copies of the Software, and to permit persons to whom the 13 | Software is furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in 16 | all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 19 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 24 | DEALINGS IN THE SOFTWARE. 25 | """ 26 | 27 | from __future__ import annotations 28 | 29 | from typing import Any 30 | 31 | import sphinx 32 | from docutils import nodes, utils 33 | from docutils.nodes import Node, system_message 34 | from docutils.parsers.rst.states import Inliner 35 | from sphinx.application import Sphinx 36 | from sphinx.util.nodes import split_explicit_title 37 | from sphinx.util.typing import RoleFunction 38 | 39 | 40 | def make_link_role(resource_links: dict[str, str]) -> RoleFunction: 41 | 42 | # noinspection PyUnusedLocal 43 | def role( 44 | typ: str, 45 | rawtext: str, 46 | text: str, 47 | lineno: int, 48 | inliner: Inliner, 49 | options: dict[Any, Any] | None = None, 50 | content: list[str] | None = None 51 | ) -> tuple[list[Node], list[system_message]]: 52 | 53 | if options is None: 54 | options = {} 55 | if content is None: 56 | content = [] 57 | 58 | has_explicit_title, title, key = split_explicit_title(utils.unescape(text)) 59 | full_url = resource_links[key] 60 | 61 | if not has_explicit_title: 62 | title = full_url 63 | 64 | pnode = nodes.reference(title, title, internal=False, refuri=full_url) 65 | return [pnode], [] 66 | 67 | return role 68 | 69 | 70 | def add_link_role(app: Sphinx) -> None: 71 | app.add_role("resource", make_link_role(app.config.resource_links)) 72 | 73 | 74 | def setup(app: Sphinx) -> dict[str, Any]: 75 | app.add_config_value("resource_links", {}, "env") 76 | app.connect("builder-inited", add_link_role) 77 | return {"version": sphinx.__display_version__, "parallel_read_safe": True} 78 | -------------------------------------------------------------------------------- /spotipy/objects/playlist.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | from .base import BaseObject, BaseObjectData, PagingObjectData, PagingObject 6 | from .common import ExternalURLs 7 | from .followers import Followers, FollowersData 8 | from .image import Image, ImageData 9 | from .track import PlaylistTrack, PlaylistTrackData 10 | from .user import User, UserData 11 | 12 | 13 | __all__ = ( 14 | "PlaylistSnapshotID", 15 | "PlaylistTrackRefData", 16 | "SimplePlaylistData", 17 | "SimplePlaylist", 18 | "PlaylistData", 19 | "Playlist" 20 | ) 21 | 22 | 23 | class PlaylistSnapshotID(TypedDict): 24 | snapshot_id: str 25 | 26 | 27 | class PlaylistTrackRefData(TypedDict): 28 | href: str 29 | total: int 30 | 31 | 32 | class SimplePlaylistData(BaseObjectData): 33 | collaborative: bool 34 | description: str | None 35 | external_urls: ExternalURLs 36 | images: list[ImageData] 37 | owner: UserData 38 | primary_color: str | None 39 | public: bool | None 40 | snapshot_id: str 41 | tracks: PlaylistTrackRefData 42 | 43 | 44 | class SimplePlaylist(BaseObject): 45 | 46 | def __init__(self, data: SimplePlaylistData) -> None: 47 | super().__init__(data) 48 | 49 | self.collaborative: bool = data["collaborative"] 50 | self.description: str | None = data["description"] 51 | self.external_urls: ExternalURLs = data["external_urls"] 52 | self.images: list[Image] = [Image(image) for image in data["images"]] 53 | self.owner: User = User(data["owner"]) 54 | self.primary_color: str | None = data["primary_color"] 55 | self.public: bool | None = data["public"] 56 | self.snapshot_id: str = data["snapshot_id"] 57 | self.tracks_href: str = data["tracks"]["href"] 58 | self.total_tracks: int = data["tracks"]["total"] 59 | 60 | @property 61 | def url(self) -> str | None: 62 | return self.external_urls.get("spotify") 63 | 64 | 65 | class PlaylistData(BaseObjectData): 66 | collaborative: bool 67 | description: str | None 68 | external_urls: ExternalURLs 69 | followers: FollowersData 70 | images: list[ImageData] 71 | owner: UserData 72 | primary_color: str | None 73 | public: bool | None 74 | snapshot_id: str 75 | tracks: PagingObjectData[PlaylistTrackData] 76 | 77 | 78 | class Playlist(BaseObject): 79 | 80 | def __init__(self, data: PlaylistData) -> None: 81 | super().__init__(data) 82 | 83 | self.collaborative: bool = data["collaborative"] 84 | self.description: str | None = data["description"] 85 | self.external_urls: ExternalURLs = data["external_urls"] 86 | self.followers: Followers = Followers(data["followers"]) 87 | self.images: list[Image] = [Image(image) for image in data["images"]] 88 | self.owner: User = User(data["owner"]) 89 | self.primary_color: str | None = data["primary_color"] 90 | self.public: bool | None = data["public"] 91 | self.snapshot_id: str = data["snapshot_id"] 92 | self.total_tracks: int = data["tracks"]["total"] 93 | 94 | self._tracks_paging = PagingObject(data["tracks"]) 95 | self.tracks: list[PlaylistTrack] = [PlaylistTrack(track) for track in self._tracks_paging.items] 96 | 97 | @property 98 | def url(self) -> str | None: 99 | return self.external_urls.get("spotify") 100 | -------------------------------------------------------------------------------- /spotipy/objects/album.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing_extensions import NotRequired 4 | 5 | from .artist import SimpleArtistData, SimpleArtist 6 | from .base import BaseObjectData, BaseObject, PagingObjectData, PagingObject 7 | from .common import ExternalURLs, ExternalIDs, ReleaseDatePrecision 8 | from .copyright import CopyrightData, Copyright 9 | from .image import ImageData, Image 10 | from .restrictions import RestrictionsData, Restrictions 11 | from .track import SimpleTrack, SimpleTrackData 12 | 13 | 14 | __all__ = ( 15 | "SimpleAlbumData", 16 | "SimpleAlbum", 17 | "AlbumData", 18 | "Album" 19 | ) 20 | 21 | 22 | class SimpleAlbumData(BaseObjectData): 23 | album_type: str 24 | artists: list[SimpleArtistData] 25 | external_urls: ExternalURLs 26 | images: list[ImageData] 27 | release_date: str 28 | release_date_precision: str 29 | total_tracks: NotRequired[int] 30 | available_markets: NotRequired[list[str]] 31 | restrictions: NotRequired[RestrictionsData] 32 | 33 | 34 | class SimpleAlbum(BaseObject): 35 | 36 | def __init__(self, data: SimpleAlbumData) -> None: 37 | super().__init__(data) 38 | 39 | self.album_type: str = data["album_type"] 40 | self.artists: list[SimpleArtist] = [SimpleArtist(artist) for artist in data["artists"]] 41 | self.external_urls: ExternalURLs = data["external_urls"] 42 | self.images: list[Image] = [Image(image) for image in data["images"]] 43 | self.release_date: str = data["release_date"] 44 | self.release_data_precision: ReleaseDatePrecision = ReleaseDatePrecision(data["release_date_precision"]) 45 | self.total_tracks: int = data.get("total_tracks", -1) 46 | 47 | self.available_markets: list[str] | None = data.get("available_markets") 48 | self.restriction: Restrictions | None = Restrictions(restriction) if (restriction := data.get("restrictions")) else None 49 | 50 | @property 51 | def url(self) -> str | None: 52 | return self.external_urls.get("spotify") 53 | 54 | 55 | class AlbumData(BaseObjectData): 56 | album_type: str 57 | artists: list[SimpleArtistData] 58 | copyrights: list[CopyrightData] 59 | external_ids: ExternalIDs 60 | external_urls: ExternalURLs 61 | genres: list[str] 62 | images: list[ImageData] 63 | label: str 64 | popularity: int 65 | release_date: str 66 | release_date_precision: str 67 | total_tracks: int 68 | tracks: PagingObjectData[SimpleTrackData] 69 | available_markets: NotRequired[list[str]] 70 | restrictions: NotRequired[RestrictionsData] 71 | 72 | 73 | class Album(BaseObject): 74 | 75 | def __init__(self, data: AlbumData) -> None: 76 | super().__init__(data) 77 | 78 | self.album_type: str = data["album_type"] 79 | self.artists: list[SimpleArtist] = [SimpleArtist(artist) for artist in data["artists"]] 80 | self.copyrights: list[Copyright] = [Copyright(copyright) for copyright in data["copyrights"]] 81 | self.external_ids: ExternalIDs = data["external_ids"] 82 | self.external_urls: ExternalURLs = data["external_urls"] 83 | self.genres: list[str] = data["genres"] 84 | self.images: list[Image] = [Image(image) for image in data["images"]] 85 | self.label: str = data["label"] 86 | self.popularity: int = data["popularity"] 87 | self.release_date: str = data["release_date"] 88 | self.release_data_precision: ReleaseDatePrecision = ReleaseDatePrecision(data["release_date_precision"]) 89 | self.total_tracks: int = data["total_tracks"] 90 | 91 | self.available_markets: list[str] | None = data.get("available_markets") 92 | self.restriction: Restrictions | None = Restrictions(restriction) if (restriction := data.get("restrictions")) else None 93 | 94 | self._tracks_paging = PagingObject(data["tracks"]) 95 | self.tracks: list[SimpleTrack] = [SimpleTrack(track) for track in self._tracks_paging.items] 96 | 97 | @property 98 | def url(self) -> str | None: 99 | return self.external_urls.get("spotify") 100 | -------------------------------------------------------------------------------- /spotipy/objects/playback.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | from typing_extensions import NotRequired 6 | 7 | from .common import ExternalURLs 8 | from .device import Device, DeviceData 9 | from .track import Track, TrackData 10 | 11 | 12 | __all__ = ( 13 | "ActionsData", 14 | "Actions", 15 | "ContextData", 16 | "Context", 17 | "PlaybackStateData", 18 | "PlaybackState", 19 | "CurrentlyPlayingData", 20 | "CurrentlyPlaying", 21 | ) 22 | 23 | 24 | class ActionsData(TypedDict): 25 | interrupting_playback: NotRequired[bool] 26 | pausing: NotRequired[bool] 27 | resuming: NotRequired[bool] 28 | seeking: NotRequired[bool] 29 | skipping_next: NotRequired[bool] 30 | skipping_prev: NotRequired[bool] 31 | toggling_repeat_context: NotRequired[bool] 32 | toggling_repeat_track: NotRequired[bool] 33 | toggling_shuffle: NotRequired[bool] 34 | transferring_playback: NotRequired[bool] 35 | 36 | 37 | class Actions: 38 | 39 | def __init__(self, data: ActionsData) -> None: 40 | self.interrupting_playback: bool = data.get("interrupting_playback", False) 41 | self.pausing: bool = data.get("pausing", False) 42 | self.resuming: bool = data.get("resuming", False) 43 | self.seeking: bool = data.get("seeking", False) 44 | self.skipping_next: bool = data.get("skipping_next", False) 45 | self.skipping_previous: bool = data.get("skipping_prev", False) 46 | self.toggling_repeat_context: bool = data.get("toggling_repeat_context", False) 47 | self.toggling_repeat_track: bool = data.get("toggling_repeat_track", False) 48 | self.toggling_shuffle: bool = data.get("toggling_shuffle", False) 49 | self.transferring_playback: bool = data.get("transferring_playback", False) 50 | 51 | def __repr__(self) -> str: 52 | return f"" 53 | 54 | 55 | class ContextData(TypedDict): 56 | external_urls: ExternalURLs 57 | href: str 58 | type: str 59 | uri: str 60 | 61 | 62 | class Context: 63 | 64 | def __init__(self, data: ContextData) -> None: 65 | self.external_urls: ExternalURLs = data["external_urls"] 66 | self.href: str = data["href"] 67 | self.type: str = data["type"] 68 | self.uri: str = data["uri"] 69 | 70 | def __repr__(self) -> str: 71 | return f"" 72 | 73 | 74 | class PlaybackStateData(TypedDict): 75 | actions: ActionsData 76 | context: ContextData 77 | currently_playing_type: str 78 | device: DeviceData 79 | is_playing: bool 80 | item: TrackData | None 81 | progress_ms: int 82 | repeat_state: str 83 | shuffle_state: str 84 | timestamp: int 85 | 86 | 87 | class PlaybackState: 88 | 89 | def __init__(self, data: PlaybackStateData) -> None: 90 | self.actions: Actions = Actions(data["actions"]) 91 | self.context: Context = Context(data["context"]) 92 | self.currently_playing_type: str = data["currently_playing_type"] 93 | self.device: Device = Device(data["device"]) 94 | self.is_playing: bool = data["is_playing"] 95 | self.item: Track | None = Track(item) if (item := data["item"]) else None 96 | self.progress_ms: int = data["progress_ms"] 97 | self.repeat_state: str = data["repeat_state"] 98 | self.shuffle_state: str = data["shuffle_state"] 99 | self.timestamp: int = data["timestamp"] 100 | 101 | def __repr__(self) -> str: 102 | return f"" 103 | 104 | 105 | class CurrentlyPlayingData(TypedDict): 106 | context: ContextData 107 | currently_playing_type: str 108 | is_playing: bool 109 | item: TrackData | None 110 | progress_ms: int 111 | timestamp: int 112 | 113 | 114 | class CurrentlyPlaying: 115 | 116 | def __init__(self, data: CurrentlyPlayingData) -> None: 117 | self.context: Context = Context(data["context"]) 118 | self.currently_playing_type: str = data["currently_playing_type"] 119 | self.is_playing: bool = data["is_playing"] 120 | self.item: Track | None = Track(item) if (item := data["item"]) else None 121 | self.progress_ms: int = data["progress_ms"] 122 | self.timestamp: int = data["timestamp"] 123 | 124 | def __repr__(self) -> str: 125 | return f"" 126 | -------------------------------------------------------------------------------- /spotipy/objects/episode.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | from typing_extensions import NotRequired 6 | 7 | from .base import BaseObject, BaseObjectData 8 | from .common import ExternalURLs, ReleaseDatePrecision 9 | from .image import Image, ImageData 10 | from .restrictions import Restrictions, RestrictionsData 11 | from .show import ShowData, Show 12 | 13 | 14 | __all__ = ( 15 | "EpisodeResumePointData", 16 | "EpisodeResumePoint", 17 | "SimpleEpisodeData", 18 | "SimpleEpisode", 19 | "EpisodeData", 20 | "Episode", 21 | ) 22 | 23 | 24 | class EpisodeResumePointData(TypedDict): 25 | fully_played: bool 26 | resume_position_ms: int 27 | 28 | 29 | class EpisodeResumePoint: 30 | 31 | def __init__(self, data: EpisodeResumePointData) -> None: 32 | self.fully_played: bool = data["fully_played"] 33 | self.resume_position_ms: int = data["resume_position_ms"] 34 | 35 | def __repr__(self) -> str: 36 | return f"" 38 | 39 | 40 | class SimpleEpisodeData(BaseObjectData): 41 | audio_preview_url: str | None 42 | description: str 43 | duration_ms: int 44 | explicit: bool 45 | external_urls: ExternalURLs 46 | html_description: str 47 | images: list[ImageData] 48 | is_externally_hosted: bool 49 | is_playable: bool 50 | languages: list[str] 51 | release_date: str 52 | release_date_precision: str 53 | restrictions: NotRequired[RestrictionsData] 54 | resume_point: NotRequired[EpisodeResumePointData] 55 | 56 | 57 | class SimpleEpisode(BaseObject): 58 | 59 | def __init__(self, data: SimpleEpisodeData) -> None: 60 | super().__init__(data) 61 | 62 | self.audio_preview_url: str | None = data["audio_preview_url"] 63 | self.description: str = data["description"] 64 | self.duration_ms: int = data["duration_ms"] 65 | self.explicit: bool = data["explicit"] 66 | self.external_urls: ExternalURLs = data["external_urls"] 67 | self.html_description: str = data["html_description"] 68 | self.images: list[Image] = [Image(image) for image in data["images"]] 69 | self.is_externally_hosted: bool = data["is_externally_hosted"] 70 | self.is_playable: bool = data["is_playable"] 71 | self.languages: list[str] = data["languages"] 72 | self.release_date: str = data["release_date"] 73 | self.release_data_precision: ReleaseDatePrecision = ReleaseDatePrecision(data["release_date_precision"]) 74 | 75 | self.restriction: Restrictions | None = Restrictions(restriction) if (restriction := data.get("restrictions")) else None 76 | self.resume_point: EpisodeResumePoint | None = EpisodeResumePoint(resume_point) if (resume_point := data.get("resume_point")) else None 77 | 78 | @property 79 | def url(self) -> str | None: 80 | return self.external_urls.get("spotify") 81 | 82 | 83 | class EpisodeData(BaseObjectData): 84 | audio_preview_url: str | None 85 | description: str 86 | duration_ms: int 87 | explicit: bool 88 | external_urls: ExternalURLs 89 | html_description: str 90 | images: list[ImageData] 91 | is_externally_hosted: bool 92 | is_playable: bool 93 | languages: list[str] 94 | release_date: str 95 | release_date_precision: str 96 | show: ShowData 97 | restrictions: NotRequired[RestrictionsData] 98 | resume_point: NotRequired[EpisodeResumePointData] 99 | 100 | 101 | class Episode(BaseObject): 102 | 103 | def __init__(self, data: EpisodeData) -> None: 104 | super().__init__(data) 105 | 106 | self.audio_preview_url: str | None = data["audio_preview_url"] 107 | self.description: str = data["description"] 108 | self.duration_ms: int = data["duration_ms"] 109 | self.explicit: bool = data["explicit"] 110 | self.external_urls: ExternalURLs = data["external_urls"] 111 | self.html_description: str = data["html_description"] 112 | self.images: list[Image] = [Image(image) for image in data["images"]] 113 | self.is_externally_hosted: bool = data["is_externally_hosted"] 114 | self.is_playable: bool = data["is_playable"] 115 | self.languages: list[str] = data["languages"] 116 | self.release_date: str = data["release_date"] 117 | self.release_data_precision: ReleaseDatePrecision = ReleaseDatePrecision(data["release_date_precision"]) 118 | self.show: Show = Show(data["show"]) 119 | 120 | self.restriction: Restrictions | None = Restrictions(restriction) if (restriction := data.get("restrictions")) else None 121 | self.resume_point: EpisodeResumePoint | None = EpisodeResumePoint(resume_point) if (resume_point := data.get("resume_point")) else None 122 | 123 | @property 124 | def url(self) -> str | None: 125 | return self.external_urls.get("spotify") 126 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import re 5 | import sys 6 | from typing import Any 7 | 8 | 9 | _DISCORD: str = "https://discord.com/invite/w9f6NkQbde" 10 | _DISCORD_SVG: str = """ 11 | 13 | 16 | 25 | 26 | """ 27 | 28 | _GITHUB: str = "https://github.com/Axelware/spoti-py" 29 | _GITHUB_SVG: str = """ 30 | 31 | 37 | 38 | """ 39 | 40 | 41 | ####################### 42 | # Project Information # 43 | ####################### 44 | 45 | project: str = "spoti-py" 46 | copyright: str = "2021 - Present, Aaron Hennessey" 47 | author: str = "Aaron Hennessey" 48 | 49 | with open(os.path.abspath(os.path.join(os.path.dirname(__file__), "../spotipy/__init__.py"))) as file: 50 | 51 | if not (match := re.search(r"^__version__: [^=]* = \"([^\"]*)\"", file.read(), re.MULTILINE)): 52 | raise RuntimeError 53 | 54 | _VERSION: str = match[1] 55 | version: str = _VERSION 56 | release: str = _VERSION 57 | 58 | 59 | ######################### 60 | # General Configuration # 61 | ######################### 62 | 63 | sys.path.insert(0, os.path.abspath("..")) 64 | sys.path.append(os.path.abspath("extensions")) 65 | 66 | extensions: list[str] = [ 67 | "sphinx.ext.autodoc", 68 | "sphinx.ext.napoleon", 69 | "sphinx.ext.intersphinx", 70 | "sphinx.ext.extlinks", 71 | "sphinxcontrib_trio", 72 | "resource_links", 73 | "sphinx_copybutton", 74 | "sphinx_inline_tabs" 75 | ] 76 | exclude_patters: list[str] = [ 77 | "_build", 78 | "Thumbs.db", 79 | ".DS_Store" 80 | ] 81 | 82 | needs_sphinx: str = "5.1.1" 83 | nitpicky: bool = True 84 | 85 | 86 | ########################### 87 | # Options for HTML output # 88 | ########################### 89 | 90 | html_theme: str = "furo" 91 | 92 | html_theme_options: dict[str, Any] = { 93 | "footer_icons": [ 94 | { 95 | "name": "Discord", 96 | "url": _DISCORD, 97 | "html": _DISCORD_SVG, 98 | "class": "", 99 | }, 100 | { 101 | "name": "GitHub", 102 | "url": _GITHUB, 103 | "html": _GITHUB_SVG, 104 | "class": "", 105 | }, 106 | ], 107 | } 108 | html_title: str = "spotipy" 109 | 110 | html_css_files: list[str] = [ 111 | "custom.css", 112 | ] 113 | html_static_path: list[str] = [ 114 | "static" 115 | ] 116 | 117 | 118 | ############## 119 | # Extensions # 120 | ############## 121 | 122 | # autodoc 123 | autoclass_content: str = "class" 124 | autodoc_class_signature: str = "mixed" 125 | autodoc_member_order: str = "bysource" 126 | autodoc_default_options: dict[str, Any] = { 127 | "undoc-members": True 128 | } 129 | autodoc_typehints: str = "signature" 130 | autodoc_type_aliases: dict[str, str] = {} 131 | autodoc_typehints_format: str = "short" 132 | 133 | # napoleon 134 | napoleon_use_admonition_for_examples: bool = True 135 | napoleon_use_admonition_for_notes: bool = True 136 | 137 | # intersphinx 138 | intersphinx_mapping: dict[str, tuple[str, None]] = { 139 | "aiohttp": ("https://docs.aiohttp.org/en/stable/", None), 140 | "python": ("https://docs.python.org/3.10", None), 141 | } 142 | 143 | # ext links 144 | extlinks: dict[str, tuple[str, str]] = { 145 | "issue": (f"{_GITHUB}/issues/%s", "GH-"), 146 | } 147 | extlinks_detect_hardcoded_links: bool = True 148 | 149 | # resource links 150 | resource_links: dict[str, str] = { 151 | "github": _GITHUB, 152 | "issues": f"{_GITHUB}/issues", 153 | "discussions": f"{_GITHUB}/discussions", 154 | 155 | "examples": f"{_GITHUB}/tree/main/examples", 156 | 157 | "discord": _DISCORD, 158 | } 159 | -------------------------------------------------------------------------------- /spotipy/objects/credentials.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | from typing import TypedDict, ClassVar 5 | 6 | import aiohttp 7 | 8 | from ..errors import AuthenticationError 9 | 10 | 11 | __all__ = ( 12 | "ClientCredentialsData", 13 | "ClientCredentials", 14 | "UserCredentialsData", 15 | "UserCredentials" 16 | ) 17 | 18 | 19 | class ClientCredentialsData(TypedDict): 20 | access_token: str 21 | token_type: str 22 | expires_in: int 23 | 24 | 25 | class ClientCredentials: 26 | 27 | TOKEN_URL: ClassVar[str] = "https://accounts.spotify.com/api/token" 28 | 29 | def __init__(self, data: ClientCredentialsData, client_id: str, client_secret: str) -> None: 30 | 31 | self._access_token: str = data["access_token"] 32 | self._token_type: str = data["token_type"] 33 | self._expires_in: int = data["expires_in"] 34 | 35 | self._client_id: str = client_id 36 | self._client_secret: str = client_secret 37 | 38 | self._last_authorized_time: float = time.time() 39 | 40 | def __repr__(self) -> str: 41 | return f"" 42 | 43 | # properties 44 | 45 | @property 46 | def access_token(self) -> str: 47 | return self._access_token 48 | 49 | @property 50 | def token_type(self) -> str: 51 | return self._token_type 52 | 53 | @property 54 | def expires_in(self) -> int: 55 | return self._expires_in 56 | 57 | # methods 58 | 59 | def is_expired(self) -> bool: 60 | return (time.time() - self._last_authorized_time) >= self.expires_in 61 | 62 | @classmethod 63 | async def from_client_details( 64 | cls, 65 | client_id: str, 66 | client_secret: str, 67 | *, 68 | session: aiohttp.ClientSession 69 | ) -> ClientCredentials: 70 | 71 | data = { 72 | "grant_type": "client_credentials", 73 | "client_id": client_id, 74 | "client_secret": client_secret 75 | } 76 | 77 | async with session.post(cls.TOKEN_URL, data=data) as response: 78 | 79 | data = await response.json() 80 | if "error" in data: 81 | raise AuthenticationError(response, data) 82 | 83 | return cls(data, client_id, client_secret) 84 | 85 | async def refresh(self, session: aiohttp.ClientSession) -> None: 86 | 87 | data = { 88 | "grant_type": "client_credentials", 89 | "client_id": self._client_id, 90 | "client_secret": self._client_secret 91 | } 92 | 93 | async with session.post(self.TOKEN_URL, data=data) as response: 94 | 95 | data = await response.json() 96 | if "error" in data: 97 | raise AuthenticationError(response, data) 98 | 99 | self._access_token = data["access_token"] 100 | self._token_type = data["token_type"] 101 | self._expires_in = data["expires_in"] 102 | 103 | self._last_authorized_time = time.time() 104 | 105 | 106 | class UserCredentialsData(TypedDict): 107 | access_token: str 108 | token_type: str 109 | expires_in: int 110 | scope: str 111 | refresh_token: str 112 | 113 | 114 | class UserCredentials: 115 | 116 | TOKEN_URL: ClassVar[str] = "https://accounts.spotify.com/api/token" 117 | 118 | def __init__(self, data: UserCredentialsData, client_id: str, client_secret: str) -> None: 119 | 120 | self._access_token: str = data["access_token"] 121 | self._token_type: str = data["token_type"] 122 | self._expires_in: int = data["expires_in"] 123 | self._scope: str = data["scope"] 124 | self._refresh_token: str = data["refresh_token"] 125 | 126 | self._client_id: str = client_id 127 | self._client_secret: str = client_secret 128 | 129 | self._last_authorized_time: float = time.time() 130 | 131 | def __repr__(self) -> str: 132 | return f"" 133 | 134 | # properties 135 | 136 | @property 137 | def access_token(self) -> str: 138 | return self._access_token 139 | 140 | @property 141 | def token_type(self) -> str: 142 | return self._token_type 143 | 144 | @property 145 | def scope(self) -> str: 146 | return self._scope 147 | 148 | @property 149 | def expires_in(self) -> int: 150 | return self._expires_in 151 | 152 | # methods 153 | 154 | def is_expired(self) -> bool: 155 | return (time.time() - self._last_authorized_time) >= self.expires_in 156 | 157 | @classmethod 158 | async def from_refresh_token( 159 | cls, 160 | client_id: str, 161 | client_secret: str, 162 | *, 163 | session: aiohttp.ClientSession, 164 | refresh_token: str, 165 | ) -> UserCredentials: 166 | 167 | data = { 168 | "client_id": client_id, 169 | "client_secret": client_secret, 170 | "grant_type": "refresh_token", 171 | "refresh_token": refresh_token, 172 | } 173 | 174 | async with session.post(cls.TOKEN_URL, data=data) as response: 175 | 176 | data = await response.json() 177 | if "error" in data: 178 | raise AuthenticationError(response, data=data) 179 | if "refresh_token" not in data: 180 | data["refresh_token"] = refresh_token 181 | 182 | return cls(data, client_id, client_secret) 183 | 184 | async def refresh(self, session: aiohttp.ClientSession) -> None: 185 | 186 | data = { 187 | "client_id": self._client_id, 188 | "client_secret": self._client_secret, 189 | "grant_type": "refresh_token", 190 | "refresh_token": self._refresh_token, 191 | } 192 | 193 | async with session.post(self.TOKEN_URL, data=data) as response: 194 | 195 | data = await response.json() 196 | if "error" in data: 197 | raise AuthenticationError(response, data=data) 198 | 199 | self._access_token = data["access_token"] 200 | self._token_type = data["token_type"] 201 | self._scope = data["scope"] 202 | self._expires_in = data["expires_in"] 203 | 204 | self._last_authorized_time = time.time() 205 | -------------------------------------------------------------------------------- /spotipy/objects/track.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | from typing import TypedDict, Any, Literal 5 | 6 | from typing_extensions import NotRequired 7 | 8 | from . import album 9 | from .artist import SimpleArtist, SimpleArtistData 10 | from .base import BaseObject, BaseObjectData 11 | from .common import ExternalURLs, ExternalIDs 12 | from .user import User, UserData 13 | from .restrictions import Restrictions, RestrictionsData 14 | 15 | 16 | __all__ = ( 17 | "Key", 18 | "Mode", 19 | "AudioFeaturesData", 20 | "AudioFeatures", 21 | "TrackLinkData", 22 | "TrackLink", 23 | "SimpleTrackData", 24 | "SimpleTrack", 25 | "TrackData", 26 | "Track", 27 | "PlaylistTrackData", 28 | "PlaylistTrack", 29 | ) 30 | 31 | 32 | class Key(enum.Enum): 33 | UNKNOWN = -1 34 | C = 0 35 | C_SHARP = 1 36 | D = 2 37 | D_SHARP = 3 38 | E = 4 39 | E_SHARP = 5 40 | F = 5 41 | F_SHARP = 6 42 | G = 7 43 | G_SHARP = 8 44 | A = 9 45 | A_SHARP = 10 46 | B = 11 47 | 48 | 49 | class Mode(enum.Enum): 50 | MINOR = 0 51 | MAJOR = 1 52 | 53 | 54 | class AudioFeaturesData(TypedDict): 55 | acousticness: float 56 | analysis_url: str 57 | danceability: float 58 | duration_ms: int 59 | energy: float 60 | id: str 61 | instrumentalness: float 62 | key: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, -1] 63 | liveness: float 64 | loudness: float 65 | mode: Literal[1, 2] 66 | speechiness: float 67 | tempo: float 68 | time_signature: Literal[3, 4, 5, 6, 7] 69 | track_href: str 70 | type: Literal["audio_features"] 71 | uri: str 72 | valence: float 73 | 74 | 75 | class AudioFeatures: 76 | 77 | def __init__(self, data: AudioFeaturesData) -> None: 78 | self.acousticness: float = data["acousticness"] 79 | self.analysis_url: str = data["analysis_url"] 80 | self.danceability: float = data["danceability"] 81 | self.duration_ms: int = data["duration_ms"] 82 | self.energy: float = data["energy"] 83 | self.id: str = data["id"] 84 | self.instrumentalness: float = data["instrumentalness"] 85 | self.key: Key = Key(data["key"]) 86 | self.liveness: float = data["liveness"] 87 | self.loudness: float = data["loudness"] 88 | self.mode: Mode = Mode(data["mode"]) 89 | self.speechiness: float = data["speechiness"] 90 | self.tempo: float = data["tempo"] 91 | self.time_signature: int = data["time_signature"] 92 | self.track_href: str = data["track_href"] 93 | self.type: str = data["type"] 94 | self.uri: str = data["uri"] 95 | self.valence: float = data["valence"] 96 | 97 | def __repr__(self) -> str: 98 | return f"" 99 | 100 | 101 | class TrackLinkData(BaseObjectData): 102 | external_urls: ExternalURLs 103 | 104 | 105 | class TrackLink(BaseObject): 106 | 107 | def __init__(self, data: TrackLinkData) -> None: 108 | super().__init__(data) 109 | self.external_urls: ExternalURLs = data["external_urls"] 110 | 111 | @property 112 | def url(self) -> str | None: 113 | return self.external_urls.get("spotify") 114 | 115 | 116 | class SimpleTrackData(BaseObjectData): 117 | artists: list[SimpleArtistData] 118 | disc_number: int 119 | duration_ms: int 120 | explicit: bool 121 | external_urls: ExternalURLs 122 | is_local: bool 123 | preview_url: str 124 | track_number: int 125 | available_markets: NotRequired[list[str]] 126 | is_playable: NotRequired[bool] 127 | linked_from: NotRequired[TrackLinkData] 128 | restrictions: NotRequired[RestrictionsData] 129 | 130 | 131 | class SimpleTrack(BaseObject): 132 | 133 | def __init__(self, data: SimpleTrackData) -> None: 134 | super().__init__(data) 135 | 136 | self.artists: list[SimpleArtist] = [SimpleArtist(artist_data) for artist_data in data["artists"]] 137 | self.disc_number: int = data["disc_number"] 138 | self.duration_ms: int = data["duration_ms"] 139 | self.explicit: bool = data["explicit"] 140 | self.external_urls: ExternalURLs = data["external_urls"] 141 | self.is_local: bool = data["is_local"] 142 | self.preview_url: str = data["preview_url"] 143 | self.track_number: int = data["track_number"] 144 | 145 | self.available_markets: list[str] | None = data.get("available_markets") 146 | self.is_playable: bool | None = data.get("is_playable") 147 | self.linked_from: TrackLink | None = TrackLink(linked_from) if (linked_from := data.get("linked_from")) else None 148 | self.restriction: Restrictions | None = Restrictions(restriction) if (restriction := data.get("restrictions")) else None 149 | 150 | @property 151 | def url(self) -> str | None: 152 | return self.external_urls.get("spotify") 153 | 154 | 155 | class TrackData(BaseObjectData): 156 | album: album.SimpleAlbumData 157 | artists: list[SimpleArtistData] 158 | disc_number: int 159 | duration_ms: int 160 | explicit: bool 161 | external_ids: ExternalIDs 162 | external_urls: ExternalURLs 163 | is_local: bool 164 | popularity: int 165 | preview_url: str 166 | track_number: int 167 | available_markets: NotRequired[list[str]] 168 | is_playable: NotRequired[bool] 169 | linked_from: NotRequired[TrackLinkData] 170 | restrictions: NotRequired[RestrictionsData] 171 | 172 | 173 | class Track(BaseObject): 174 | 175 | def __init__(self, data: TrackData) -> None: 176 | super().__init__(data) 177 | 178 | self.album: album.SimpleAlbum = album.SimpleAlbum(data["album"]) 179 | self.artists: list[SimpleArtist] = [SimpleArtist(artist_data) for artist_data in data["artists"]] 180 | self.disc_number: int = data["disc_number"] 181 | self.duration_ms: int = data["duration_ms"] 182 | self.explicit: bool = data["explicit"] 183 | self.external_ids: ExternalIDs = data["external_ids"] 184 | self.external_urls: ExternalURLs = data["external_urls"] 185 | self.is_local: bool = data["is_local"] 186 | self.popularity: int = data["popularity"] 187 | self.preview_url: str = data["preview_url"] 188 | self.track_number: int = data["track_number"] 189 | 190 | self.available_markets: list[str] | None = data.get("available_markets") 191 | self.is_playable: bool | None = data.get("is_playable") 192 | self.linked_from: TrackLink | None = TrackLink(linked_from) if (linked_from := data.get("linked_from")) else None 193 | self.restriction: Restrictions | None = Restrictions(restriction) if (restriction := data.get("restrictions")) else None 194 | 195 | @property 196 | def url(self) -> str | None: 197 | return self.external_urls.get("spotify") 198 | 199 | 200 | class PlaylistTrackData(BaseObjectData): 201 | added_at: str 202 | added_by: UserData 203 | is_local: bool 204 | primary_color: Any 205 | video_thumbnail: Any 206 | track: TrackData 207 | 208 | 209 | class PlaylistTrack(BaseObject): 210 | 211 | def __init__(self, data: PlaylistTrackData) -> None: 212 | super().__init__(data["track"]) 213 | 214 | self.added_at: str = data["added_at"] 215 | self.added_by: User = User(data["added_by"]) 216 | self.is_local: bool = data["is_local"] 217 | self.primary_colour: Any = data["primary_color"] 218 | self.video_thumbnail: Any = data["video_thumbnail"]["url"] 219 | 220 | track = data["track"] 221 | self.album: album.SimpleAlbum = album.SimpleAlbum(track["album"]) 222 | self.artists: list[SimpleArtist] = [SimpleArtist(artist_data) for artist_data in track["artists"]] 223 | self.disc_number: int = track["disc_number"] 224 | self.duration_ms: int = track["duration_ms"] 225 | self.explicit: bool = track["explicit"] 226 | self.external_ids: ExternalIDs = track["external_ids"] 227 | self.external_urls: ExternalURLs = track["external_urls"] 228 | self.is_local: bool = track["is_local"] 229 | self.popularity: int = track["popularity"] 230 | self.preview_url: str = track["preview_url"] 231 | self.track_number: int = track["track_number"] 232 | 233 | self.available_markets: list[str] | None = track.get("available_markets") 234 | self.is_playable: bool | None = track.get("is_playable") 235 | self.linked_from: TrackLink | None = TrackLink(linked_from) if (linked_from := track.get("linked_from")) else None 236 | self.restriction: Restrictions | None = Restrictions(restriction) if (restriction := track.get("restrictions")) else None 237 | 238 | @property 239 | def url(self) -> str | None: 240 | return self.external_urls.get("spotify") 241 | -------------------------------------------------------------------------------- /spotipy/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | from collections.abc import Sequence 5 | from typing import TypeVar 6 | 7 | import aiohttp 8 | 9 | from .enums import IncludeGroup, SearchType, TimeRange 10 | from .http import HTTPClient 11 | from .objects.album import Album, SimpleAlbum 12 | from .objects.artist import Artist 13 | from .objects.base import PagingObject 14 | from .objects.category import Category 15 | from .objects.credentials import UserCredentials 16 | from .objects.episode import SimpleEpisode, Episode 17 | from .objects.image import Image 18 | from .objects.playlist import Playlist, SimplePlaylist 19 | from .objects.recommendation import Recommendations 20 | from .objects.search import SearchResult 21 | from .objects.show import Show 22 | from .objects.track import SimpleTrack, Track, AudioFeatures, PlaylistTrack 23 | from .objects.user import User 24 | from .types.common import AnyCredentials 25 | 26 | 27 | __all__ = ( 28 | "Client", 29 | ) 30 | 31 | 32 | ID = TypeVar("ID", bound=str) 33 | 34 | 35 | class Client: 36 | 37 | def __init__( 38 | self, 39 | client_id: str, 40 | client_secret: str, 41 | session: aiohttp.ClientSession | None = None 42 | ) -> None: 43 | 44 | self._client_id: str = client_id 45 | self._client_secret: str = client_secret 46 | self._session: aiohttp.ClientSession | None = session 47 | 48 | self.http: HTTPClient = HTTPClient( 49 | client_id=self._client_id, 50 | client_secret=self._client_secret, 51 | session=self._session 52 | ) 53 | 54 | def __repr__(self) -> str: 55 | return f"" 56 | 57 | # ALBUMS API 58 | 59 | async def get_album( 60 | self, 61 | _id: str, 62 | /, *, 63 | market: str | None = None, 64 | credentials: AnyCredentials | None = None, 65 | ) -> Album: 66 | response = await self.http.get_album(_id, market=market, credentials=credentials) 67 | return Album(response) 68 | 69 | async def get_albums( 70 | self, 71 | ids: Sequence[ID], 72 | *, 73 | market: str | None = None, 74 | credentials: AnyCredentials | None = None, 75 | ) -> dict[ID, Album | None]: 76 | response = await self.http.get_multiple_albums(ids, market=market, credentials=credentials) 77 | return dict(zip(ids, [Album(data) if data else None for data in response["albums"]])) 78 | 79 | async def get_album_tracks( 80 | self, 81 | _id: str, 82 | /, *, 83 | market: str | None = None, 84 | limit: int | None = None, 85 | offset: int | None = None, 86 | credentials: AnyCredentials | None = None, 87 | ) -> list[SimpleTrack]: 88 | response = await self.http.get_album_tracks( 89 | _id, 90 | market=market, 91 | limit=limit, 92 | offset=offset, 93 | credentials=credentials 94 | ) 95 | return [SimpleTrack(data) for data in PagingObject(response).items] 96 | 97 | async def get_all_album_tracks( 98 | self, 99 | _id: str, 100 | /, *, 101 | market: str | None = None, 102 | credentials: AnyCredentials | None = None, 103 | ) -> list[SimpleTrack]: 104 | 105 | response = await self.http.get_album_tracks(_id, market=market, limit=50, offset=0, credentials=credentials) 106 | paging = PagingObject(response) 107 | 108 | tracks = [SimpleTrack(data) for data in paging.items] 109 | 110 | if paging.total <= 50: 111 | return tracks 112 | 113 | for _ in range(1, math.ceil(paging.total / 50)): 114 | response = await self.http.get_album_tracks( 115 | _id, 116 | market=market, 117 | limit=50, 118 | offset=_ * 50, 119 | credentials=credentials 120 | ) 121 | tracks.extend([SimpleTrack(data) for data in PagingObject(response).items]) 122 | 123 | return tracks 124 | 125 | async def get_full_album( 126 | self, 127 | _id: str, 128 | /, *, 129 | market: str | None = None, 130 | credentials: AnyCredentials | None = None, 131 | ) -> Album: 132 | 133 | album = await self.get_album(_id, market=market, credentials=credentials) 134 | 135 | if album._tracks_paging.total <= 50: 136 | return album 137 | 138 | for i in range(2, math.ceil(album._tracks_paging.total / 50)): 139 | response = await self.http.get_album_tracks( 140 | _id, 141 | market=market, 142 | limit=50, 143 | offset=i * 50, 144 | credentials=credentials 145 | ) 146 | album.tracks.extend([SimpleTrack(data) for data in PagingObject(response).items]) 147 | 148 | return album 149 | 150 | async def get_saved_albums(self) -> ...: 151 | raise NotImplementedError 152 | 153 | async def save_albums(self) -> ...: 154 | raise NotImplementedError 155 | 156 | async def remove_albums(self) -> ...: 157 | raise NotImplementedError 158 | 159 | async def check_saved_albums(self) -> ...: 160 | raise NotImplementedError 161 | 162 | async def get_new_releases( 163 | self, 164 | *, 165 | country: str | None = None, 166 | limit: int | None = None, 167 | offset: int | None = None, 168 | credentials: AnyCredentials | None = None, 169 | ) -> list[SimpleAlbum]: 170 | 171 | response = await self.http.get_new_releases( 172 | country=country, 173 | limit=limit, 174 | offset=offset, 175 | credentials=credentials 176 | ) 177 | return [SimpleAlbum(data) for data in PagingObject(response["albums"]).items] 178 | 179 | # ARTISTS API 180 | 181 | async def get_artist( 182 | self, 183 | _id: str, 184 | /, *, 185 | market: str | None = None, 186 | credentials: AnyCredentials | None = None, 187 | ) -> Artist: 188 | 189 | response = await self.http.get_artist(_id, market=market, credentials=credentials) 190 | return Artist(response) 191 | 192 | async def get_artists( 193 | self, 194 | ids: Sequence[ID], 195 | *, 196 | market: str | None = None, 197 | credentials: AnyCredentials | None = None, 198 | ) -> dict[ID, Artist | None]: 199 | 200 | response = await self.http.get_multiple_artists(ids, market=market, credentials=credentials) 201 | return dict(zip(ids, [Artist(data) if data else None for data in response["artists"]])) 202 | 203 | async def get_artist_albums( 204 | self, 205 | _id: str, 206 | /, *, 207 | market: str | None = None, 208 | include_groups: list[IncludeGroup] | None = None, 209 | limit: int | None = None, 210 | offset: int | None = None, 211 | credentials: AnyCredentials | None = None, 212 | ) -> list[SimpleAlbum]: 213 | 214 | if include_groups is None: 215 | include_groups = [IncludeGroup.ALBUM] 216 | 217 | response = await self.http.get_artist_albums( 218 | _id, 219 | market=market, 220 | include_groups=include_groups, 221 | limit=limit, 222 | offset=offset, 223 | credentials=credentials 224 | ) 225 | return [SimpleAlbum(data) for data in PagingObject(response).items] 226 | 227 | async def get_all_artist_albums( 228 | self, 229 | _id: str, 230 | /, *, 231 | market: str | None = None, 232 | include_groups: list[IncludeGroup] | None = None, 233 | credentials: AnyCredentials | None = None, 234 | ) -> list[SimpleAlbum]: 235 | 236 | if include_groups is None: 237 | include_groups = [IncludeGroup.ALBUM] 238 | 239 | response = await self.http.get_artist_albums( 240 | _id, 241 | market=market, 242 | include_groups=include_groups, 243 | limit=50, 244 | offset=0, 245 | credentials=credentials 246 | ) 247 | paging = PagingObject(response) 248 | 249 | albums = [SimpleAlbum(data) for data in paging.items] 250 | 251 | if paging.total <= 50: # There are 50 or fewer tracks, and we already have them so just return them 252 | return albums 253 | 254 | for _ in range(1, math.ceil(paging.total / 50)): 255 | response = await self.http.get_artist_albums( 256 | _id, 257 | market=market, 258 | include_groups=include_groups, 259 | limit=50, 260 | offset=_ * 50, 261 | credentials=credentials 262 | ) 263 | albums.extend([SimpleAlbum(data) for data in PagingObject(response).items]) 264 | 265 | return albums 266 | 267 | async def get_artist_top_tracks( 268 | self, 269 | _id: str, 270 | /, *, 271 | market: str, 272 | credentials: AnyCredentials | None = None, 273 | ) -> list[Track]: 274 | 275 | response = await self.http.get_artist_top_tracks(_id, market=market, credentials=credentials) 276 | return [Track(data) for data in response["tracks"]] 277 | 278 | async def get_related_artists( 279 | self, 280 | _id: str, 281 | /, *, 282 | credentials: AnyCredentials | None = None, 283 | ) -> list[Artist]: 284 | 285 | response = await self.http.get_related_artists(_id, credentials=credentials) 286 | return [Artist(data) for data in response["artists"]] 287 | 288 | # SHOWS API 289 | 290 | async def get_show( 291 | self, 292 | _id: str, 293 | /, *, 294 | market: str, 295 | credentials: AnyCredentials | None = None, 296 | ) -> Show: 297 | 298 | response = await self.http.get_show(_id, market=market, credentials=credentials) 299 | return Show(response) 300 | 301 | async def get_shows( 302 | self, 303 | ids: Sequence[ID], 304 | *, 305 | market: str, 306 | credentials: AnyCredentials | None = None, 307 | ) -> dict[ID, Show | None]: 308 | 309 | response = await self.http.get_multiple_shows(ids, market=market, credentials=credentials) 310 | return dict(zip(ids, [Show(data) if data else None for data in response["shows"]])) 311 | 312 | async def get_show_episodes( 313 | self, 314 | _id: str, 315 | /, *, 316 | market: str, 317 | limit: int | None = None, 318 | offset: int | None = None, 319 | credentials: AnyCredentials | None = None, 320 | ) -> list[SimpleEpisode]: 321 | 322 | response = await self.http.get_show_episodes( 323 | _id, 324 | market=market, 325 | limit=limit, 326 | offset=offset, 327 | credentials=credentials 328 | ) 329 | return [SimpleEpisode(data) for data in PagingObject(response).items] 330 | 331 | async def get_all_show_episodes( 332 | self, 333 | _id: str, 334 | /, *, 335 | market: str, 336 | credentials: AnyCredentials | None = None, 337 | ) -> list[SimpleEpisode]: 338 | 339 | response = await self.http.get_show_episodes(_id, market=market, limit=50, offset=0, credentials=credentials) 340 | paging = PagingObject(response) 341 | 342 | episodes = [SimpleEpisode(data) for data in paging.items] 343 | 344 | if paging.total <= 50: # There are 50 or fewer episodes, and we already have them so just return them 345 | return episodes 346 | 347 | for _ in range(1, math.ceil(paging.total / 50)): 348 | response = await self.http.get_show_episodes( 349 | _id, 350 | market=market, 351 | limit=50, 352 | offset=_ * 50, 353 | credentials=credentials 354 | ) 355 | episodes.extend([SimpleEpisode(data) for data in PagingObject(response).items]) 356 | 357 | return episodes 358 | 359 | async def get_saved_shows(self) -> ...: 360 | raise NotImplementedError 361 | 362 | async def save_shows(self) -> ...: 363 | raise NotImplementedError 364 | 365 | async def remove_shows(self) -> ...: 366 | raise NotImplementedError 367 | 368 | async def check_saved_shows(self) -> ...: 369 | raise NotImplementedError 370 | 371 | # EPISODE API 372 | 373 | async def get_episode( 374 | self, 375 | _id: str, 376 | /, *, 377 | market: str | None = "GB", 378 | credentials: AnyCredentials | None = None, 379 | ) -> Episode: 380 | 381 | response = await self.http.get_episode(_id, market=market, credentials=credentials) 382 | return Episode(response) 383 | 384 | async def get_episodes( 385 | self, 386 | ids: Sequence[ID], 387 | *, 388 | market: str | None = "GB", 389 | credentials: AnyCredentials | None = None, 390 | ) -> dict[ID, Episode | None]: 391 | 392 | response = await self.http.get_multiple_episodes(ids, market=market, credentials=credentials) 393 | return dict(zip(ids, [Episode(data) if data else None for data in response["episodes"]])) 394 | 395 | async def get_saved_episodes(self) -> ...: 396 | raise NotImplementedError 397 | 398 | async def save_episodes(self) -> ...: 399 | raise NotImplementedError 400 | 401 | async def remove_episodes(self) -> ...: 402 | raise NotImplementedError 403 | 404 | async def check_saved_episodes(self) -> ...: 405 | raise NotImplementedError 406 | 407 | # TRACKS API 408 | 409 | async def get_track( 410 | self, 411 | _id: str, 412 | /, *, 413 | market: str | None = None, 414 | credentials: AnyCredentials | None = None, 415 | ) -> Track: 416 | 417 | response = await self.http.get_track(_id, market=market, credentials=credentials) 418 | return Track(response) 419 | 420 | async def get_tracks( 421 | self, 422 | ids: Sequence[ID], 423 | *, 424 | market: str | None = None, 425 | credentials: AnyCredentials | None = None, 426 | ) -> dict[ID, Track | None]: 427 | 428 | response = await self.http.get_multiple_tracks(ids=ids, market=market, credentials=credentials) 429 | return dict(zip(ids, [Track(data) if data else None for data in response["tracks"]])) 430 | 431 | async def get_saved_tracks(self) -> ...: 432 | raise NotImplementedError 433 | 434 | async def save_tracks(self) -> ...: 435 | raise NotImplementedError 436 | 437 | async def remove_tracks(self) -> ...: 438 | raise NotImplementedError 439 | 440 | async def check_saved_tracks(self) -> ...: 441 | raise NotImplementedError 442 | 443 | async def get_multiple_tracks_audio_features( 444 | self, 445 | ids: Sequence[ID], 446 | *, 447 | credentials: AnyCredentials | None = None, 448 | ) -> dict[ID, AudioFeatures | None]: 449 | 450 | response = await self.http.get_multiple_tracks_audio_features(ids, credentials=credentials) 451 | return dict(zip(ids, [AudioFeatures(data) if data else None for data in response["audio_features"]])) 452 | 453 | async def get_track_audio_features( 454 | self, 455 | _id: str, 456 | /, *, 457 | credentials: AnyCredentials | None = None, 458 | ) -> AudioFeatures: 459 | 460 | response = await self.http.get_track_audio_features(_id, credentials=credentials) 461 | return AudioFeatures(response) 462 | 463 | async def get_track_audio_analysis(self) -> ...: 464 | raise NotImplementedError 465 | 466 | async def get_recommendations( 467 | self, 468 | *, 469 | seed_artist_ids: list[str] | None = None, 470 | seed_track_ids: list[str] | None = None, 471 | seed_genres: list[str] | None = None, 472 | limit: int | None = None, 473 | market: str | None = None, 474 | credentials: AnyCredentials | None = None, 475 | **kwargs: int 476 | ) -> Recommendations: 477 | 478 | response = await self.http.get_recommendations( 479 | seed_artist_ids=seed_artist_ids, 480 | seed_track_ids=seed_track_ids, 481 | seed_genres=seed_genres, 482 | limit=limit, 483 | market=market, 484 | credentials=credentials, 485 | **kwargs 486 | ) 487 | return Recommendations(response) 488 | 489 | # SEARCH API 490 | 491 | async def search( 492 | self, 493 | query: str, 494 | /, *, 495 | search_types: list[SearchType] | None = None, 496 | market: str | None = None, 497 | limit: int | None = None, 498 | offset: int | None = None, 499 | include_external: bool = False, 500 | credentials: AnyCredentials | None = None, 501 | ) -> SearchResult: 502 | 503 | if search_types is None: 504 | search_types = [SearchType.All] 505 | 506 | response = await self.http.search( 507 | query, 508 | search_types=search_types, 509 | market=market, 510 | limit=limit, 511 | offset=offset, 512 | include_external=include_external, 513 | credentials=credentials 514 | ) 515 | return SearchResult(response) 516 | 517 | # USERS API 518 | 519 | async def get_current_user_profile( 520 | self, 521 | *, 522 | credentials: UserCredentials, 523 | ) -> User: 524 | 525 | response = await self.http.get_current_user_profile(credentials=credentials) 526 | return User(response) 527 | 528 | async def get_current_users_top_artists( 529 | self, 530 | *, 531 | time_range: TimeRange | None, 532 | limit: int | None, 533 | offset: int | None, 534 | credentials: UserCredentials, 535 | ) -> list[Artist]: 536 | 537 | response = await self.http.get_current_user_top_artists( 538 | time_range=time_range, 539 | limit=limit, 540 | offset=offset, 541 | credentials=credentials 542 | ) 543 | return [Artist(data) for data in PagingObject(response).items] 544 | 545 | async def get_current_users_top_tracks( 546 | self, 547 | *, 548 | time_range: TimeRange | None = None, 549 | limit: int | None = None, 550 | offset: int | None = None, 551 | credentials: UserCredentials, 552 | ) -> list[Track]: 553 | 554 | response = await self.http.get_current_user_top_tracks( 555 | time_range=time_range, 556 | limit=limit, 557 | offset=offset, 558 | credentials=credentials 559 | ) 560 | return [Track(data) for data in PagingObject(response).items] 561 | 562 | async def get_user_profile( 563 | self, 564 | _id: str, 565 | /, *, 566 | credentials: UserCredentials, 567 | ) -> User: 568 | 569 | response = await self.http.get_user_profile(_id, credentials=credentials) 570 | return User(response) 571 | 572 | async def follow_playlist(self) -> ...: 573 | raise NotImplementedError 574 | 575 | async def unfollow_playlist(self) -> ...: 576 | raise NotImplementedError 577 | 578 | async def get_followed_artists(self) -> ...: 579 | raise NotImplementedError 580 | 581 | async def get_followed_users(self) -> ...: 582 | raise NotImplementedError 583 | 584 | async def follow_artists(self) -> ...: 585 | raise NotImplementedError 586 | 587 | async def follow_users(self) -> ...: 588 | raise NotImplementedError 589 | 590 | async def unfollow_artists(self) -> ...: 591 | raise NotImplementedError 592 | 593 | async def unfollow_users(self) -> ...: 594 | raise NotImplementedError 595 | 596 | async def check_followed_artists(self) -> ...: 597 | raise NotImplementedError 598 | 599 | async def check_followed_users(self) -> ...: 600 | raise NotImplementedError 601 | 602 | async def check_playlist_followers(self) -> ...: 603 | raise NotImplementedError 604 | 605 | # PLAYLISTS API 606 | 607 | async def get_playlist( 608 | self, 609 | _id: str, 610 | /, *, 611 | market: str | None = None, 612 | fields: str | None = None, 613 | credentials: AnyCredentials | None = None, 614 | ) -> Playlist: 615 | 616 | response = await self.http.get_playlist(_id, market=market, fields=fields, credentials=credentials) 617 | return Playlist(response) 618 | 619 | async def change_playlist_details(self) -> ...: 620 | raise NotImplementedError 621 | 622 | async def get_playlist_items( 623 | self, 624 | _id: str, 625 | /, *, 626 | market: str | None = None, 627 | fields: str | None = None, 628 | limit: int | None = None, 629 | offset: int | None = None, 630 | credentials: AnyCredentials | None = None, 631 | ) -> list[PlaylistTrack]: 632 | 633 | response = await self.http.get_playlist_items( 634 | _id, 635 | market=market, 636 | fields=fields, 637 | limit=limit, 638 | offset=offset, 639 | credentials=credentials 640 | ) 641 | return [PlaylistTrack(data) for data in PagingObject(response).items] 642 | 643 | async def get_all_playlist_items( 644 | self, 645 | _id: str, 646 | /, *, 647 | market: str | None = None, 648 | fields: str | None = None, 649 | credentials: AnyCredentials | None = None, 650 | ) -> list[PlaylistTrack]: 651 | 652 | response = await self.http.get_playlist_items( 653 | _id, 654 | market=market, 655 | fields=fields, 656 | limit=100, 657 | offset=0, 658 | credentials=credentials 659 | ) 660 | paging = PagingObject(response) 661 | 662 | items = [PlaylistTrack(data) for data in paging.items] 663 | 664 | if paging.total <= 100: # There are 50 or fewer tracks, and we already have them so just return them 665 | return items 666 | 667 | for _ in range(1, math.ceil(paging.total / 100)): 668 | response = await self.http.get_playlist_items( 669 | _id, 670 | market=market, 671 | fields=fields, 672 | limit=100, 673 | offset=_ * 100, 674 | credentials=credentials 675 | ) 676 | items.extend([PlaylistTrack(data) for data in PagingObject(response).items]) 677 | 678 | return items 679 | 680 | async def get_full_playlist( 681 | self, 682 | _id: str, 683 | /, *, 684 | market: str | None = None, 685 | fields: str | None = None, 686 | credentials: AnyCredentials | None = None, 687 | ) -> Playlist: 688 | 689 | playlist = await self.get_playlist(_id, market=market, fields=fields, credentials=credentials) 690 | 691 | if playlist._tracks_paging.total <= 100: 692 | return playlist 693 | 694 | for _ in range(1, math.ceil(playlist._tracks_paging.total / 100)): 695 | response = await self.http.get_playlist_items( 696 | _id, 697 | market=market, 698 | fields=fields, 699 | limit=100, 700 | offset=_ * 100, 701 | credentials=credentials 702 | ) 703 | playlist.tracks.extend([PlaylistTrack(data) for data in PagingObject(response).items]) 704 | 705 | return playlist 706 | 707 | async def add_items_to_playlist(self) -> ...: 708 | raise NotImplementedError 709 | 710 | async def reorder_playlist_items(self) -> ...: 711 | raise NotImplementedError 712 | 713 | async def replace_playlist_items(self) -> ...: 714 | raise NotImplementedError 715 | 716 | async def remove_items_from_playlist(self) -> ...: 717 | raise NotImplementedError 718 | 719 | async def get_current_user_playlists( 720 | self, 721 | *, 722 | limit: int | None = None, 723 | offset: int | None = None, 724 | credentials: UserCredentials, 725 | ) -> list[SimplePlaylist]: 726 | 727 | response = await self.http.get_current_user_playlists(limit=limit, offset=offset, credentials=credentials) 728 | return [SimplePlaylist(data) for data in PagingObject(response).items] 729 | 730 | async def get_all_current_user_playlists( 731 | self, 732 | *, 733 | credentials: UserCredentials, 734 | ) -> list[SimplePlaylist]: 735 | 736 | response = await self.http.get_current_user_playlists(limit=50, offset=0, credentials=credentials) 737 | paging = PagingObject(response) 738 | 739 | playlists = [SimplePlaylist(data) for data in paging.items] 740 | 741 | if paging.total <= 50: # There are 50 or fewer playlists, and we already have them so just return them 742 | return playlists 743 | 744 | for _ in range(1, math.ceil(paging.total / 50)): 745 | response = await self.http.get_current_user_playlists(limit=50, offset=_ * 50, credentials=credentials) 746 | playlists.extend([SimplePlaylist(data) for data in PagingObject(response).items]) 747 | 748 | return playlists 749 | 750 | async def get_user_playlists( 751 | self, 752 | _id: str, 753 | /, *, 754 | limit: int | None = None, 755 | offset: int | None = None, 756 | credentials: AnyCredentials | None = None, 757 | ) -> list[SimplePlaylist]: 758 | 759 | response = await self.http.get_user_playlists(_id, limit=limit, offset=offset, credentials=credentials) 760 | return [SimplePlaylist(data) for data in PagingObject(response).items] 761 | 762 | async def get_all_user_playlists( 763 | self, 764 | _id: str, 765 | /, *, 766 | credentials: AnyCredentials | None = None, 767 | ) -> list[SimplePlaylist]: 768 | 769 | response = await self.http.get_user_playlists(_id, limit=50, offset=0, credentials=credentials) 770 | paging = PagingObject(response) 771 | 772 | playlists = [SimplePlaylist(data) for data in paging.items] 773 | 774 | if paging.total <= 50: # There are 50 or fewer playlists, and we already have them so just return them 775 | return playlists 776 | 777 | for _ in range(1, math.ceil(paging.total / 50)): 778 | response = await self.http.get_user_playlists(_id, limit=50, offset=_ * 50, credentials=credentials) 779 | playlists.extend([SimplePlaylist(data) for data in PagingObject(response).items]) 780 | 781 | return playlists 782 | 783 | async def create_playlist(self) -> ...: 784 | raise NotImplementedError 785 | 786 | async def get_featured_playlists( 787 | self, 788 | *, 789 | country: str | None = None, 790 | locale: str | None = None, 791 | timestamp: str | None = None, 792 | limit: int | None = None, 793 | offset: int | None = None, 794 | credentials: AnyCredentials | None = None, 795 | ) -> tuple[str, list[SimplePlaylist]]: 796 | 797 | response = await self.http.get_featured_playlists( 798 | country=country, 799 | locale=locale, 800 | timestamp=timestamp, 801 | limit=limit, 802 | offset=offset, 803 | credentials=credentials 804 | ) 805 | return response["message"], [SimplePlaylist(data) for data in PagingObject(response["playlists"]).items] 806 | 807 | async def get_category_playlists( 808 | self, 809 | _id: str, 810 | /, *, 811 | country: str | None = None, 812 | limit: int | None = None, 813 | offset: int | None = None, 814 | credentials: AnyCredentials | None = None, 815 | ) -> list[SimplePlaylist]: 816 | 817 | response = await self.http.get_category_playlists( 818 | _id, 819 | country=country, 820 | limit=limit, 821 | offset=offset, 822 | credentials=credentials 823 | ) 824 | return [SimplePlaylist(data) for data in PagingObject(response["playlists"]).items] 825 | 826 | async def get_playlist_cover_image( 827 | self, 828 | _id: str, 829 | /, *, 830 | credentials: AnyCredentials | None = None 831 | ) -> list[Image]: 832 | 833 | response = await self.http.get_playlist_cover_image(_id, credentials=credentials) 834 | return [Image(data) for data in response] 835 | 836 | async def upload_playlist_cover_image(self) -> ...: 837 | raise NotImplementedError 838 | 839 | # CATEGORY API 840 | 841 | async def get_categories( 842 | self, 843 | *, 844 | country: str | None = None, 845 | locale: str | None = None, 846 | limit: int | None = None, 847 | offset: int | None = None, 848 | credentials: AnyCredentials | None = None, 849 | ) -> list[Category]: 850 | 851 | response = await self.http.get_categories( 852 | country=country, 853 | locale=locale, 854 | limit=limit, 855 | offset=offset, 856 | credentials=credentials 857 | ) 858 | return [Category(data) for data in PagingObject(response["categories"]).items] 859 | 860 | async def get_category( 861 | self, 862 | _id: str, 863 | /, *, 864 | country: str | None = None, 865 | locale: str | None = None, 866 | credentials: AnyCredentials | None = None, 867 | ) -> Category: 868 | 869 | response = await self.http.get_category(_id, country=country, locale=locale, credentials=credentials) 870 | return Category(response) 871 | 872 | # GENRE API 873 | 874 | async def get_available_genre_seeds( 875 | self, 876 | *, 877 | credentials: AnyCredentials | None = None, 878 | ) -> list[str]: 879 | 880 | response = await self.http.get_available_genre_seeds(credentials=credentials) 881 | return response["genres"] 882 | 883 | # PLAYER API 884 | 885 | async def get_playback_state(self) -> ...: 886 | raise NotImplementedError 887 | 888 | async def transfer_playback(self) -> ...: 889 | raise NotImplementedError 890 | 891 | async def get_available_devices(self) -> ...: 892 | raise NotImplementedError 893 | 894 | async def get_currently_playing_track(self) -> ...: 895 | raise NotImplementedError 896 | 897 | async def start_playback(self) -> ...: 898 | raise NotImplementedError 899 | 900 | async def resume_playback(self) -> ...: 901 | raise NotImplementedError 902 | 903 | async def pause_playback(self) -> ...: 904 | raise NotImplementedError 905 | 906 | async def skip_to_next(self) -> ...: 907 | raise NotImplementedError 908 | 909 | async def skip_to_previous(self) -> ...: 910 | raise NotImplementedError 911 | 912 | async def seek_to_position(self) -> ...: 913 | raise NotImplementedError 914 | 915 | async def set_repeat_mode(self) -> ...: 916 | raise NotImplementedError 917 | 918 | async def set_playback_volume(self) -> ...: 919 | raise NotImplementedError 920 | 921 | async def toggle_playback_shuffle(self) -> ...: 922 | raise NotImplementedError 923 | 924 | async def get_recently_played_tracks(self) -> ...: 925 | raise NotImplementedError 926 | 927 | async def add_item_to_playback_queue(self) -> ...: 928 | raise NotImplementedError 929 | 930 | # MARKETS API 931 | 932 | async def get_available_markets( 933 | self, 934 | *, 935 | credentials: AnyCredentials | None = None, 936 | ) -> list[str]: 937 | 938 | response = await self.http.get_available_markets(credentials=credentials) 939 | return response["markets"] 940 | -------------------------------------------------------------------------------- /spotipy/http.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import base64 5 | import logging 6 | from collections.abc import Sequence 7 | from typing import ClassVar, Any, Literal 8 | from urllib.parse import quote 9 | 10 | import aiohttp 11 | 12 | from .enums import IncludeGroup, SearchType, TimeRange, RepeatMode 13 | from .errors import RequestEntityTooLarge, HTTPError, SpotipyError, SpotifyServerError, HTTPErrorMapping 14 | from .objects.album import AlbumData, SimpleAlbumData 15 | from .objects.artist import ArtistData 16 | from .objects.base import PagingObjectData, AlternativePagingObjectData 17 | from .objects.category import CategoryData 18 | from .objects.credentials import ClientCredentials, UserCredentials 19 | from .objects.device import DeviceData 20 | from .objects.episode import EpisodeData 21 | from .objects.image import ImageData 22 | from .objects.playback import PlaybackStateData, CurrentlyPlayingData 23 | from .objects.playlist import PlaylistData, PlaylistSnapshotID, SimplePlaylistData 24 | from .objects.recommendation import RecommendationsData 25 | from .objects.search import SearchResultData 26 | from .objects.show import ShowData 27 | from .objects.track import SimpleTrackData, TrackData, AudioFeaturesData, PlaylistTrackData 28 | from .objects.user import UserData 29 | from .types.common import AnyCredentials 30 | from .types.http import HTTPMethod, Headers, FeaturedPlaylistsData 31 | from .utilities import to_json, limit_value, json_or_text 32 | from .values import VALID_RECOMMENDATION_SEED_KWARGS 33 | 34 | 35 | __all__ = ( 36 | "Route", 37 | "HTTPClient" 38 | ) 39 | 40 | 41 | LOG: logging.Logger = logging.getLogger("spotipy.http") 42 | 43 | 44 | class Route: 45 | 46 | BASE: ClassVar[str] = "https://api.spotify.com/v1" 47 | 48 | def __init__( 49 | self, 50 | method: HTTPMethod, 51 | path: str, 52 | /, 53 | **parameters: Any 54 | ) -> None: 55 | 56 | self.method: HTTPMethod = method 57 | self.path: str = path 58 | self.parameters: dict[str, Any] = parameters 59 | 60 | url = self.BASE + path 61 | if parameters: 62 | url = url.format_map({k: quote(v) if isinstance(v, str) else v for k, v in parameters.items()}) 63 | 64 | self.url: str = url 65 | 66 | def __repr__(self) -> str: 67 | return f"" 68 | 69 | 70 | class HTTPClient: 71 | 72 | def __init__( 73 | self, 74 | *, 75 | client_id: str, 76 | client_secret: str, 77 | session: aiohttp.ClientSession | None = None, 78 | ) -> None: 79 | 80 | self._client_id: str = client_id 81 | self._client_secret: str = client_secret 82 | self._session: aiohttp.ClientSession | None = session 83 | 84 | self._credentials: ClientCredentials | None = None 85 | 86 | self._request_lock: asyncio.Event = asyncio.Event() 87 | self._request_lock.set() 88 | 89 | def __repr__(self) -> str: 90 | return f"" 91 | 92 | # internal methods 93 | 94 | async def _get_session(self) -> aiohttp.ClientSession: 95 | 96 | if self._session is None: 97 | self._session = aiohttp.ClientSession() 98 | 99 | return self._session 100 | 101 | async def _get_credentials(self, credentials: AnyCredentials | None) -> AnyCredentials: 102 | 103 | session = await self._get_session() 104 | 105 | if not self._credentials: 106 | self._credentials = await ClientCredentials.from_client_details( 107 | self._client_id, self._client_secret, 108 | session=session 109 | ) 110 | 111 | credentials = credentials or self._credentials 112 | if credentials.is_expired(): 113 | await credentials.refresh(session) 114 | 115 | return credentials 116 | 117 | # public methods 118 | 119 | async def close(self) -> None: 120 | 121 | if self._session is None: 122 | return 123 | 124 | await self._session.close() 125 | 126 | async def request( 127 | self, 128 | route: Route, 129 | /, *, 130 | credentials: AnyCredentials | None = None, 131 | query: dict[str, Any] | None = None, 132 | body: str | None = None, 133 | json: dict[str, Any] | None = None, 134 | ) -> Any: 135 | 136 | session = await self._get_session() 137 | credentials = await self._get_credentials(credentials) 138 | 139 | headers: Headers = { 140 | "Authorization": f"Bearer {credentials.access_token}" 141 | } 142 | if json is not None: 143 | headers["Content-Type"] = "application/json" 144 | body = to_json(json) 145 | 146 | if self._request_lock.is_set() is False: 147 | await self._request_lock.wait() 148 | 149 | response: aiohttp.ClientResponse | None = None 150 | data: dict[str, Any] | str | None = None 151 | 152 | for tries in range(4): 153 | try: 154 | async with session.request( 155 | route.method, route.url, headers=headers, params=query, data=body 156 | ) as response: 157 | 158 | status = response.status 159 | LOG.debug(f"'{route.method}' @ '{response.url}' -> '{status}'.") 160 | 161 | data = await json_or_text(response) 162 | 163 | if 200 <= status < 300: 164 | return data 165 | 166 | if status in {400, 401, 403, 404}: 167 | raise HTTPErrorMapping[status](response, data) # type: ignore 168 | 169 | elif status == 413: 170 | # special case handler for playlist image uploads. 171 | raise RequestEntityTooLarge( 172 | response, 173 | data={"error": {"status": 413, "message": "Playlist image was too large."}} 174 | ) 175 | 176 | elif status == 429: 177 | # sleep for 'Retry-After' seconds before making new requests. 178 | self._request_lock.clear() 179 | await asyncio.sleep(int(response.headers["Retry-After"])) 180 | self._request_lock.set() 181 | continue 182 | 183 | elif status in {500, 502, 503}: 184 | # retry request for specific 5xx status codes. 185 | await asyncio.sleep(1 + tries * 2) 186 | continue 187 | 188 | elif status >= 500: 189 | # raise an exception for any other 5xx status code. 190 | raise SpotifyServerError(response, data) # type: ignore 191 | 192 | raise HTTPError(response, data) # type: ignore 193 | 194 | except OSError as error: 195 | # retry request for the 'connection reset by peer' error. 196 | if tries < 3 and error.errno in (54, 10054): 197 | await asyncio.sleep(1 + tries * 2) 198 | continue 199 | raise 200 | 201 | if response is not None: 202 | # raise an exception when we run out of retries. 203 | if response.status >= 500: 204 | raise SpotifyServerError(response, data) # type: ignore 205 | raise HTTPError(response, data) # type: ignore 206 | 207 | raise RuntimeError("This shouldn't happen.") 208 | 209 | # ALBUMS API 210 | 211 | async def get_album( 212 | self, 213 | _id: str, 214 | /, *, 215 | market: str | None, 216 | credentials: AnyCredentials | None = None 217 | ) -> AlbumData: 218 | 219 | query: dict[str, Any] = {} 220 | if market: 221 | query["market"] = market 222 | 223 | return await self.request( 224 | Route("GET", "/albums/{id}", id=_id), 225 | query=query, credentials=credentials 226 | ) 227 | 228 | async def get_multiple_albums( 229 | self, 230 | ids: Sequence[str], 231 | /, *, 232 | market: str | None, 233 | credentials: AnyCredentials | None = None 234 | ) -> dict[Literal["albums"], list[AlbumData | None]]: 235 | 236 | if len(ids) > 20: 237 | raise ValueError("'ids' can not contain more than 20 ids.") 238 | 239 | query: dict[str, Any] = { 240 | "ids": ",".join(ids) 241 | } 242 | if market: 243 | query["market"] = market 244 | 245 | return await self.request( 246 | Route("GET", "/albums"), 247 | query=query, credentials=credentials 248 | ) 249 | 250 | async def get_album_tracks( 251 | self, 252 | _id: str, 253 | /, *, 254 | limit: int | None, 255 | offset: int | None, 256 | market: str | None, 257 | credentials: AnyCredentials | None = None 258 | ) -> PagingObjectData[SimpleTrackData]: 259 | 260 | query: dict[str, Any] = {} 261 | if limit: 262 | limit_value("limit", limit, 1, 50) 263 | query["limit"] = limit 264 | if offset: 265 | query["offset"] = offset 266 | if market: 267 | query["market"] = market 268 | 269 | return await self.request( 270 | Route("GET", "/albums/{id}/tracks", id=_id), 271 | query=query, credentials=credentials 272 | ) 273 | 274 | async def get_saved_albums( 275 | self, 276 | *, 277 | limit: int | None, 278 | offset: int | None, 279 | market: str | None, 280 | credentials: UserCredentials, 281 | ) -> PagingObjectData[AlbumData]: 282 | 283 | query: dict[str, Any] = {} 284 | if limit: 285 | limit_value("limit", limit, 1, 50) 286 | query["limit"] = limit 287 | if offset: 288 | query["offset"] = offset 289 | if market: 290 | query["market"] = market 291 | 292 | return await self.request( 293 | Route("GET", "/me/albums"), 294 | query=query, credentials=credentials 295 | ) 296 | 297 | async def save_albums( 298 | self, 299 | ids: list[str], 300 | /, *, 301 | credentials: UserCredentials, 302 | ) -> None: 303 | 304 | if len(ids) > 50: 305 | raise ValueError("'ids' can not contain more than 50 ids.") 306 | 307 | query: dict[str, Any] = { 308 | "ids": ",".join(ids) 309 | } 310 | return await self.request( 311 | Route("PUT", "/me/albums"), 312 | query=query, credentials=credentials 313 | ) 314 | 315 | async def remove_albums( 316 | self, 317 | ids: list[str], 318 | /, *, 319 | credentials: UserCredentials, 320 | ) -> None: 321 | 322 | if len(ids) > 50: 323 | raise ValueError("'ids' can not contain more than 50 ids.") 324 | 325 | query: dict[str, Any] = { 326 | "ids": ",".join(ids) 327 | } 328 | return await self.request( 329 | Route("DELETE", "/me/albums"), 330 | query=query, credentials=credentials 331 | ) 332 | 333 | async def check_saved_albums( 334 | self, 335 | ids: list[str], 336 | /, *, 337 | credentials: UserCredentials, 338 | ) -> list[bool]: 339 | 340 | if len(ids) > 50: 341 | raise ValueError("'ids' can not contain more than 50 ids.") 342 | 343 | query: dict[str, Any] = { 344 | "ids": ",".join(ids) 345 | } 346 | return await self.request( 347 | Route("GET", "/me/albums/contains"), 348 | query=query, credentials=credentials 349 | ) 350 | 351 | async def get_new_releases( 352 | self, 353 | *, 354 | country: str | None, 355 | limit: int | None, 356 | offset: int | None, 357 | credentials: AnyCredentials | None = None 358 | ) -> dict[Literal["albums"], PagingObjectData[SimpleAlbumData]]: 359 | 360 | query: dict[str, Any] = {} 361 | if country: 362 | query["country"] = country 363 | if limit: 364 | limit_value("limit", limit, 1, 50) 365 | query["limit"] = limit 366 | if offset: 367 | query["offset"] = offset 368 | 369 | return await self.request( 370 | Route("GET", "/browse/new-releases"), 371 | query=query, credentials=credentials 372 | ) 373 | 374 | # ARTISTS API 375 | 376 | async def get_artist( 377 | self, 378 | _id: str, 379 | /, *, 380 | market: str | None, 381 | credentials: AnyCredentials | None = None 382 | ) -> ArtistData: 383 | 384 | query: dict[str, Any] = {} 385 | if market: 386 | query["market"] = market 387 | 388 | return await self.request( 389 | Route("GET", "/artists/{id}", id=_id), 390 | query=query, credentials=credentials 391 | ) 392 | 393 | async def get_multiple_artists( 394 | self, 395 | ids: Sequence[str], 396 | /, *, 397 | market: str | None, 398 | credentials: AnyCredentials | None = None 399 | ) -> dict[Literal["artists"], list[ArtistData | None]]: 400 | 401 | if len(ids) > 50: 402 | raise ValueError("'ids' can not contain more than 50 ids.") 403 | 404 | query: dict[str, Any] = { 405 | "ids": ",".join(ids) 406 | } 407 | if market: 408 | query["market"] = market 409 | 410 | return await self.request( 411 | Route("GET", "/artists"), 412 | query=query, credentials=credentials 413 | ) 414 | 415 | async def get_artist_albums( 416 | self, 417 | _id: str, 418 | /, *, 419 | include_groups: list[IncludeGroup] | None, 420 | limit: int | None, 421 | offset: int | None, 422 | market: str | None, 423 | credentials: AnyCredentials | None = None 424 | ) -> PagingObjectData[SimpleAlbumData]: 425 | 426 | query: dict[str, Any] = {} 427 | if include_groups: 428 | query["include_groups"] = ",".join(include_group.value for include_group in include_groups) 429 | if limit: 430 | limit_value("limit", limit, 1, 50) 431 | query["limit"] = limit 432 | if offset: 433 | query["offset"] = offset 434 | if market: 435 | query["market"] = market 436 | 437 | return await self.request( 438 | Route("GET", "/artists/{id}/albums", id=_id), 439 | query=query, credentials=credentials 440 | ) 441 | 442 | async def get_artist_top_tracks( 443 | self, 444 | _id: str, 445 | /, *, 446 | market: str, 447 | credentials: AnyCredentials | None = None 448 | ) -> dict[Literal["tracks"], list[TrackData]]: 449 | 450 | query: dict[str, Any] = { 451 | "market": market 452 | } 453 | return await self.request( 454 | Route("GET", "/artists/{id}/top-tracks", id=_id), 455 | query=query, credentials=credentials 456 | ) 457 | 458 | async def get_related_artists( 459 | self, 460 | _id: str, 461 | /, *, 462 | credentials: AnyCredentials | None = None 463 | ) -> dict[Literal["artists"], list[ArtistData]]: 464 | return await self.request( 465 | Route("GET", "/artists/{id}/related-artists", id=_id), 466 | credentials=credentials 467 | ) 468 | 469 | # SHOWS API 470 | 471 | async def get_show( 472 | self, 473 | _id: str, 474 | /, *, 475 | market: str, 476 | credentials: AnyCredentials | None = None 477 | ) -> ShowData: 478 | 479 | query: dict[str, Any] = {} 480 | if market: 481 | query["market"] = market 482 | 483 | return await self.request( 484 | Route("GET", "/shows/{id}", id=_id), 485 | query=query, credentials=credentials 486 | ) 487 | 488 | async def get_multiple_shows( 489 | self, 490 | ids: Sequence[str], 491 | /, *, 492 | market: str, 493 | credentials: AnyCredentials | None = None 494 | ) -> dict[Literal["shows"], list[ShowData | None]]: 495 | 496 | if len(ids) > 50: 497 | raise ValueError("'ids' can not contain more than 50 ids.") 498 | 499 | query: dict[str, Any] = { 500 | "ids": ",".join(ids) 501 | } 502 | if market: 503 | query["market"] = market 504 | 505 | return await self.request( 506 | Route("GET", "/shows"), 507 | query=query, credentials=credentials 508 | ) 509 | 510 | async def get_show_episodes( 511 | self, 512 | _id: str, 513 | /, *, 514 | limit: int | None, 515 | offset: int | None, 516 | market: str, 517 | credentials: AnyCredentials | None = None 518 | ) -> PagingObjectData[EpisodeData]: 519 | 520 | query: dict[str, Any] = {} 521 | if limit: 522 | limit_value("limit", limit, 1, 50) 523 | query["limit"] = limit 524 | if offset: 525 | query["offset"] = offset 526 | if market: 527 | query["market"] = market 528 | 529 | return await self.request( 530 | Route("GET", "/shows/{id}/episodes", id=_id), 531 | query=query, credentials=credentials 532 | ) 533 | 534 | async def get_saved_shows( 535 | self, 536 | *, 537 | limit: int | None, 538 | offset: int | None, 539 | credentials: UserCredentials, 540 | ) -> PagingObjectData[ShowData]: 541 | 542 | query: dict[str, Any] = {} 543 | if limit: 544 | limit_value("limit", limit, 1, 50) 545 | query["limit"] = limit 546 | if offset: 547 | query["offset"] = offset 548 | 549 | return await self.request( 550 | Route("GET", "/me/shows"), 551 | query=query, credentials=credentials 552 | ) 553 | 554 | async def save_shows( 555 | self, 556 | ids: list[str], 557 | /, *, 558 | credentials: UserCredentials, 559 | ) -> None: 560 | 561 | if len(ids) > 50: 562 | raise ValueError("'ids' can not contain more than 50 ids.") 563 | 564 | query: dict[str, Any] = { 565 | "ids": ",".join(ids) 566 | } 567 | return await self.request( 568 | Route("PUT", "/me/shows"), 569 | query=query, credentials=credentials 570 | ) 571 | 572 | async def remove_shows( 573 | self, 574 | ids: list[str], 575 | /, *, 576 | credentials: UserCredentials, 577 | ) -> None: 578 | 579 | if len(ids) > 50: 580 | raise ValueError("'ids' can not contain more than 50 ids.") 581 | 582 | query: dict[str, Any] = { 583 | "ids": ",".join(ids) 584 | } 585 | return await self.request( 586 | Route("DELETE", "/me/shows"), 587 | query=query, credentials=credentials 588 | ) 589 | 590 | async def check_saved_shows( 591 | self, 592 | ids: list[str], 593 | /, *, 594 | credentials: UserCredentials, 595 | ) -> list[bool]: 596 | 597 | if len(ids) > 50: 598 | raise ValueError("'ids' can not contain more than 50 ids.") 599 | 600 | query: dict[str, Any] = { 601 | "ids": ",".join(ids) 602 | } 603 | return await self.request( 604 | Route("GET", "/me/shows/contains"), 605 | query=query, credentials=credentials 606 | ) 607 | 608 | # EPISODE API 609 | 610 | async def get_episode( 611 | self, 612 | _id: str, 613 | /, *, 614 | market: str | None, 615 | credentials: AnyCredentials | None = None 616 | ) -> EpisodeData: 617 | 618 | query: dict[str, Any] = {} 619 | if market: 620 | query["market"] = market 621 | 622 | return await self.request( 623 | Route("GET", "/episodes/{id}", id=_id), 624 | query=query, credentials=credentials 625 | ) 626 | 627 | async def get_multiple_episodes( 628 | self, 629 | ids: Sequence[str], 630 | /, *, 631 | market: str | None, 632 | credentials: AnyCredentials | None = None 633 | ) -> dict[Literal["episodes"], list[EpisodeData | None]]: 634 | 635 | if len(ids) > 50: 636 | raise ValueError("'ids' can not contain more than 50 ids.") 637 | 638 | query: dict[str, Any] = { 639 | "ids": ",".join(ids) 640 | } 641 | if market: 642 | query["market"] = market 643 | 644 | return await self.request( 645 | Route("GET", "/episodes"), 646 | query=query, credentials=credentials 647 | ) 648 | 649 | async def get_saved_episodes( 650 | self, 651 | *, 652 | limit: int | None, 653 | offset: int | None, 654 | market: str | None, 655 | credentials: UserCredentials, 656 | ) -> PagingObjectData[EpisodeData]: 657 | 658 | query: dict[str, Any] = {} 659 | if limit: 660 | limit_value("limit", limit, 1, 50) 661 | query["limit"] = limit 662 | if offset: 663 | query["offset"] = offset 664 | if market: 665 | query["market"] = market 666 | 667 | return await self.request( 668 | Route("GET", "/me/episodes"), 669 | query=query, credentials=credentials 670 | ) 671 | 672 | async def save_episodes( 673 | self, 674 | ids: list[str], 675 | /, *, 676 | credentials: UserCredentials 677 | ) -> None: 678 | 679 | if len(ids) > 50: 680 | raise ValueError("'ids' can not contain more than 50 ids.") 681 | 682 | query: dict[str, Any] = { 683 | "ids": ",".join(ids) 684 | } 685 | return await self.request( 686 | Route("PUT", "/me/episodes"), 687 | query=query, credentials=credentials 688 | ) 689 | 690 | async def remove_episodes( 691 | self, 692 | ids: list[str], 693 | /, *, 694 | credentials: UserCredentials 695 | ) -> None: 696 | 697 | if len(ids) > 50: 698 | raise ValueError("'ids' can not contain more than 50 ids.") 699 | 700 | query: dict[str, Any] = { 701 | "ids": ",".join(ids) 702 | } 703 | return await self.request( 704 | Route("DELETE", "/me/episodes"), 705 | query=query, credentials=credentials 706 | ) 707 | 708 | async def check_saved_episodes( 709 | self, 710 | ids: list[str], 711 | /, *, 712 | credentials: UserCredentials 713 | ) -> list[bool]: 714 | 715 | if len(ids) > 50: 716 | raise ValueError("'ids' can not contain more than 50 ids.") 717 | 718 | query: dict[str, Any] = { 719 | "ids": ",".join(ids) 720 | } 721 | return await self.request( 722 | Route("GET", "/me/episodes/contains"), 723 | query=query, credentials=credentials 724 | ) 725 | 726 | # TRACKS API 727 | 728 | async def get_track( 729 | self, 730 | _id: str, 731 | /, *, 732 | market: str | None, 733 | credentials: AnyCredentials | None = None 734 | ) -> TrackData: 735 | 736 | query: dict[str, Any] = {} 737 | if market: 738 | query["market"] = market 739 | 740 | return await self.request( 741 | Route("GET", "/tracks/{id}", id=_id), 742 | query=query, credentials=credentials 743 | ) 744 | 745 | async def get_multiple_tracks( 746 | self, 747 | ids: Sequence[str], 748 | *, 749 | market: str | None, 750 | credentials: AnyCredentials | None = None 751 | ) -> dict[Literal["tracks"], list[TrackData | None]]: 752 | 753 | if len(ids) > 50: 754 | raise ValueError("'ids' can not contain more than 50 ids.") 755 | 756 | query: dict[str, Any] = { 757 | "ids": ",".join(ids) 758 | } 759 | if market: 760 | query["market"] = market 761 | 762 | return await self.request( 763 | Route("GET", "/tracks"), 764 | query=query, credentials=credentials 765 | ) 766 | 767 | async def get_saved_tracks( 768 | self, 769 | *, 770 | limit: int | None, 771 | offset: int | None, 772 | market: str | None, 773 | credentials: UserCredentials 774 | ) -> PagingObjectData[TrackData]: 775 | 776 | query: dict[str, Any] = {} 777 | if limit: 778 | limit_value("limit", limit, 1, 50) 779 | query["limit"] = limit 780 | if offset: 781 | query["offset"] = offset 782 | if market: 783 | query["market"] = market 784 | 785 | return await self.request( 786 | Route("GET", "/me/tracks"), 787 | query=query, credentials=credentials 788 | ) 789 | 790 | async def save_tracks( 791 | self, 792 | ids: list[str], 793 | /, *, 794 | credentials: UserCredentials 795 | ) -> None: 796 | 797 | if len(ids) > 50: 798 | raise ValueError("'ids' can not contain more than 50 ids.") 799 | 800 | query: dict[str, Any] = { 801 | "ids": ",".join(ids) 802 | } 803 | return await self.request( 804 | Route("PUT", "/me/tracks"), 805 | query=query, credentials=credentials 806 | ) 807 | 808 | async def remove_tracks( 809 | self, 810 | ids: list[str], 811 | /, *, 812 | credentials: UserCredentials 813 | ) -> None: 814 | 815 | if len(ids) > 50: 816 | raise ValueError("'ids' can not contain more than 50 ids.") 817 | 818 | query: dict[str, Any] = { 819 | "ids": ",".join(ids) 820 | } 821 | return await self.request( 822 | Route("DELETE", "/me/tracks"), 823 | query=query, credentials=credentials 824 | ) 825 | 826 | async def check_saved_tracks( 827 | self, 828 | ids: list[str], 829 | /, *, 830 | credentials: UserCredentials 831 | ) -> list[bool]: 832 | 833 | if len(ids) > 50: 834 | raise ValueError("'ids' can not contain more than 50 ids.") 835 | 836 | query: dict[str, Any] = { 837 | "ids": ",".join(ids) 838 | } 839 | return await self.request( 840 | Route("GET", "/me/tracks/contains"), 841 | query=query, credentials=credentials 842 | ) 843 | 844 | async def get_track_audio_features( 845 | self, 846 | _id: str, 847 | /, *, 848 | credentials: AnyCredentials | None = None 849 | ) -> AudioFeaturesData: 850 | return await self.request( 851 | Route("GET", "/audio-features/{id}", id=_id), 852 | credentials=credentials 853 | ) 854 | 855 | async def get_multiple_tracks_audio_features( 856 | self, 857 | ids: Sequence[str], 858 | /, *, 859 | credentials: AnyCredentials | None = None 860 | ) -> dict[Literal["audio_features"], list[AudioFeaturesData | None]]: 861 | 862 | if len(ids) > 100: 863 | raise ValueError("'ids' can not contain more than 100 ids.") 864 | 865 | query: dict[str, Any] = { 866 | "ids": ",".join(ids) 867 | } 868 | return await self.request( 869 | Route("GET", "/audio-features"), 870 | query=query, credentials=credentials 871 | ) 872 | 873 | async def get_track_audio_analysis( 874 | self, 875 | _id: str, 876 | /, *, 877 | credentials: AnyCredentials | None = None 878 | ) -> dict[str, Any]: # TODO: create TypedDict for this monstrosity 879 | return await self.request( 880 | Route("GET", "/audio-analysis/{id}", id=_id), 881 | credentials=credentials 882 | ) 883 | 884 | async def get_recommendations( 885 | self, 886 | *, 887 | seed_artist_ids: list[str] | None, 888 | seed_track_ids: list[str] | None, 889 | seed_genres: list[str] | None, 890 | limit: int | None, 891 | market: str | None, 892 | credentials: AnyCredentials | None = None, 893 | **kwargs: int 894 | ) -> RecommendationsData: 895 | 896 | count = len(seed_artist_ids or []) + len(seed_track_ids or []) + len(seed_genres or []) 897 | if count < 1 or count > 5: 898 | raise ValueError("too many or not enough seed values provided. minimum 1, maximum 5.") 899 | 900 | query: dict[str, Any] = {} 901 | if seed_artist_ids: 902 | query["seed_artists"] = ",".join(seed_artist_ids) 903 | if seed_track_ids: 904 | query["seed_tracks"] = ",".join(seed_track_ids) 905 | if seed_genres: 906 | query["seed_genres"] = ",".join(seed_genres) 907 | 908 | for key, value in kwargs.items(): 909 | if key not in VALID_RECOMMENDATION_SEED_KWARGS: 910 | raise ValueError(f"'{key}' is not a valid keyword argument for this method.") 911 | query[key] = value 912 | 913 | if limit: 914 | limit_value("limit", limit, 1, 100) 915 | query["limit"] = limit 916 | if market: 917 | query["market"] = market 918 | 919 | return await self.request( 920 | Route("GET", "/recommendations"), 921 | query=query, credentials=credentials 922 | ) 923 | 924 | # SEARCH API 925 | 926 | async def search( 927 | self, 928 | _query: str, 929 | /, *, 930 | search_types: list[SearchType], 931 | include_external: bool, 932 | limit: int | None, 933 | offset: int | None, 934 | market: str | None, 935 | credentials: AnyCredentials | None = None 936 | ) -> SearchResultData: 937 | 938 | query: dict[str, Any] = { 939 | "q": _query.replace(" ", "+"), 940 | "type": ",".join(search_type.value for search_type in search_types) 941 | } 942 | if include_external: 943 | query["include_external"] = "audio" 944 | if limit: 945 | limit_value("limit", limit, 1, 50) 946 | query["limit"] = limit 947 | if offset: 948 | query["offset"] = offset 949 | if market: 950 | query["market"] = market 951 | 952 | return await self.request( 953 | Route("GET", "/search"), 954 | query=query, credentials=credentials 955 | ) 956 | 957 | # USERS API 958 | 959 | async def get_current_user_profile( 960 | self, 961 | *, 962 | credentials: UserCredentials 963 | ) -> UserData: 964 | return await self.request( 965 | Route("GET", "/me"), 966 | credentials=credentials 967 | ) 968 | 969 | async def get_current_user_top_artists( 970 | self, 971 | *, 972 | limit: int | None, 973 | offset: int | None, 974 | time_range: TimeRange | None, 975 | credentials: UserCredentials 976 | ) -> PagingObjectData[ArtistData]: 977 | 978 | query: dict[str, Any] = {} 979 | if limit: 980 | limit_value("limit", limit, 1, 50) 981 | query["limit"] = limit 982 | if offset: 983 | query["offset"] = offset 984 | if time_range: 985 | query["time_range"] = time_range.value 986 | 987 | return await self.request( 988 | Route("GET", "/me/top/artists"), 989 | query=query, credentials=credentials 990 | ) 991 | 992 | async def get_current_user_top_tracks( 993 | self, 994 | *, 995 | limit: int | None, 996 | offset: int | None, 997 | time_range: TimeRange | None, 998 | credentials: UserCredentials 999 | ) -> PagingObjectData[TrackData]: 1000 | 1001 | query: dict[str, Any] = {} 1002 | if limit: 1003 | limit_value("limit", limit, 1, 50) 1004 | query["limit"] = limit 1005 | if offset: 1006 | query["offset"] = offset 1007 | if time_range: 1008 | query["time_range"] = time_range.value 1009 | 1010 | return await self.request( 1011 | Route("GET", "/me/top/tracks"), 1012 | query=query, credentials=credentials 1013 | ) 1014 | 1015 | async def get_user_profile( 1016 | self, 1017 | _id: str, 1018 | /, *, 1019 | credentials: AnyCredentials | None = None 1020 | ) -> UserData: 1021 | return await self.request( 1022 | Route("GET", "/users/{id}", id=_id), 1023 | credentials=credentials 1024 | ) 1025 | 1026 | async def follow_playlist( 1027 | self, 1028 | _id: str, 1029 | /, *, 1030 | public: bool, 1031 | credentials: UserCredentials 1032 | ) -> None: 1033 | 1034 | body: dict[str, Any] = {} 1035 | if public: 1036 | body["public"] = public 1037 | 1038 | return await self.request( 1039 | Route("PUT", "playlists/{id}/followers", id=_id), 1040 | json=body, credentials=credentials 1041 | ) 1042 | 1043 | async def unfollow_playlist( 1044 | self, 1045 | _id: str, 1046 | /, *, 1047 | credentials: UserCredentials 1048 | ) -> None: 1049 | return await self.request( 1050 | Route("DELETE", "playlists/{id}/followers", id=_id), 1051 | credentials=credentials 1052 | ) 1053 | 1054 | async def check_if_users_follow_playlist( 1055 | self, 1056 | _id: str, 1057 | /, *, 1058 | user_ids: list[str], 1059 | credentials: AnyCredentials | None = None 1060 | ) -> None: 1061 | 1062 | if len(user_ids) > 5: 1063 | raise ValueError("'user_ids' can not contain more than 5 ids.") 1064 | 1065 | query: dict[str, Any] = { 1066 | "ids": ",".join(user_ids) 1067 | } 1068 | return await self.request( 1069 | Route("GET", "/playlists/{id}/followers/contains", id=_id), 1070 | query=query, credentials=credentials 1071 | ) 1072 | 1073 | async def get_followed_artists( 1074 | self, 1075 | *, 1076 | limit: int | None, 1077 | after: str | None, 1078 | credentials: UserCredentials 1079 | ) -> AlternativePagingObjectData[ArtistData]: 1080 | 1081 | query: dict[str, Any] = { 1082 | "type": "artist" 1083 | } 1084 | if limit: 1085 | limit_value("limit", limit, 1, 50) 1086 | query["limit"] = limit 1087 | if after: 1088 | query["after"] = after 1089 | 1090 | return await self.request( 1091 | Route("GET", "/me/following"), 1092 | query=query, credentials=credentials 1093 | ) 1094 | 1095 | async def follow_artists( 1096 | self, 1097 | ids: list[str], 1098 | /, *, 1099 | credentials: UserCredentials 1100 | ) -> None: 1101 | 1102 | if len(ids) > 50: 1103 | raise ValueError("'ids' can not contain more than 50 ids.") 1104 | 1105 | query: dict[str, Any] = { 1106 | "type": "artist", 1107 | "ids": ",".join(ids) 1108 | } 1109 | return await self.request( 1110 | Route("PUT", "/me/following"), 1111 | query=query, credentials=credentials 1112 | ) 1113 | 1114 | async def unfollow_artists( 1115 | self, 1116 | ids: list[str], 1117 | /, *, 1118 | credentials: UserCredentials 1119 | ) -> None: 1120 | 1121 | if len(ids) > 50: 1122 | raise ValueError("'ids' can not contain more than 50 ids.") 1123 | 1124 | query: dict[str, Any] = { 1125 | "type": "artist", 1126 | "ids": ",".join(ids) 1127 | } 1128 | return await self.request( 1129 | Route("DELETE", "/me/following"), 1130 | query=query, credentials=credentials 1131 | ) 1132 | 1133 | async def check_followed_artists( 1134 | self, 1135 | ids: list[str], 1136 | /, *, 1137 | credentials: UserCredentials 1138 | ) -> list[bool]: 1139 | 1140 | if len(ids) > 50: 1141 | raise ValueError("'ids' can not contain more than 50 ids.") 1142 | 1143 | query: dict[str, Any] = { 1144 | "type": "artist", 1145 | "ids": ",".join(ids) 1146 | } 1147 | return await self.request( 1148 | Route("GET", "/me/following/contains"), 1149 | query=query, credentials=credentials 1150 | ) 1151 | 1152 | async def follow_users( 1153 | self, 1154 | ids: list[str], 1155 | /, *, 1156 | credentials: UserCredentials 1157 | ) -> None: 1158 | 1159 | if len(ids) > 50: 1160 | raise ValueError("'ids' can not contain more than 50 ids.") 1161 | 1162 | query: dict[str, Any] = { 1163 | "type": "user", 1164 | "ids": ",".join(ids) 1165 | } 1166 | return await self.request( 1167 | Route("PUT", "/me/following"), 1168 | query=query, credentials=credentials 1169 | ) 1170 | 1171 | async def unfollow_users( 1172 | self, 1173 | ids: list[str], 1174 | /, *, 1175 | credentials: UserCredentials 1176 | ) -> None: 1177 | 1178 | if len(ids) > 50: 1179 | raise ValueError("'ids' can not contain more than 50 ids.") 1180 | 1181 | query: dict[str, Any] = { 1182 | "type": "user", 1183 | "ids": ",".join(ids) 1184 | } 1185 | return await self.request( 1186 | Route("DELETE", "/me/following"), 1187 | query=query, credentials=credentials 1188 | ) 1189 | 1190 | async def check_followed_users( 1191 | self, 1192 | ids: list[str], 1193 | /, *, 1194 | credentials: UserCredentials 1195 | ) -> list[bool]: 1196 | 1197 | if len(ids) > 50: 1198 | raise ValueError("'ids' can not contain more than 50 ids.") 1199 | 1200 | query: dict[str, Any] = { 1201 | "type": "user", 1202 | "ids": ",".join(ids) 1203 | } 1204 | return await self.request( 1205 | Route("GET", "/me/following/contains"), 1206 | query=query, credentials=credentials 1207 | ) 1208 | 1209 | # PLAYLISTS API 1210 | # TODO: Check through docs to make sure every parameter is accounted for 1211 | 1212 | async def get_playlist( 1213 | self, 1214 | _id: str, 1215 | /, *, 1216 | fields: str | None, 1217 | market: str | None, 1218 | credentials: AnyCredentials | None = None 1219 | ) -> PlaylistData: 1220 | 1221 | query: dict[str, Any] = {} 1222 | if fields: 1223 | query["fields"] = fields 1224 | if market: 1225 | query["market"] = market 1226 | 1227 | return await self.request( 1228 | Route("GET", "/playlists/{id}", id=_id), 1229 | query=query, credentials=credentials 1230 | ) 1231 | 1232 | async def change_playlist_details( 1233 | self, 1234 | _id: str, 1235 | /, *, 1236 | name: str | None, 1237 | public: bool | None, 1238 | collaborative: bool | None, 1239 | description: str | None, 1240 | credentials: UserCredentials 1241 | ) -> None: 1242 | 1243 | if collaborative and public: 1244 | raise ValueError("collaborative playlists can not be public.") 1245 | 1246 | body: dict[str, Any] = {} 1247 | if name: 1248 | body["name"] = name 1249 | if public: 1250 | body["public"] = public 1251 | if collaborative: 1252 | body["collaborative"] = collaborative 1253 | if description: 1254 | body["description"] = description 1255 | 1256 | return await self.request( 1257 | Route("PUT", "/playlists/{id}", id=_id), 1258 | json=body, credentials=credentials 1259 | ) 1260 | 1261 | async def get_playlist_items( 1262 | self, 1263 | _id: str, 1264 | /, *, 1265 | fields: str | None, 1266 | limit: int | None, 1267 | offset: int | None, 1268 | market: str | None, 1269 | credentials: AnyCredentials | None = None 1270 | ) -> PagingObjectData[PlaylistTrackData]: 1271 | 1272 | query: dict[str, Any] = {} 1273 | 1274 | if fields: 1275 | query["fields"] = fields 1276 | if limit: 1277 | limit_value("limit", limit, 1, 100) 1278 | query["limit"] = limit 1279 | if offset: 1280 | query["offset"] = offset 1281 | if market: 1282 | query["market"] = market 1283 | 1284 | return await self.request( 1285 | Route("GET", "/playlists/{id}/tracks", id=_id), 1286 | query=query, credentials=credentials 1287 | ) 1288 | 1289 | async def add_items_to_playlist( 1290 | self, 1291 | _id: str, 1292 | /, *, 1293 | uris: list[str], 1294 | position: int | None, 1295 | credentials: UserCredentials 1296 | ) -> PlaylistSnapshotID: 1297 | 1298 | if len(uris) > 100: 1299 | raise ValueError("'uris' can not contain more than 100 URI's.") 1300 | 1301 | body: dict[str, Any] = { 1302 | "uris": uris 1303 | } 1304 | if position: 1305 | body["position"] = position 1306 | 1307 | return await self.request( 1308 | Route("POST", "/playlists/{id}/tracks", id=_id), 1309 | json=body, credentials=credentials 1310 | ) 1311 | 1312 | async def reorder_playlist_items( 1313 | self, 1314 | _id: str, 1315 | /, *, 1316 | range_start: int, 1317 | range_length: int, 1318 | insert_before: int, 1319 | snapshot_id: str | None, 1320 | credentials: UserCredentials 1321 | ) -> PlaylistSnapshotID: 1322 | 1323 | body: dict[str, Any] = { 1324 | "range_start": range_start, 1325 | "range_length": range_length, 1326 | "insert_before": insert_before 1327 | } 1328 | if snapshot_id: 1329 | body["snapshot_id"] = snapshot_id 1330 | 1331 | return await self.request( 1332 | Route("PUT", "/playlists/{id}/tracks", id=_id), 1333 | json=body, credentials=credentials 1334 | ) 1335 | 1336 | async def replace_playlist_items( 1337 | self, 1338 | _id: str, 1339 | /, *, 1340 | uris: list[str], 1341 | credentials: UserCredentials 1342 | ) -> None: 1343 | 1344 | if len(uris) > 100: 1345 | raise ValueError("'uris' can not contain more than 100 URI's.") 1346 | 1347 | body: dict[str, Any] = { 1348 | "uris": uris 1349 | } 1350 | return await self.request( 1351 | Route("PUT", "/playlists/{id}/tracks", id=_id), 1352 | json=body, credentials=credentials 1353 | ) 1354 | 1355 | async def remove_items_from_playlist( 1356 | self, 1357 | _id: str, 1358 | /, *, 1359 | uris: list[str], 1360 | snapshot_id: str | None, 1361 | credentials: UserCredentials 1362 | ) -> PlaylistSnapshotID: 1363 | 1364 | if len(uris) > 100: 1365 | raise ValueError("'uris' can not contain more than 100 URI's.") 1366 | 1367 | body: dict[str, Any] = { 1368 | "tracks": [{"uri": uri} for uri in uris] 1369 | } 1370 | if snapshot_id: 1371 | body["snapshot_id"] = snapshot_id 1372 | 1373 | return await self.request( 1374 | Route("DELETE", "/playlists/{id}/tracks", id=_id), 1375 | json=body, credentials=credentials 1376 | ) 1377 | 1378 | async def get_current_user_playlists( 1379 | self, 1380 | *, 1381 | limit: int | None, 1382 | offset: int | None, 1383 | credentials: UserCredentials 1384 | ) -> PagingObjectData[SimplePlaylistData]: 1385 | 1386 | query: dict[str, Any] = {} 1387 | if limit: 1388 | limit_value("limit", limit, 1, 50) 1389 | query["limit"] = limit 1390 | if offset: 1391 | query["offset"] = offset 1392 | 1393 | return await self.request( 1394 | Route("GET", "/me/playlists"), 1395 | query=query, credentials=credentials 1396 | ) 1397 | 1398 | async def get_user_playlists( 1399 | self, 1400 | _id: str, 1401 | /, *, 1402 | limit: int | None, 1403 | offset: int | None, 1404 | credentials: AnyCredentials | None = None 1405 | ) -> PagingObjectData[SimplePlaylistData]: 1406 | 1407 | query: dict[str, Any] = {} 1408 | if limit: 1409 | limit_value("limit", limit, 1, 50) 1410 | query["limit"] = limit 1411 | if offset: 1412 | query["offset"] = offset 1413 | 1414 | return await self.request( 1415 | Route("GET", "/users/{id}/playlists", id=_id), 1416 | query=query, credentials=credentials 1417 | ) 1418 | 1419 | async def create_playlist( 1420 | self, 1421 | *, 1422 | user_id: str, 1423 | name: str, 1424 | public: bool | None, 1425 | collaborative: bool | None, 1426 | description: str | None, 1427 | credentials: UserCredentials 1428 | ) -> PlaylistData: 1429 | 1430 | if collaborative and public: 1431 | raise ValueError("collaborative playlists can not be public.") 1432 | 1433 | body: dict[str, Any] = { 1434 | "name": name 1435 | } 1436 | if public: 1437 | body["public"] = public 1438 | if collaborative: 1439 | body["collaborative"] = collaborative 1440 | if description: 1441 | body["description"] = description 1442 | 1443 | return await self.request( 1444 | Route("POST", "/users/{user_id}/playlists", user_id=user_id), 1445 | json=body, credentials=credentials 1446 | ) 1447 | 1448 | async def get_featured_playlists( 1449 | self, 1450 | *, 1451 | country: str | None, 1452 | locale: str | None, 1453 | timestamp: str | None, 1454 | limit: int | None, 1455 | offset: int | None, 1456 | credentials: AnyCredentials | None = None 1457 | ) -> FeaturedPlaylistsData: 1458 | 1459 | query: dict[str, Any] = {} 1460 | if country: 1461 | query["country"] = country 1462 | if locale: 1463 | query["locale"] = locale 1464 | if timestamp: 1465 | query["timestamp"] = timestamp 1466 | if limit: 1467 | limit_value("limit", limit, 1, 50) 1468 | query["limit"] = limit 1469 | if offset: 1470 | query["offset"] = offset 1471 | 1472 | return await self.request( 1473 | Route("GET", "/browse/featured-playlists"), 1474 | query=query, credentials=credentials 1475 | ) 1476 | 1477 | async def get_category_playlists( 1478 | self, 1479 | _id: str, 1480 | /, *, 1481 | limit: int | None, 1482 | offset: int | None, 1483 | country: str | None, 1484 | credentials: AnyCredentials | None = None 1485 | ) -> dict[Literal["playlists"], PagingObjectData[SimplePlaylistData]]: 1486 | 1487 | query: dict[str, Any] = {} 1488 | if limit: 1489 | limit_value("limit", limit, 1, 50) 1490 | query["limit"] = limit 1491 | if offset: 1492 | query["offset"] = offset 1493 | if country: 1494 | query["country"] = country 1495 | 1496 | return await self.request( 1497 | Route("GET", "/browse/categories/{id}/playlists", id=_id), 1498 | query=query, credentials=credentials 1499 | ) 1500 | 1501 | async def get_playlist_cover_image( 1502 | self, 1503 | _id: str, 1504 | /, *, 1505 | credentials: AnyCredentials | None = None 1506 | ) -> list[ImageData]: 1507 | return await self.request( 1508 | Route("GET", "/playlists/{id}/images", id=_id), 1509 | credentials=credentials 1510 | ) 1511 | 1512 | async def upload_playlist_cover_image( 1513 | self, 1514 | _id: str, 1515 | /, *, 1516 | url: str, 1517 | credentials: UserCredentials 1518 | ) -> None: 1519 | 1520 | session = await self._get_session() 1521 | async with session.get(url) as request: 1522 | 1523 | if request.status != 200: 1524 | raise SpotipyError("There was a problem while uploading that image.") 1525 | 1526 | image_bytes = await request.read() 1527 | body = base64.b64encode(image_bytes).decode("utf-8") 1528 | 1529 | return await self.request( 1530 | Route("PUT", "/playlists/{id}/images", id=_id), 1531 | body=body, credentials=credentials 1532 | ) 1533 | 1534 | # CATEGORY API 1535 | 1536 | async def get_categories( 1537 | self, 1538 | *, 1539 | limit: int | None, 1540 | offset: int | None, 1541 | locale: str | None, 1542 | country: str | None, 1543 | credentials: AnyCredentials | None = None 1544 | ) -> dict[Literal["categories"], PagingObjectData[CategoryData]]: 1545 | 1546 | query: dict[str, Any] = {} 1547 | if limit: 1548 | limit_value("limit", limit, 1, 50) 1549 | query["limit"] = limit 1550 | if offset: 1551 | query["offset"] = offset 1552 | if locale: 1553 | query["locale"] = locale 1554 | if country: 1555 | query["country"] = country 1556 | 1557 | return await self.request( 1558 | Route("GET", "/browse/categories"), 1559 | query=query, credentials=credentials 1560 | ) 1561 | 1562 | async def get_category( 1563 | self, 1564 | _id: str, 1565 | /, *, 1566 | locale: str | None, 1567 | country: str | None, 1568 | credentials: AnyCredentials | None = None 1569 | ) -> CategoryData: 1570 | 1571 | query: dict[str, Any] = {} 1572 | if locale: 1573 | query["locale"] = locale 1574 | if country: 1575 | query["country"] = country 1576 | 1577 | return await self.request( 1578 | Route("GET", "/browse/categories/{id}", id=_id), 1579 | query=query, credentials=credentials 1580 | ) 1581 | 1582 | # GENRE API 1583 | 1584 | async def get_available_genre_seeds( 1585 | self, 1586 | *, 1587 | credentials: AnyCredentials | None = None 1588 | ) -> dict[Literal["genres"], list[str]]: 1589 | return await self.request( 1590 | Route("GET", "/recommendations/available-genre-seeds"), 1591 | credentials=credentials 1592 | ) 1593 | 1594 | # PLAYER API 1595 | 1596 | async def get_playback_state( 1597 | self, 1598 | *, 1599 | market: str | None, 1600 | credentials: UserCredentials 1601 | ) -> PlaybackStateData: 1602 | 1603 | query: dict[str, Any] = {} 1604 | if market: 1605 | query["market"] = market 1606 | 1607 | return await self.request( 1608 | Route("GET", "/me/player"), 1609 | query=query, credentials=credentials 1610 | ) 1611 | 1612 | async def transfer_playback( 1613 | self, 1614 | *, 1615 | device_id: str, 1616 | ensure_playback: bool | None, 1617 | credentials: UserCredentials 1618 | ) -> None: 1619 | 1620 | body: dict[str, Any] = { 1621 | "device_ids": [device_id] 1622 | } 1623 | if ensure_playback: 1624 | body["play"] = ensure_playback 1625 | 1626 | return await self.request( 1627 | Route("PUT", "/me/player"), 1628 | json=body, credentials=credentials 1629 | ) 1630 | 1631 | async def get_available_devices( 1632 | self, 1633 | *, 1634 | credentials: UserCredentials 1635 | ) -> dict[Literal["devices"], list[DeviceData]]: 1636 | return await self.request( 1637 | Route("GET", "/me/player/devices"), 1638 | credentials=credentials 1639 | ) 1640 | 1641 | async def get_currently_playing_track( 1642 | self, 1643 | *, 1644 | market: str | None, 1645 | credentials: UserCredentials 1646 | ) -> CurrentlyPlayingData: 1647 | 1648 | query: dict[str, Any] = {} 1649 | if market: 1650 | query["market"] = market 1651 | 1652 | return await self.request( 1653 | Route("GET", "/me/player/currently-playing"), 1654 | query=query, credentials=credentials 1655 | ) 1656 | 1657 | async def start_playback( 1658 | self, 1659 | *, 1660 | device_id: str | None, 1661 | context_uri: str | None, 1662 | uris: list[str] | None, 1663 | offset: int | str | None, 1664 | position_ms: int | None, 1665 | credentials: UserCredentials 1666 | ) -> None: 1667 | 1668 | if context_uri and uris: 1669 | raise ValueError("'context_uri' and 'uris' can not both be specified.") 1670 | 1671 | query: dict[str, Any] = {} 1672 | if device_id: 1673 | query["device_id"] = device_id 1674 | 1675 | body: dict[str, Any] = {} 1676 | 1677 | if context_uri or uris: 1678 | if context_uri: 1679 | body["context_uri"] = context_uri 1680 | if uris: 1681 | body["uris"] = uris 1682 | if offset: 1683 | body["offset"] = {} 1684 | if isinstance(offset, int): 1685 | body["offset"]["position"] = offset 1686 | else: 1687 | body["offset"]["uri"] = offset 1688 | if position_ms: 1689 | body["position_ms"] = position_ms 1690 | 1691 | return await self.request( 1692 | Route("PUT", "/me/player/play"), 1693 | query=query, json=body, credentials=credentials 1694 | ) 1695 | 1696 | async def resume_playback( 1697 | self, 1698 | *, 1699 | device_id: str | None, 1700 | offset: int | str | None, 1701 | position_ms: int | None, 1702 | credentials: UserCredentials 1703 | ) -> None: 1704 | 1705 | # TODO: wtf is happening here 1706 | 1707 | return await self.start_playback( 1708 | device_id=device_id, 1709 | context_uri=None, 1710 | uris=None, 1711 | offset=offset, 1712 | position_ms=position_ms, 1713 | credentials=credentials 1714 | ) 1715 | 1716 | async def pause_playback( 1717 | self, 1718 | *, 1719 | device_id: str | None, 1720 | credentials: UserCredentials 1721 | ) -> None: 1722 | 1723 | query: dict[str, Any] = {} 1724 | if device_id: 1725 | query["device_id"] = device_id 1726 | 1727 | return await self.request( 1728 | Route("PUT", "/me/player/pause"), 1729 | query=query, credentials=credentials 1730 | ) 1731 | 1732 | async def skip_to_next( 1733 | self, 1734 | *, 1735 | device_id: str | None, 1736 | credentials: UserCredentials 1737 | ) -> None: 1738 | 1739 | query: dict[str, Any] = {} 1740 | if device_id: 1741 | query["device_id"] = device_id 1742 | 1743 | return await self.request( 1744 | Route("POST", "/me/player/next"), 1745 | query=query, credentials=credentials 1746 | ) 1747 | 1748 | async def skip_to_previous( 1749 | self, 1750 | *, 1751 | device_id: str | None, 1752 | credentials: UserCredentials 1753 | ) -> None: 1754 | 1755 | query: dict[str, Any] = {} 1756 | if device_id: 1757 | query["device_id"] = device_id 1758 | 1759 | return await self.request( 1760 | Route("POST", "/me/player/previous"), 1761 | query=query, credentials=credentials 1762 | ) 1763 | 1764 | async def seek_to_position( 1765 | self, 1766 | *, 1767 | position_ms: int, 1768 | device_id: str | None, 1769 | credentials: UserCredentials 1770 | ) -> None: 1771 | 1772 | query: dict[str, Any] = { 1773 | "position_ms": position_ms 1774 | } 1775 | if device_id: 1776 | query["device_id"] = device_id 1777 | 1778 | return await self.request( 1779 | Route("PUT", "/me/player/seek"), 1780 | query=query, credentials=credentials 1781 | ) 1782 | 1783 | async def set_repeat_mode( 1784 | self, 1785 | *, 1786 | repeat_mode: RepeatMode, 1787 | device_id: str | None, 1788 | credentials: UserCredentials 1789 | ) -> None: 1790 | 1791 | query: dict[str, Any] = { 1792 | "state": repeat_mode.value 1793 | } 1794 | if device_id: 1795 | query["device_id"] = device_id 1796 | 1797 | return await self.request( 1798 | Route("PUT", "/me/player/repeat"), 1799 | query=query, credentials=credentials 1800 | ) 1801 | 1802 | async def set_playback_volume( 1803 | self, 1804 | *, 1805 | volume_percent: int, 1806 | device_id: str | None, 1807 | credentials: UserCredentials 1808 | ) -> None: 1809 | 1810 | limit_value("volume_percent", volume_percent, 0, 100) 1811 | 1812 | query: dict[str, Any] = { 1813 | "volume_percent": volume_percent 1814 | } 1815 | if device_id: 1816 | query["device_id"] = device_id 1817 | 1818 | return await self.request( 1819 | Route("PUT", "/me/player/volume"), 1820 | query=query, credentials=credentials 1821 | ) 1822 | 1823 | async def toggle_playback_shuffle( 1824 | self, 1825 | *, 1826 | state: bool, 1827 | device_id: str | None, 1828 | credentials: UserCredentials 1829 | ) -> None: 1830 | 1831 | query: dict[str, Any] = { 1832 | "state": "true" if state else "false" 1833 | } 1834 | if device_id: 1835 | query["device_id"] = device_id 1836 | 1837 | return await self.request( 1838 | Route("PUT", "/me/player/shuffle"), 1839 | query=query, credentials=credentials 1840 | ) 1841 | 1842 | async def get_recently_played_tracks( 1843 | self, 1844 | *, 1845 | limit: int | None, 1846 | before: int | None, 1847 | after: int | None, 1848 | credentials: UserCredentials 1849 | ) -> AlternativePagingObjectData[SimpleTrackData]: 1850 | 1851 | if before and after: 1852 | raise ValueError("'before' and 'after' can not both be specified.") 1853 | 1854 | query: dict[str, Any] = {} 1855 | if limit: 1856 | query["limit"] = limit 1857 | if before: 1858 | query["before"] = before 1859 | if after: 1860 | query["after"] = after 1861 | 1862 | return await self.request( 1863 | Route("GET", "/me/player/recently-played"), 1864 | query=query, credentials=credentials 1865 | ) 1866 | 1867 | async def add_item_to_playback_queue( 1868 | self, 1869 | *, 1870 | uri: str, 1871 | device_id: str | None, 1872 | credentials: UserCredentials 1873 | ) -> None: 1874 | 1875 | query: dict[str, Any] = { 1876 | "uri": uri 1877 | } 1878 | if device_id: 1879 | query["device_id"] = device_id 1880 | 1881 | return await self.request( 1882 | Route("POST", "/me/player/queue"), 1883 | query=query, credentials=credentials 1884 | ) 1885 | 1886 | # MARKETS API 1887 | 1888 | async def get_available_markets( 1889 | self, 1890 | *, 1891 | credentials: AnyCredentials | None = None 1892 | ) -> dict[Literal["markets"], list[str]]: 1893 | return await self.request( 1894 | Route("GET", "/markets"), 1895 | credentials=credentials 1896 | ) 1897 | --------------------------------------------------------------------------------