├── docs ├── _static │ └── .gitignore ├── requirements.txt ├── api │ ├── user.rst │ ├── inbox.rst │ ├── config.rst │ ├── session.rst │ ├── sink.rst │ ├── link.rst │ ├── player.rst │ ├── eventloop.rst │ ├── offline.rst │ ├── album.rst │ ├── artist.rst │ ├── image.rst │ ├── toplist.rst │ ├── track.rst │ ├── social.rst │ ├── connection.rst │ ├── search.rst │ ├── error.rst │ ├── audio.rst │ ├── index.rst │ ├── playlist.rst │ └── internal.rst ├── index.rst ├── authors.rst ├── contributing.rst ├── conf.py ├── sp-constants.csv ├── Makefile └── installation.rst ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .mailmap ├── pyproject.toml ├── setup.py ├── .gitignore ├── .readthedocs.yml ├── AUTHORS ├── spotify ├── compat.py ├── version.py ├── _spotify_build.py ├── audio.py ├── error.py ├── playlist_unseen_tracks.py ├── user.py ├── playlist_track.py ├── player.py ├── eventloop.py ├── offline.py ├── inbox.py ├── social.py ├── __init__.py ├── connection.py ├── image.py ├── sink.py ├── link.py └── toplist.py ├── tests ├── test_lib.py ├── regression │ ├── failing_link_to_playlist.py │ ├── bug_122.py │ ├── bug_119.py │ └── bug_123.py ├── test_version.py ├── test_audio.py ├── __init__.py ├── test_eventloop.py ├── test_error.py ├── test_offline.py ├── test_loadable.py ├── test_user.py ├── test_playlist_track.py ├── test_player.py ├── test_playlist_unseen_tracks.py ├── test_social.py ├── test_connection.py ├── test_sink.py └── test_inbox.py ├── MANIFEST.in ├── examples ├── cover.py ├── play_track.py └── shell.py ├── tox.ini ├── setup.cfg ├── tasks.py └── README.rst /docs/_static/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jodal 2 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx == 4.5.0 2 | mock 3 | -------------------------------------------------------------------------------- /docs/api/user.rst: -------------------------------------------------------------------------------- 1 | ***** 2 | Users 3 | ***** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: User 9 | -------------------------------------------------------------------------------- /docs/api/inbox.rst: -------------------------------------------------------------------------------- 1 | ***** 2 | Inbox 3 | ***** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: InboxPostResult 9 | -------------------------------------------------------------------------------- /docs/api/config.rst: -------------------------------------------------------------------------------- 1 | ************* 2 | Configuration 3 | ************* 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: Config 9 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Richard Ive 2 | Thomas Vander Stichele 3 | Nick Steel 4 | Tarik Dadi 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 30.3.0", "wheel"] 3 | 4 | [tool.black] 5 | target-version = ["py35"] 6 | 7 | [tool.isort] 8 | profile = "black" 9 | -------------------------------------------------------------------------------- /docs/api/session.rst: -------------------------------------------------------------------------------- 1 | ******** 2 | Sessions 3 | ******** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: Session 9 | 10 | .. autoclass:: SessionEvent 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | # XXX Setting cffi_modules in setup.cfg does not have any effect. 5 | cffi_modules=["spotify/_spotify_build.py:ffi"] 6 | ) 7 | -------------------------------------------------------------------------------- /docs/api/sink.rst: -------------------------------------------------------------------------------- 1 | *********** 2 | Audio sinks 3 | *********** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: AlsaSink 9 | 10 | .. autoclass:: PortAudioSink 11 | -------------------------------------------------------------------------------- /docs/api/link.rst: -------------------------------------------------------------------------------- 1 | ***** 2 | Links 3 | ***** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: Link 9 | 10 | .. autoclass:: LinkType 11 | :no-inherited-members: 12 | -------------------------------------------------------------------------------- /docs/api/player.rst: -------------------------------------------------------------------------------- 1 | ****** 2 | Player 3 | ****** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: spotify.player.Player 9 | 10 | .. autoclass:: spotify.player.PlayerState 11 | -------------------------------------------------------------------------------- /docs/api/eventloop.rst: -------------------------------------------------------------------------------- 1 | ********** 2 | Event loop 3 | ********** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: EventLoop 9 | :no-undoc-members: 10 | :no-inherited-members: 11 | -------------------------------------------------------------------------------- /docs/api/offline.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Offline sync 3 | ************ 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: spotify.offline.Offline 9 | 10 | .. autoclass:: OfflineSyncStatus 11 | -------------------------------------------------------------------------------- /docs/api/album.rst: -------------------------------------------------------------------------------- 1 | ****** 2 | Albums 3 | ****** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: Album 9 | 10 | .. autoclass:: AlbumBrowser 11 | 12 | .. autoclass:: AlbumType 13 | :no-inherited-members: 14 | -------------------------------------------------------------------------------- /docs/api/artist.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | Artists 3 | ******* 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: Artist 9 | 10 | .. autoclass:: ArtistBrowser 11 | 12 | .. autoclass:: ArtistBrowserType 13 | :no-inherited-members: 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.so 4 | .coverage 5 | .eggs/ 6 | .mypy_cache/ 7 | .pytest_cache/ 8 | .python-version 9 | .tox/ 10 | .venv/ 11 | __pycache__/ 12 | _build/ 13 | _spotify.* 14 | build/ 15 | dist/ 16 | spotify_appkey.key 17 | tmp/ 18 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | python: 4 | version: 3.7 5 | install: 6 | - requirements: docs/requirements.txt 7 | 8 | sphinx: 9 | builder: htmldir 10 | configuration: docs/conf.py 11 | 12 | formats: 13 | - pdf 14 | - epub 15 | -------------------------------------------------------------------------------- /docs/api/image.rst: -------------------------------------------------------------------------------- 1 | ****** 2 | Images 3 | ****** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: Image 9 | 10 | .. autoclass:: ImageFormat 11 | :no-inherited-members: 12 | 13 | .. autoclass:: ImageSize 14 | :no-inherited-members: 15 | -------------------------------------------------------------------------------- /docs/api/toplist.rst: -------------------------------------------------------------------------------- 1 | ******** 2 | Toplists 3 | ******** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: Toplist 9 | 10 | .. autoclass:: ToplistRegion 11 | :no-inherited-members: 12 | 13 | .. autoclass:: ToplistType 14 | :no-inherited-members: 15 | -------------------------------------------------------------------------------- /docs/api/track.rst: -------------------------------------------------------------------------------- 1 | ****** 2 | Tracks 3 | ****** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: Track 9 | 10 | .. autoclass:: TrackAvailability 11 | :no-inherited-members: 12 | 13 | .. autoclass:: TrackOfflineStatus 14 | :no-inherited-members: 15 | -------------------------------------------------------------------------------- /docs/api/social.rst: -------------------------------------------------------------------------------- 1 | ****** 2 | Social 3 | ****** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: spotify.social.Social 9 | 10 | .. autoclass:: ScrobblingState 11 | :no-inherited-members: 12 | 13 | .. autoclass:: SocialProvider 14 | :no-inherited-members: 15 | -------------------------------------------------------------------------------- /docs/api/connection.rst: -------------------------------------------------------------------------------- 1 | ********** 2 | Connection 3 | ********** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: spotify.connection.Connection 9 | 10 | .. autoclass:: ConnectionRule 11 | :no-inherited-members: 12 | 13 | .. autoclass:: ConnectionState 14 | :no-inherited-members: 15 | 16 | .. autoclass:: ConnectionType 17 | :no-inherited-members: 18 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | - Stein Magnus Jodal 2 | - Richard Ive 3 | - Thomas Vander Stichele 4 | - Nick Steel 5 | - Trygve Aaberge 6 | - Tarik Dadi 7 | - vrs01 8 | - Thomas Adamcik 9 | - Edward Betts 10 | -------------------------------------------------------------------------------- /spotify/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | PY2 = sys.version_info[0] == 2 4 | 5 | if PY2: # pragma: no branch 6 | from collections import Iterable, MutableSequence, Sequence # noqa 7 | 8 | text_type = unicode # noqa 9 | binary_type = str 10 | else: 11 | from collections.abc import Iterable, MutableSequence, Sequence # noqa 12 | 13 | text_type = str 14 | binary_type = bytes 15 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | 4 | User's guide 5 | ============ 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | installation 11 | quickstart 12 | 13 | 14 | API reference 15 | ============= 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | 20 | api/index 21 | 22 | 23 | About 24 | ===== 25 | 26 | .. toctree:: 27 | :maxdepth: 2 28 | 29 | authors 30 | changelog 31 | contributing 32 | -------------------------------------------------------------------------------- /tests/test_lib.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | 7 | 8 | class LibTest(unittest.TestCase): 9 | def test_sp_error_message(self): 10 | self.assertEqual( 11 | spotify.ffi.string(spotify.lib.sp_error_message(0)), b"No error" 12 | ) 13 | 14 | def test_SPOTIFY_API_VERSION_macro(self): 15 | self.assertEqual(spotify.lib.SPOTIFY_API_VERSION, 12) 16 | -------------------------------------------------------------------------------- /docs/api/search.rst: -------------------------------------------------------------------------------- 1 | ****** 2 | Search 3 | ****** 4 | 5 | .. warning:: 6 | 7 | The search API was broken at 2016-02-03 by a server-side change 8 | made by Spotify. The functionality was never restored. 9 | 10 | Please use the Spotify Web API to perform searches. 11 | 12 | .. module:: spotify 13 | :noindex: 14 | 15 | .. autoclass:: Search 16 | 17 | .. autoclass:: SearchPlaylist 18 | 19 | .. autoclass:: SearchType 20 | :no-inherited-members: 21 | -------------------------------------------------------------------------------- /docs/api/error.rst: -------------------------------------------------------------------------------- 1 | ************** 2 | Error handling 3 | ************** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoexception:: Error 9 | :no-undoc-members: 10 | :no-inherited-members: 11 | 12 | .. autoclass:: ErrorType 13 | :no-inherited-members: 14 | 15 | .. autoexception:: LibError 16 | :no-undoc-members: 17 | :no-inherited-members: 18 | 19 | .. autoexception:: Timeout 20 | :no-undoc-members: 21 | :no-inherited-members: 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include *.rst 3 | include .mailmap 4 | include .readthedocs.yml 5 | include AUTHORS 6 | include LICENSE 7 | include MANIFEST.in 8 | include pyproject.toml 9 | include tox.ini 10 | 11 | recursive-include .circleci * 12 | recursive-include .github * 13 | 14 | recursive-include docs * 15 | prune docs/_build 16 | 17 | recursive-include examples *.py 18 | prune examples/tmp 19 | 20 | include spotify/api.h 21 | include spotify/api.processed.h 22 | 23 | recursive-include tests *.py 24 | 25 | global-exclude __pycache__/* 26 | -------------------------------------------------------------------------------- /docs/api/audio.rst: -------------------------------------------------------------------------------- 1 | ***** 2 | Audio 3 | ***** 4 | 5 | .. module:: spotify 6 | :noindex: 7 | 8 | .. autoclass:: AudioBufferStats 9 | :no-inherited-members: 10 | 11 | .. attribute:: samples 12 | 13 | Number of samples currently in the buffer. 14 | 15 | .. attribute:: stutter 16 | 17 | Number of stutters (audio dropouts) since the last query. 18 | 19 | .. autoclass:: AudioFormat 20 | 21 | .. autoclass:: Bitrate 22 | :no-inherited-members: 23 | 24 | .. autoclass:: SampleType 25 | :no-inherited-members: 26 | -------------------------------------------------------------------------------- /examples/cover.py: -------------------------------------------------------------------------------- 1 | import spotify 2 | 3 | # Assuming a spotify_appkey.key in the current dir: 4 | session = spotify.Session() 5 | 6 | # Assuming a previous login with remember_me=True and a proper logout: 7 | session.relogin() 8 | 9 | while session.connection.state != spotify.ConnectionState.LOGGED_IN: 10 | session.process_events() 11 | 12 | album = session.get_album("spotify:album:4m2880jivSbbyEGAKfITCa").load() 13 | cover = album.cover(spotify.ImageSize.LARGE).load() 14 | 15 | open("/tmp/cover.jpg", "wb").write(cover.data) 16 | open("/tmp/cover.html", "w").write('' % cover.data_uri) 17 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | Authors 3 | ******* 4 | 5 | pyspotify 2.x is copyright 2013-2022 Stein Magnus Jodal and contributors. 6 | pyspotify is licensed under the Apache License, Version 2.0. 7 | 8 | Thanks to Thomas Adamcik who continuously reviewed code and provided feedback 9 | during the development of pyspotify 2.x. 10 | 11 | The following persons have contributed to pyspotify 2.x. The list is in the 12 | order of first contribution. For details on who have contributed what, please 13 | refer to our Git repository. 14 | 15 | .. include:: ../AUTHORS 16 | 17 | If you want to contribute to pyspotify, see :doc:`contributing`. 18 | -------------------------------------------------------------------------------- /spotify/version.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from spotify import lib, utils 4 | 5 | 6 | def get_libspotify_api_version(): 7 | """Get the API compatibility level of the wrapped libspotify library. 8 | 9 | >>> import spotify 10 | >>> spotify.get_libspotify_api_version() 11 | 12 12 | """ 13 | return lib.SPOTIFY_API_VERSION 14 | 15 | 16 | def get_libspotify_build_id(): 17 | """Get the build ID of the wrapped libspotify library. 18 | 19 | >>> import spotify 20 | >>> spotify.get_libspotify_build_id() 21 | u'12.1.51.g86c92b43 Release Linux-x86_64 ' 22 | """ 23 | return utils.to_unicode(lib.sp_build_id()) 24 | -------------------------------------------------------------------------------- /spotify/_spotify_build.py: -------------------------------------------------------------------------------- 1 | import os 2 | from distutils.version import StrictVersion 3 | 4 | import cffi 5 | 6 | if StrictVersion(cffi.__version__) < StrictVersion("1.0.0"): 7 | raise RuntimeError( 8 | "pyspotify requires cffi >= 1.0, but found %s" % cffi.__version__ 9 | ) 10 | 11 | 12 | header_file = os.path.join(os.path.dirname(__file__), "api.processed.h") 13 | 14 | with open(header_file) as fh: 15 | header = fh.read() 16 | header += "#define SPOTIFY_API_VERSION ...\n" 17 | 18 | ffi = cffi.FFI() 19 | ffi.cdef(header) 20 | ffi.set_source("spotify._spotify", '#include "libspotify/api.h"', libraries=["spotify"]) 21 | 22 | 23 | if __name__ == "__main__": 24 | ffi.compile() 25 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py27, py35, py36, py37, py38, py39, py310, 4 | pypy, pypy3, 5 | check-manifest, docs, flake8 6 | 7 | [testenv] 8 | # XXX For the tests to find the spotify._spotify module, we need to either: 9 | # - build the module in the source checkout (usedevelop), or 10 | # - move the source to src/ so the tests only find the installed version. 11 | usedevelop = true 12 | deps = .[test] 13 | commands = 14 | python -m pytest \ 15 | --basetemp={envtmpdir} \ 16 | --cov=spotify --cov-report=term-missing \ 17 | -v \ 18 | {posargs} 19 | 20 | [testenv:check-manifest] 21 | deps = .[lint] 22 | commands = python -m check_manifest 23 | 24 | [testenv:docs] 25 | changedir = docs 26 | deps = .[docs] 27 | commands = python -m sphinx -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 28 | 29 | [testenv:flake8] 30 | deps = .[lint] 31 | commands = python -m flake8 --show-source --statistics 32 | -------------------------------------------------------------------------------- /tests/regression/failing_link_to_playlist.py: -------------------------------------------------------------------------------- 1 | # TODO This example should work, but fails to get the URIs of the playlists. 2 | 3 | from __future__ import print_function 4 | 5 | import logging 6 | import time 7 | 8 | import spotify 9 | 10 | logging.basicConfig(level=logging.INFO) 11 | 12 | # Assuming a spotify_appkey.key in the current dir: 13 | session = spotify.Session() 14 | 15 | # Assuming a previous login with remember_me=True and a proper logout: 16 | session.relogin() 17 | 18 | while session.connection.state != spotify.ConnectionState.LOGGED_IN: 19 | session.process_events() 20 | 21 | user = session.get_user("spotify:user:p3.no").load() 22 | user.published_playlists.load() 23 | 24 | time.sleep(10) 25 | session.process_events() 26 | 27 | print("%d playlists found" % len(user.published_playlists)) 28 | 29 | for playlist in user.published_playlists: 30 | playlist.load() 31 | print("Loaded", playlist) 32 | 33 | print(user.published_playlists) 34 | 35 | session.logout() 36 | session.process_events() 37 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | ************* 2 | API reference 3 | ************* 4 | 5 | The pyspotify API follows the `libspotify 6 | `__ API closely. Thus, 7 | you can refer to the similarly named functions in the libspotify docs for 8 | further details. 9 | 10 | .. module:: spotify 11 | 12 | .. attribute:: __version__ 13 | 14 | pyspotify's version number in the :pep:`386` format. 15 | 16 | :: 17 | 18 | >>> import spotify 19 | >>> spotify.__version__ 20 | u'2.0.0' 21 | 22 | .. autofunction:: get_libspotify_api_version 23 | 24 | .. autofunction:: get_libspotify_build_id 25 | 26 | 27 | **Sections** 28 | 29 | .. toctree:: 30 | :maxdepth: 1 31 | 32 | error 33 | config 34 | session 35 | eventloop 36 | connection 37 | offline 38 | link 39 | user 40 | track 41 | album 42 | artist 43 | image 44 | search 45 | playlist 46 | toplist 47 | inbox 48 | social 49 | player 50 | audio 51 | sink 52 | internal 53 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from __future__ import unicode_literals 4 | 5 | import unittest 6 | from distutils.version import StrictVersion as SV 7 | 8 | import spotify 9 | from tests import mock 10 | 11 | 12 | class VersionTest(unittest.TestCase): 13 | def test_version_is_a_valid_pep_386_strict_version(self): 14 | SV(spotify.__version__) 15 | 16 | def test_version_is_grater_than_all_1_x_versions(self): 17 | self.assertLess(SV("1.999"), SV(spotify.__version__)) 18 | 19 | 20 | @mock.patch("spotify.version.lib", spec=spotify.lib) 21 | class LibspotifyVersionTest(unittest.TestCase): 22 | def test_libspotify_api_version(self, lib_mock): 23 | lib_mock.SPOTIFY_API_VERSION = 73 24 | 25 | result = spotify.get_libspotify_api_version() 26 | 27 | self.assertEqual(result, 73) 28 | 29 | def test_libspotify_build_id(self, lib_mock): 30 | build_id = spotify.ffi.new("char []", "12.1.51.foobaræøå".encode("utf-8")) 31 | lib_mock.sp_build_id.return_value = build_id 32 | 33 | result = spotify.get_libspotify_build_id() 34 | 35 | self.assertEqual(result, "12.1.51.foobaræøå") 36 | -------------------------------------------------------------------------------- /docs/api/playlist.rst: -------------------------------------------------------------------------------- 1 | ********* 2 | Playlists 3 | ********* 4 | 5 | .. warning:: 6 | 7 | The playlists API was broken at 2018-05-24 by a server-side change 8 | made by Spotify. The functionality was never restored. 9 | 10 | Please use the Spotify Web API to work with playlists. 11 | 12 | .. module:: spotify 13 | :noindex: 14 | 15 | .. autoclass:: Playlist 16 | 17 | .. autoclass:: PlaylistEvent 18 | 19 | .. autoclass:: PlaylistContainer 20 | 21 | .. autoclass:: PlaylistContainerEvent 22 | 23 | .. autoclass:: PlaylistFolder 24 | :no-inherited-members: 25 | 26 | .. attribute:: id 27 | 28 | An opaque ID that matches the ID of the :class:`PlaylistFolder` object 29 | at the other end of the folder. 30 | 31 | .. attribute:: name 32 | 33 | Name of the playlist folder. This is an empty string for the 34 | :attr:`~PlaylistType.END_FOLDER`. 35 | 36 | .. attribute:: type 37 | 38 | The :class:`PlaylistType` of the folder. Either 39 | :attr:`~PlaylistType.START_FOLDER` or :attr:`~PlaylistType.END_FOLDER`. 40 | 41 | .. autoclass:: PlaylistOfflineStatus 42 | :no-inherited-members: 43 | 44 | .. autoclass:: PlaylistPlaceholder 45 | 46 | .. autoclass:: PlaylistTrack 47 | 48 | .. autoclass:: PlaylistType 49 | :no-inherited-members: 50 | 51 | .. autoclass:: PlaylistUnseenTracks 52 | -------------------------------------------------------------------------------- /tests/regression/bug_122.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import logging 4 | import sys 5 | import threading 6 | import time 7 | 8 | import spotify 9 | 10 | if len(sys.argv) != 3: 11 | sys.exit("Usage: %s USERNAME PASSWORD" % sys.argv[0]) 12 | 13 | username, password = sys.argv[1], sys.argv[2] 14 | 15 | 16 | def login(session, username, password): 17 | logged_in_event = threading.Event() 18 | 19 | def logged_in_listener(session, error_type): 20 | logged_in_event.set() 21 | 22 | session.on(spotify.SessionEvent.LOGGED_IN, logged_in_listener) 23 | session.login(username, password) 24 | 25 | if not logged_in_event.wait(10): 26 | raise RuntimeError("Login timed out") 27 | 28 | while session.connection.state != spotify.ConnectionState.LOGGED_IN: 29 | time.sleep(0.1) 30 | 31 | 32 | logging.basicConfig(level=logging.DEBUG) 33 | logger = logging.getLogger(__name__) 34 | 35 | session = spotify.Session() 36 | loop = spotify.EventLoop(session) 37 | loop.start() 38 | 39 | login(session, username, password) 40 | 41 | logger.debug("Getting playlist") 42 | pl = session.get_playlist("spotify:user:durden20:playlist:1chOHrXPCFcShCwB357MFX") 43 | logger.debug("Got playlist %r %r", pl, pl._sp_playlist) 44 | logger.debug("Loading playlist %r %r", pl, pl._sp_playlist) 45 | pl.load() 46 | logger.debug("Loaded playlist %r %r", pl, pl._sp_playlist) 47 | 48 | print(pl) 49 | print(pl.tracks) 50 | -------------------------------------------------------------------------------- /spotify/audio.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import collections 4 | 5 | from spotify import utils 6 | 7 | __all__ = ["AudioBufferStats", "AudioFormat", "Bitrate", "SampleType"] 8 | 9 | 10 | class AudioBufferStats( 11 | collections.namedtuple("AudioBufferStats", ["samples", "stutter"]) 12 | ): 13 | 14 | """Stats about the application's audio buffers.""" 15 | 16 | pass 17 | 18 | 19 | @utils.make_enum("SP_BITRATE_", "BITRATE_") 20 | class Bitrate(utils.IntEnum): 21 | pass 22 | 23 | 24 | @utils.make_enum("SP_SAMPLETYPE_") 25 | class SampleType(utils.IntEnum): 26 | pass 27 | 28 | 29 | class AudioFormat(object): 30 | 31 | """A Spotify audio format object. 32 | 33 | You'll never need to create an instance of this class yourself, but you'll 34 | get :class:`AudioFormat` objects as the ``audio_format`` argument to the 35 | :attr:`~spotify.SessionCallbacks.music_delivery` callback. 36 | """ 37 | 38 | def __init__(self, sp_audioformat): 39 | self._sp_audioformat = sp_audioformat 40 | 41 | @property 42 | def sample_type(self): 43 | """The :class:`SampleType`, currently always 44 | :attr:`SampleType.INT16_NATIVE_ENDIAN`.""" 45 | return SampleType(self._sp_audioformat.sample_type) 46 | 47 | @property 48 | def sample_rate(self): 49 | """The sample rate, typically 44100 Hz.""" 50 | return self._sp_audioformat.sample_rate 51 | 52 | @property 53 | def channels(self): 54 | """The number of audio channels, typically 2.""" 55 | return self._sp_audioformat.channels 56 | 57 | def frame_size(self): 58 | """The byte size of a single frame of this format.""" 59 | if self.sample_type == SampleType.INT16_NATIVE_ENDIAN: 60 | return 2 * self.channels 61 | else: 62 | raise ValueError("Unknown sample type: %d", self.sample_type) 63 | -------------------------------------------------------------------------------- /examples/play_track.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | This is an example of playing music from Spotify using pyspotify. 5 | 6 | The example use the :class:`spotify.AlsaSink`, and will thus only work on 7 | systems with an ALSA sound subsystem, which means most Linux systems. 8 | 9 | You can either run this file directly without arguments to play a default 10 | track:: 11 | 12 | python play_track.py 13 | 14 | Or, give the script a Spotify track URI to play:: 15 | 16 | python play_track.py spotify:track:3iFjScPoAC21CT5cbAFZ7b 17 | """ 18 | 19 | from __future__ import unicode_literals 20 | 21 | import sys 22 | import threading 23 | 24 | import spotify 25 | 26 | if sys.argv[1:]: 27 | track_uri = sys.argv[1] 28 | else: 29 | track_uri = "spotify:track:6xZtSE6xaBxmRozKA0F6TA" 30 | 31 | # Assuming a spotify_appkey.key in the current dir 32 | session = spotify.Session() 33 | 34 | # Process events in the background 35 | loop = spotify.EventLoop(session) 36 | loop.start() 37 | 38 | # Connect an audio sink 39 | audio = spotify.AlsaSink(session) 40 | 41 | # Events for coordination 42 | logged_in = threading.Event() 43 | end_of_track = threading.Event() 44 | 45 | 46 | def on_connection_state_updated(session): 47 | if session.connection.state is spotify.ConnectionState.LOGGED_IN: 48 | logged_in.set() 49 | 50 | 51 | def on_end_of_track(self): 52 | end_of_track.set() 53 | 54 | 55 | # Register event listeners 56 | session.on(spotify.SessionEvent.CONNECTION_STATE_UPDATED, on_connection_state_updated) 57 | session.on(spotify.SessionEvent.END_OF_TRACK, on_end_of_track) 58 | 59 | # Assuming a previous login with remember_me=True and a proper logout 60 | session.relogin() 61 | 62 | logged_in.wait() 63 | 64 | # Play a track 65 | track = session.get_track(track_uri).load() 66 | session.player.load(track) 67 | session.player.play() 68 | 69 | # Wait for playback to complete or Ctrl+C 70 | try: 71 | while not end_of_track.wait(0.1): 72 | pass 73 | except KeyboardInterrupt: 74 | pass 75 | -------------------------------------------------------------------------------- /spotify/error.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from spotify import lib, serialized, utils 4 | 5 | __all__ = ["Error", "ErrorType", "LibError", "Timeout"] 6 | 7 | 8 | class Error(Exception): 9 | 10 | """A Spotify error. 11 | 12 | This is the superclass of all custom exceptions raised by pyspotify. 13 | """ 14 | 15 | @classmethod 16 | def maybe_raise(cls, error_type, ignores=None): 17 | """Raise an :exc:`LibError` unless the ``error_type`` is 18 | :attr:`ErrorType.OK` or in the ``ignores`` list of error types. 19 | 20 | Internal method. 21 | """ 22 | ignores = set(ignores or []) 23 | ignores.add(ErrorType.OK) 24 | if error_type not in ignores: 25 | raise LibError(error_type) 26 | 27 | 28 | @utils.make_enum("SP_ERROR_") 29 | class ErrorType(utils.IntEnum): 30 | pass 31 | 32 | 33 | class LibError(Error): 34 | 35 | """A libspotify error. 36 | 37 | Where many libspotify functions return error codes that must be checked 38 | after each and every function call, pyspotify raises the 39 | :exc:`LibError` exception instead. This helps you to not accidentally 40 | swallow and hide errors when using pyspotify. 41 | """ 42 | 43 | error_type = None 44 | """The :class:`ErrorType` of the error.""" 45 | 46 | @serialized 47 | def __init__(self, error_type): 48 | self.error_type = error_type 49 | message = utils.to_unicode(lib.sp_error_message(error_type)) 50 | super(Error, self).__init__(message) 51 | 52 | def __eq__(self, other): 53 | return self.error_type == getattr(other, "error_type", None) 54 | 55 | def __ne__(self, other): 56 | return not self.__eq__(other) 57 | 58 | 59 | for attr in dir(lib): 60 | if attr.startswith("SP_ERROR_"): 61 | name = attr.replace("SP_ERROR_", "") 62 | error_no = getattr(lib, attr) 63 | setattr(LibError, name, LibError(error_no)) 64 | 65 | 66 | class Timeout(Error): 67 | 68 | """Exception raised by an operation not completing within the given 69 | timeout.""" 70 | 71 | def __init__(self, timeout): 72 | message = "Operation did not complete in %.3fs" % timeout 73 | super(Timeout, self).__init__(message) 74 | -------------------------------------------------------------------------------- /tests/test_audio.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | 7 | 8 | class AudioBufferStatsTest(unittest.TestCase): 9 | def test_samples(self): 10 | stats = spotify.AudioBufferStats(100, 5) 11 | 12 | self.assertEqual(stats.samples, 100) 13 | 14 | def test_stutter(self): 15 | stats = spotify.AudioBufferStats(100, 5) 16 | 17 | self.assertEqual(stats.stutter, 5) 18 | 19 | 20 | class AudioFormatTest(unittest.TestCase): 21 | def setUp(self): 22 | self._sp_audioformat = spotify.ffi.new("sp_audioformat *") 23 | self._sp_audioformat.sample_type = spotify.SampleType.INT16_NATIVE_ENDIAN 24 | self._sp_audioformat.sample_rate = 44100 25 | self._sp_audioformat.channels = 2 26 | self.audio_format = spotify.AudioFormat(self._sp_audioformat) 27 | 28 | def test_sample_type(self): 29 | self.assertIs( 30 | self.audio_format.sample_type, 31 | spotify.SampleType.INT16_NATIVE_ENDIAN, 32 | ) 33 | 34 | def test_sample_rate(self): 35 | self.assertEqual(self.audio_format.sample_rate, 44100) 36 | 37 | def test_channels(self): 38 | self.assertEqual(self.audio_format.channels, 2) 39 | 40 | def test_frame_size(self): 41 | # INT16 means 16 bits aka 2 bytes per channel 42 | self._sp_audioformat.sample_type = spotify.SampleType.INT16_NATIVE_ENDIAN 43 | 44 | self._sp_audioformat.channels = 1 45 | self.assertEqual(self.audio_format.frame_size(), 2) 46 | 47 | self._sp_audioformat.channels = 2 48 | self.assertEqual(self.audio_format.frame_size(), 4) 49 | 50 | def test_frame_size_fails_if_sample_type_is_unknown(self): 51 | self._sp_audioformat.sample_type = 666 52 | 53 | with self.assertRaises(ValueError): 54 | self.audio_format.frame_size() 55 | 56 | 57 | class BitrateTest(unittest.TestCase): 58 | def test_has_contants(self): 59 | self.assertEqual(spotify.Bitrate.BITRATE_96k, 2) 60 | self.assertEqual(spotify.Bitrate.BITRATE_160k, 0) 61 | self.assertEqual(spotify.Bitrate.BITRATE_320k, 1) 62 | 63 | 64 | class SampleTypeTest(unittest.TestCase): 65 | def test_has_constants(self): 66 | self.assertEqual(spotify.SampleType.INT16_NATIVE_ENDIAN, 0) 67 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pyspotify 3 | version = 2.1.4 4 | url = https://pyspotify.readthedocs.io/ 5 | author = Stein Magnus Jodal 6 | author_email = stein.magnus@jodal.no 7 | license = Apache License, Version 2.0 8 | license_file = LICENSE 9 | description = Python wrapper for libspotify 10 | long_description = file: README.rst 11 | classifiers = 12 | Development Status :: 5 - Production/Stable 13 | Intended Audience :: Developers 14 | License :: OSI Approved :: Apache Software License 15 | Programming Language :: Python :: 2 16 | Programming Language :: Python :: 2.7 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3.5 19 | Programming Language :: Python :: 3.6 20 | Programming Language :: Python :: 3.7 21 | Programming Language :: Python :: 3.8 22 | Programming Language :: Python :: 3.9 23 | Programming Language :: Python :: 3.10 24 | Programming Language :: Python :: Implementation :: CPython 25 | Programming Language :: Python :: Implementation :: PyPy 26 | Topic :: Software Development :: Libraries 27 | 28 | 29 | [options] 30 | zip_safe = False 31 | include_package_data = True 32 | packages = find: 33 | setup_requires = 34 | cffi >= 1.0.0 35 | install_requires = 36 | cffi >= 1.0.0 37 | setuptools 38 | 39 | 40 | [options.extras_require] 41 | docs = 42 | mock 43 | sphinx == 4.5.0 44 | lint = 45 | black 46 | check-manifest 47 | flake8 48 | flake8-black 49 | flake8-bugbear 50 | flake8-isort 51 | isort 52 | release = 53 | delocate 54 | twine 55 | wheel 56 | test = 57 | mock 58 | pytest 59 | pytest-cov 60 | dev = 61 | invoke 62 | %(docs)s 63 | %(lint)s 64 | %(release)s 65 | %(test)s 66 | 67 | 68 | [options.packages.find] 69 | exclude = 70 | tests 71 | tests.* 72 | 73 | 74 | [flake8] 75 | application-import-names = spotify, tests 76 | max-line-length = 80 77 | exclude = .eggs, .git, .tox, .venv, build 78 | select = 79 | # Regular flake8 rules 80 | C, E, F, W 81 | # flake8-bugbear rules 82 | B 83 | # B950: line too long (soft speed limit) 84 | B950 85 | # flake8-black rules 86 | BLK 87 | # pep8-naming rules 88 | N 89 | ignore = 90 | # E203: whitespace before ':' (not PEP8 compliant) 91 | E203 92 | # E501: line too long (replaced by B950) 93 | E501 94 | # W503: line break before binary operator (not PEP8 compliant) 95 | W503 96 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, unicode_literals 2 | 3 | import shutil 4 | 5 | from invoke import task 6 | 7 | 8 | @task 9 | def docs(ctx, warn=False): 10 | ctx.run("make -C docs/ html", warn=warn) 11 | 12 | 13 | @task 14 | def test(ctx, coverage=False, warn=False): 15 | cmd = "py.test" 16 | if coverage: 17 | cmd += " --cov=spotify --cov-report=term-missing" 18 | ctx.run(cmd, pty=True, warn=warn) 19 | 20 | 21 | @task 22 | def preprocess_header(ctx): 23 | ctx.run( 24 | 'cpp -nostdinc spotify/api.h | egrep -v "(^#)|(^$)" ' 25 | "> spotify/api.processed.h || true" 26 | ) 27 | 28 | 29 | @task 30 | def update_authors(ctx): 31 | # Keep authors in the order of appearance and use awk to filter out dupes 32 | ctx.run("git log --format='- %aN <%aE>' --reverse | awk '!x[$0]++' > AUTHORS") 33 | 34 | 35 | @task 36 | def update_sp_constants(ctx): 37 | import spotify 38 | 39 | constants = [ 40 | "%s,%s\n" % (attr, getattr(spotify.lib, attr)) 41 | for attr in dir(spotify.lib) 42 | if attr.startswith("SP_") 43 | ] 44 | with open("docs/sp-constants.csv", "w+") as fh: 45 | fh.writelines(constants) 46 | 47 | 48 | @task 49 | def mac_wheels(ctx): 50 | """ 51 | Create wheel packages compatible with: 52 | 53 | - macOS 10.6+ 54 | - 32-bit and 64-bit 55 | - Apple-Python, Python.org-Python, Homebrew-Python 56 | 57 | Based upon https://github.com/MacPython/wiki/wiki/Spinning-wheels 58 | """ 59 | 60 | versions = [ 61 | # Python.org Python 2.7 for macOS 10.6 and later 62 | "/Library/Frameworks/Python.framework/Versions/2.7/bin/python", 63 | # Python.org Python 3.7 for macOS 10.6 and later 64 | "/Library/Frameworks/Python.framework/Versions/3.7/bin/python3", 65 | # Homebrew Python 2.7 66 | "/usr/local/bin/python2.7", 67 | # Homebrew Python 3.7 68 | "/usr/local/bin/python3.7", 69 | ] 70 | 71 | # Build wheels for all Python versions 72 | for executable in versions: 73 | shutil.rmtree("./build", ignore_errors=True) 74 | ctx.run("%s -m pip install wheel" % executable) 75 | ctx.run("%s setup.py bdist_wheel" % executable) 76 | 77 | # Bundle libspotify into the wheels 78 | shutil.rmtree("./fixed_dist", ignore_errors=True) 79 | ctx.run("delocate-wheel -w ./fixed_dist ./dist/*.whl") 80 | 81 | print("To upload wheels, run: twine upload fixed_dist/*") 82 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import gc 4 | import platform 5 | import unittest 6 | import weakref 7 | 8 | try: 9 | # Python 3.5+ 10 | from unittest import mock 11 | except ImportError: 12 | # From PyPI 13 | import mock 14 | 15 | import spotify 16 | 17 | 18 | def buffer_writer(string): 19 | """Creates a function that takes a ``buffer`` and ``buffer_size`` as the 20 | two last arguments and writes the given ``string`` to ``buffer``. 21 | """ 22 | 23 | def func(*args): 24 | assert len(args) >= 2 25 | buffer_, buffer_size = args[-2:] 26 | 27 | # -1 to keep a char free for \0 terminating the string 28 | length = min(len(string), buffer_size - 1) 29 | 30 | # Due to Python 3 treating bytes as an array of ints, we have to 31 | # encode and copy chars one by one. 32 | for i in range(length): 33 | buffer_[i] = string[i].encode("utf-8") 34 | 35 | return len(string) 36 | 37 | return func 38 | 39 | 40 | def create_real_session(lib_mock): 41 | """Create a real :class:`spotify.Session` using ``lib_mock``.""" 42 | lib_mock.sp_session_create.return_value = spotify.ErrorType.OK 43 | config = spotify.Config() 44 | config.application_key = b"\x01" * 321 45 | return spotify.Session(config=config) 46 | 47 | 48 | def create_session_mock(): 49 | """Create a :class:`spotify.Session` mock for testing.""" 50 | session = mock.Mock() 51 | session._cache = weakref.WeakValueDictionary() 52 | session._emitters = [] 53 | session._callback_handles = set() 54 | return session 55 | 56 | 57 | def gc_collect(): 58 | """Run enough GC collections to make object finalizers run.""" 59 | 60 | # XXX Tests of GC and cleanup behavior are generally flaky and icky, 61 | # especially when you target all of Python 2.7, 3.5+ and PyPy. Their result 62 | # quickly depends on other tests, the arguments to the test runner and the 63 | # computer running the tests. This skips them all for now. 64 | raise unittest.SkipTest 65 | 66 | if platform.python_implementation() == "PyPy": 67 | # Since PyPy use garbage collection instead of reference counting 68 | # objects are not finalized before the next major GC collection. 69 | # Currently, the best way we have to ensure a major GC collection has 70 | # run is to call gc.collect() a number of times. 71 | [gc.collect() for _ in range(10)] 72 | else: 73 | gc.collect() 74 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | main: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | include: 11 | - name: "Test: Python 2.7" 12 | python: "2.7" 13 | tox: py27 14 | - name: "Test: Python 3.5" 15 | python: "3.5" 16 | tox: py35 17 | - name: "Test: Python 3.6" 18 | python: "3.6" 19 | tox: py36 20 | - name: "Test: Python 3.7" 21 | python: "3.7" 22 | tox: py37 23 | - name: "Test: Python 3.8" 24 | python: "3.8" 25 | tox: py38 26 | - name: "Test: Python 3.9" 27 | python: "3.9" 28 | tox: py39 29 | - name: "Test: Python 3.10" 30 | python: "3.10" 31 | tox: py310 32 | coverage: true 33 | - name: "Test: PyPy 2.7" 34 | python: "pypy-2.7" 35 | tox: pypy 36 | - name: "Test: PyPy 3.7" 37 | python: "pypy-3.7" 38 | tox: pypy3 39 | - name: "Lint: check-manifest" 40 | python: "3.10" 41 | tox: check-manifest 42 | - name: "Lint: flake8" 43 | python: "3.10" 44 | tox: flake8 45 | - name: "Docs" 46 | python: "3.10" 47 | tox: docs 48 | 49 | name: ${{ matrix.name }} 50 | runs-on: ubuntu-20.04 51 | 52 | steps: 53 | - uses: actions/checkout@v2 54 | - uses: actions/setup-python@v2 55 | with: 56 | python-version: ${{ matrix.python }} 57 | - name: Install libspotify 58 | run: | 59 | sudo mkdir -p /usr/local/share/keyrings 60 | sudo wget -q -O /usr/local/share/keyrings/mopidy-archive-keyring.gpg https://apt.mopidy.com/mopidy.gpg 61 | sudo wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/buster.list 62 | sudo apt-get update 63 | sudo apt-get install -y libspotify-dev 64 | - name: Cache pip 65 | uses: actions/cache@v2 66 | with: 67 | path: ~/.cache/pip 68 | key: ${{ runner.os }}-${{ matrix.python }}-${{ matrix.tox }}-pip-${{ hashFiles('setup.cfg', 'tox.ini') }} }} 69 | restore-keys: | 70 | ${{ runner.os }}-${{ matrix.python }}-${{ matrix.tox }}-pip- 71 | - run: python -m pip install tox 72 | - run: python -m tox -e ${{ matrix.tox }} 73 | if: ${{ ! matrix.coverage }} 74 | - run: python -m tox -e ${{ matrix.tox }} -- --cov-report=xml 75 | if: ${{ matrix.coverage }} 76 | - uses: codecov/codecov-action@v1 77 | if: ${{ matrix.coverage }} 78 | -------------------------------------------------------------------------------- /spotify/playlist_unseen_tracks.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | import pprint 5 | 6 | import spotify 7 | from spotify import compat, ffi, lib, serialized 8 | 9 | __all__ = ["PlaylistUnseenTracks"] 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class PlaylistUnseenTracks(compat.Sequence): 15 | 16 | """A list of unseen tracks in a playlist. 17 | 18 | The list may contain items that are :class:`None`. 19 | 20 | Returned by :meth:`PlaylistContainer.get_unseen_tracks`. 21 | """ 22 | 23 | _BATCH_SIZE = 100 24 | 25 | @serialized 26 | def __init__(self, session, sp_playlistcontainer, sp_playlist): 27 | self._session = session 28 | 29 | lib.sp_playlistcontainer_add_ref(sp_playlistcontainer) 30 | self._sp_playlistcontainer = ffi.gc( 31 | sp_playlistcontainer, lib.sp_playlistcontainer_release 32 | ) 33 | 34 | lib.sp_playlist_add_ref(sp_playlist) 35 | self._sp_playlist = ffi.gc(sp_playlist, lib.sp_playlist_release) 36 | 37 | self._num_tracks = 0 38 | self._sp_tracks_len = 0 39 | self._get_more_tracks() 40 | 41 | @serialized 42 | def _get_more_tracks(self): 43 | self._sp_tracks_len = min( 44 | self._num_tracks, self._sp_tracks_len + self._BATCH_SIZE 45 | ) 46 | self._sp_tracks = ffi.new("sp_track *[]", self._sp_tracks_len) 47 | self._num_tracks = lib.sp_playlistcontainer_get_unseen_tracks( 48 | self._sp_playlistcontainer, 49 | self._sp_playlist, 50 | self._sp_tracks, 51 | self._sp_tracks_len, 52 | ) 53 | 54 | if self._num_tracks < 0: 55 | raise spotify.Error("Failed to get unseen tracks for playlist") 56 | 57 | def __len__(self): 58 | return self._num_tracks 59 | 60 | def __getitem__(self, key): 61 | if isinstance(key, slice): 62 | return list(self).__getitem__(key) 63 | if not isinstance(key, int): 64 | raise TypeError( 65 | "list indices must be int or slice, not %s" % key.__class__.__name__ 66 | ) 67 | if key < 0: 68 | key += self.__len__() 69 | if not 0 <= key < self.__len__(): 70 | raise IndexError("list index out of range") 71 | while key >= self._sp_tracks_len: 72 | self._get_more_tracks() 73 | sp_track = self._sp_tracks[key] 74 | if sp_track == ffi.NULL: 75 | return None 76 | return spotify.Track(self._session, sp_track=sp_track, add_ref=True) 77 | 78 | def __repr__(self): 79 | return "PlaylistUnseenTracks(%s)" % pprint.pformat(list(self)) 80 | -------------------------------------------------------------------------------- /tests/regression/bug_119.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import logging 4 | import sys 5 | import threading 6 | import time 7 | 8 | import spotify 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | ALBUMS = [ 14 | "spotify:album:02Zb13fM8k04tRwTfMUhe9", # OK 15 | "spotify:album:3ph1ceuYuayuzoIJzPQji2", # Fails 16 | "spotify:album:4IBQvwIbtDluogvDe2qpaB", # Fails 17 | "spotify:album:5VppVyy751PTQWrfJbrJ4H", # Fails 18 | "spotify:album:2cRMVS71c49Pf5SnIlJX3U", # OK 19 | "spotify:album:6mulYcpWRDAiv7KIouWvyP", # OK 20 | "spotify:album:02jqf49ws9bcTvXLPGtjbT", # Fails 21 | "spotify:album:17orrZznh0gmxYtpNP47nK", # Fails 22 | "spotify:album:5lnQLEUiVDkLbFJHXHQu9m", # Fails 23 | "spotify:album:3ph1ceuYuayuzoIJzPQji2", # Fails 24 | ] 25 | 26 | 27 | def init(): 28 | session = spotify.Session() 29 | loop = spotify.EventLoop(session) 30 | loop.start() 31 | return session 32 | 33 | 34 | def login(session, username, password): 35 | logged_in_event = threading.Event() 36 | 37 | def logged_in_listener(session, error_type): 38 | logged_in_event.set() 39 | 40 | session.on(spotify.SessionEvent.LOGGED_IN, logged_in_listener) 41 | session.login(username, password) 42 | 43 | if not logged_in_event.wait(10): 44 | raise RuntimeError("Login timed out") 45 | 46 | while session.connection.state != spotify.ConnectionState.LOGGED_IN: 47 | time.sleep(0.1) 48 | 49 | 50 | def logout(session): 51 | logged_out_event = threading.Event() 52 | 53 | def logged_out_listener(session): 54 | logged_out_event.set() 55 | 56 | session.on(spotify.SessionEvent.LOGGED_OUT, logged_out_listener) 57 | session.logout() 58 | 59 | if not logged_out_event.wait(10): 60 | raise RuntimeError("Logout timed out") 61 | 62 | 63 | def get_albums(session): 64 | logger.info("Getting albums") 65 | for uri in ALBUMS: 66 | logger.info("Loading %s...", uri) 67 | try: 68 | album = session.get_album(uri) 69 | # album.browse() # Add this line, and everything works 70 | logger.info(album.load(3).name) 71 | except spotify.Timeout: 72 | logger.warning("Timeout") 73 | 74 | 75 | if __name__ == "__main__": 76 | if len(sys.argv) != 3: 77 | sys.exit("Usage: %s USERNAME PASSWORD" % sys.argv[0]) 78 | 79 | logging.basicConfig(level=logging.INFO) 80 | 81 | username, password = sys.argv[1], sys.argv[2] 82 | session = init() 83 | login(session, username, password) 84 | 85 | try: 86 | get_albums(session) 87 | logout(session) 88 | except KeyboardInterrupt: 89 | logout(session) 90 | -------------------------------------------------------------------------------- /spotify/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import spotify 4 | from spotify import ffi, lib, serialized, utils 5 | 6 | __all__ = ["User"] 7 | 8 | 9 | class User(object): 10 | 11 | """A Spotify user. 12 | 13 | You can get users from the session, or you can create a :class:`User` 14 | yourself from a Spotify URI:: 15 | 16 | >>> session = spotify.Session() 17 | # ... 18 | >>> user = session.get_user('spotify:user:jodal') 19 | >>> user.load().display_name 20 | u'jodal' 21 | """ 22 | 23 | def __init__(self, session, uri=None, sp_user=None, add_ref=True): 24 | assert uri or sp_user, "uri or sp_user is required" 25 | 26 | self._session = session 27 | 28 | if uri is not None: 29 | user = spotify.Link(self._session, uri=uri).as_user() 30 | if user is None: 31 | raise ValueError("Failed to get user from Spotify URI: %r" % uri) 32 | sp_user = user._sp_user 33 | add_ref = True 34 | 35 | if add_ref: 36 | lib.sp_user_add_ref(sp_user) 37 | self._sp_user = ffi.gc(sp_user, lib.sp_user_release) 38 | 39 | def __repr__(self): 40 | return "User(%r)" % self.link.uri 41 | 42 | @property 43 | @serialized 44 | def canonical_name(self): 45 | """The user's canonical username.""" 46 | return utils.to_unicode(lib.sp_user_canonical_name(self._sp_user)) 47 | 48 | @property 49 | @serialized 50 | def display_name(self): 51 | """The user's displayable username.""" 52 | return utils.to_unicode(lib.sp_user_display_name(self._sp_user)) 53 | 54 | @property 55 | def is_loaded(self): 56 | """Whether the user's data is loaded yet.""" 57 | return bool(lib.sp_user_is_loaded(self._sp_user)) 58 | 59 | def load(self, timeout=None): 60 | """Block until the user's data is loaded. 61 | 62 | After ``timeout`` seconds with no results :exc:`~spotify.Timeout` is 63 | raised. If ``timeout`` is :class:`None` the default timeout is used. 64 | 65 | The method returns ``self`` to allow for chaining of calls. 66 | """ 67 | return utils.load(self._session, self, timeout=timeout) 68 | 69 | @property 70 | def link(self): 71 | """A :class:`Link` to the user.""" 72 | return spotify.Link( 73 | self._session, 74 | sp_link=lib.sp_link_create_from_user(self._sp_user), 75 | add_ref=False, 76 | ) 77 | 78 | @property 79 | def starred(self): 80 | """The :class:`Playlist` of tracks starred by the user.""" 81 | return self._session.get_starred(self.canonical_name) 82 | 83 | @property 84 | def published_playlists(self): 85 | """The :class:`PlaylistContainer` of playlists published by the 86 | user.""" 87 | return self._session.get_published_playlists(self.canonical_name) 88 | -------------------------------------------------------------------------------- /spotify/playlist_track.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | 5 | import spotify 6 | from spotify import ffi, lib, serialized, utils 7 | 8 | __all__ = ["PlaylistTrack"] 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class PlaylistTrack(object): 14 | 15 | """A playlist track with metadata specific to the playlist. 16 | 17 | Use :attr:`~spotify.Playlist.tracks_with_metadata` to get a list of 18 | :class:`PlaylistTrack`. 19 | """ 20 | 21 | def __init__(self, session, sp_playlist, index): 22 | self._session = session 23 | 24 | lib.sp_playlist_add_ref(sp_playlist) 25 | self._sp_playlist = ffi.gc(sp_playlist, lib.sp_playlist_release) 26 | 27 | self._index = index 28 | 29 | def __repr__(self): 30 | return "PlaylistTrack(uri=%r, creator=%r, create_time=%d)" % ( 31 | self.track.link.uri, 32 | self.creator, 33 | self.create_time, 34 | ) 35 | 36 | def __eq__(self, other): 37 | if isinstance(other, self.__class__): 38 | return ( 39 | self._sp_playlist == other._sp_playlist and self._index == other._index 40 | ) 41 | else: 42 | return False 43 | 44 | def __ne__(self, other): 45 | return not self.__eq__(other) 46 | 47 | def __hash__(self): 48 | return hash((self._sp_playlist, self._index)) 49 | 50 | @property 51 | @serialized 52 | def track(self): 53 | """The :class:`~spotify.Track`.""" 54 | return spotify.Track( 55 | self._session, 56 | sp_track=lib.sp_playlist_track(self._sp_playlist, self._index), 57 | add_ref=True, 58 | ) 59 | 60 | @property 61 | def create_time(self): 62 | """When the track was added to the playlist, as seconds since Unix 63 | epoch. 64 | """ 65 | return lib.sp_playlist_track_create_time(self._sp_playlist, self._index) 66 | 67 | @property 68 | @serialized 69 | def creator(self): 70 | """The :class:`~spotify.User` that added the track to the playlist.""" 71 | return spotify.User( 72 | self._session, 73 | sp_user=lib.sp_playlist_track_creator(self._sp_playlist, self._index), 74 | add_ref=True, 75 | ) 76 | 77 | def is_seen(self): 78 | return bool(lib.sp_playlist_track_seen(self._sp_playlist, self._index)) 79 | 80 | def set_seen(self, value): 81 | spotify.Error.maybe_raise( 82 | lib.sp_playlist_track_set_seen(self._sp_playlist, self._index, int(value)) 83 | ) 84 | 85 | seen = property(is_seen, set_seen) 86 | """Whether the track is marked as seen or not.""" 87 | 88 | @property 89 | @serialized 90 | def message(self): 91 | """A message attached to the track. Typically used in the inbox.""" 92 | message = lib.sp_playlist_track_message(self._sp_playlist, self._index) 93 | return utils.to_unicode_or_none(message) 94 | -------------------------------------------------------------------------------- /tests/test_eventloop.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import time 4 | import unittest 5 | 6 | try: 7 | # Python 3 8 | import queue 9 | except ImportError: 10 | # Python 2 11 | import Queue as queue 12 | 13 | import spotify 14 | from tests import mock 15 | 16 | 17 | class EventLoopTest(unittest.TestCase): 18 | def setUp(self): 19 | self.timeout = 0.1 20 | self.session = mock.Mock(spec=spotify.Session) 21 | self.session.process_events.return_value = int(self.timeout * 1000) 22 | self.loop = spotify.EventLoop(self.session) 23 | 24 | def tearDown(self): 25 | self.loop.stop() 26 | while self.loop.is_alive(): 27 | self.loop.join(1) 28 | 29 | def test_is_a_daemon_thread(self): 30 | self.assertTrue(self.loop.daemon) 31 | 32 | def test_has_a_descriptive_thread_name(self): 33 | self.assertEqual(self.loop.name, "SpotifyEventLoop") 34 | 35 | def test_can_be_started_and_stopped_and_joined(self): 36 | self.assertFalse(self.loop.is_alive()) 37 | 38 | self.loop.start() 39 | self.assertTrue(self.loop.is_alive()) 40 | 41 | self.loop.stop() 42 | self.loop.join(1) 43 | self.assertFalse(self.loop.is_alive()) 44 | 45 | def test_start_registers_notify_main_thread_listener(self): 46 | self.loop.start() 47 | 48 | self.session.on.assert_called_once_with( 49 | spotify.SessionEvent.NOTIFY_MAIN_THREAD, 50 | self.loop._on_notify_main_thread, 51 | ) 52 | 53 | def test_stop_unregisters_notify_main_thread_listener(self): 54 | self.loop.stop() 55 | 56 | self.session.off.assert_called_once_with( 57 | spotify.SessionEvent.NOTIFY_MAIN_THREAD, 58 | self.loop._on_notify_main_thread, 59 | ) 60 | 61 | def test_run_immediately_process_events(self): 62 | self.loop._runnable = False # Short circuit run() 63 | self.loop.run() 64 | 65 | self.session.process_events.assert_called_once_with() 66 | 67 | def test_processes_events_if_no_notify_main_thread_before_timeout(self): 68 | self.loop._queue = mock.Mock(spec=queue.Queue) 69 | self.loop._queue.get = lambda timeout: time.sleep(timeout) 70 | self.loop.start() 71 | 72 | time.sleep(0.25) 73 | self.loop.stop() 74 | self.assertGreaterEqual(self.session.process_events.call_count, 3) 75 | 76 | def test_puts_on_queue_on_notify_main_thread(self): 77 | self.loop._queue = mock.Mock(spec=queue.Queue) 78 | 79 | self.loop._on_notify_main_thread(self.session) 80 | 81 | self.loop._queue.put_nowait.assert_called_once_with(mock.ANY) 82 | 83 | def test_on_notify_main_thread_fails_nicely_if_queue_is_full(self): 84 | self.loop._queue = mock.Mock(spec=queue.Queue) 85 | self.loop._queue.put_nowait.side_effect = queue.Full 86 | 87 | self.loop._on_notify_main_thread(self.session) 88 | 89 | self.loop._queue.put_nowait.assert_called_once_with(mock.ANY) 90 | -------------------------------------------------------------------------------- /spotify/player.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import spotify 4 | from spotify import lib 5 | 6 | __all__ = ["PlayerState"] 7 | 8 | 9 | class PlayerState(object): 10 | UNLOADED = "unloaded" 11 | LOADED = "loaded" 12 | PLAYING = "playing" 13 | PAUSED = "paused" 14 | 15 | 16 | class Player(object): 17 | 18 | """Playback controller. 19 | 20 | You'll never need to create an instance of this class yourself. You'll find 21 | it ready to use as the :attr:`~Session.player` attribute on the 22 | :class:`Session` instance. 23 | """ 24 | 25 | state = PlayerState.UNLOADED 26 | """The player state. 27 | 28 | - The state is initially :attr:`PlayerState.UNLOADED`. 29 | - When a track is loaded, the state changes to :attr:`PlayerState.LOADED`. 30 | - When playback is started the state changes to 31 | :attr:`PlayerState.PLAYING`. 32 | - When playback is paused the state changes to :attr:`PlayerState.PAUSED`. 33 | - When the track is unloaded the state changes to 34 | :attr:`PlayerState.UNLOADED` again. 35 | """ 36 | 37 | def __init__(self, session): 38 | self._session = session 39 | 40 | def load(self, track): 41 | """Load :class:`Track` for playback.""" 42 | spotify.Error.maybe_raise( 43 | lib.sp_session_player_load(self._session._sp_session, track._sp_track) 44 | ) 45 | self.state = PlayerState.LOADED 46 | 47 | def seek(self, offset): 48 | """Seek to the offset in ms in the currently loaded track.""" 49 | spotify.Error.maybe_raise( 50 | lib.sp_session_player_seek(self._session._sp_session, offset) 51 | ) 52 | 53 | def play(self, play=True): 54 | """Play the currently loaded track. 55 | 56 | This will cause audio data to be passed to the 57 | :attr:`~SessionCallbacks.music_delivery` callback. 58 | 59 | If ``play`` is set to :class:`False`, playback will be paused. 60 | """ 61 | spotify.Error.maybe_raise( 62 | lib.sp_session_player_play(self._session._sp_session, play) 63 | ) 64 | if play: 65 | self.state = PlayerState.PLAYING 66 | else: 67 | self.state = PlayerState.PAUSED 68 | 69 | def pause(self): 70 | """Pause the currently loaded track. 71 | 72 | This is the same as calling :meth:`play` with :class:`False`. 73 | """ 74 | self.play(False) 75 | 76 | def unload(self): 77 | """Stops the currently playing track.""" 78 | spotify.Error.maybe_raise( 79 | lib.sp_session_player_unload(self._session._sp_session) 80 | ) 81 | self.state = PlayerState.UNLOADED 82 | 83 | def prefetch(self, track): 84 | """Prefetch a :class:`Track` for playback. 85 | 86 | This can be used to make libspotify download and cache a track before 87 | playing it. 88 | """ 89 | spotify.Error.maybe_raise( 90 | lib.sp_session_player_prefetch(self._session._sp_session, track._sp_track) 91 | ) 92 | -------------------------------------------------------------------------------- /docs/api/internal.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Internal API 3 | ************ 4 | 5 | .. warning:: 6 | 7 | This page documents pyspotify's internal APIs. Its intended audience is 8 | developers working on pyspotify itself. You should not use anything you 9 | find on this page in your own applications. 10 | 11 | 12 | libspotify CFFI interface 13 | ========================= 14 | 15 | The `CFFI `__ wrapper for the full libspotify API 16 | is available as :attr:`spotify.ffi` and :attr:`spotify.lib`. 17 | 18 | .. attribute:: spotify.ffi 19 | 20 | :class:`cffi.FFI` instance which knows about libspotify types. 21 | 22 | :: 23 | 24 | >>> import spotify 25 | >>> spotify.ffi.new('sp_audioformat *') 26 | 27 | 28 | .. attribute:: spotify.lib 29 | 30 | Dynamic wrapper around the full libspotify C API. 31 | 32 | :: 33 | 34 | >>> import spotify 35 | >>> msg = spotify.lib.sp_error_message(spotify.lib.SP_ERROR_OK) 36 | >>> msg 37 | 38 | >>> spotify.ffi.string(msg) 39 | 'No error' 40 | 41 | :attr:`spotify.lib` will always reflect the contents of the 42 | ``spotify/api.processed.h`` file in the pyspotify distribution. To update 43 | the API: 44 | 45 | #. Update the file ``spotify/api.h`` with the latest header file from 46 | libspotify. 47 | 48 | #. Run the `Invoke `_ task ``preprocess_header`` 49 | defined in ``tasks.py`` by running:: 50 | 51 | invoke preprocess_header 52 | 53 | The task will update the ``spotify/api.processed.h`` file. 54 | 55 | #. Commit both header files so that they are distributed with pyspotify. 56 | 57 | 58 | Thread safety utils 59 | =================== 60 | 61 | .. autofunction:: spotify.serialized 62 | 63 | 64 | Event emitter utils 65 | =================== 66 | 67 | .. autoclass:: spotify.utils.EventEmitter 68 | 69 | 70 | Enumeration utils 71 | ================= 72 | 73 | .. autoclass:: spotify.utils.IntEnum 74 | :no-inherited-members: 75 | 76 | .. autofunction:: spotify.utils.make_enum 77 | 78 | 79 | Object loading utils 80 | ==================== 81 | 82 | .. autofunction:: spotify.utils.load 83 | 84 | 85 | Sequence utils 86 | ============== 87 | 88 | .. autoclass:: spotify.utils.Sequence 89 | :no-inherited-members: 90 | 91 | 92 | String conversion utils 93 | ======================= 94 | 95 | .. autofunction:: spotify.utils.get_with_fixed_buffer 96 | 97 | .. autofunction:: spotify.utils.get_with_growing_buffer 98 | 99 | .. autofunction:: spotify.utils.to_bytes 100 | 101 | .. autofunction:: spotify.utils.to_bytes_or_none 102 | 103 | .. autofunction:: spotify.utils.to_unicode 104 | 105 | .. autofunction:: spotify.utils.to_unicode_or_none 106 | 107 | .. autofunction:: spotify.utils.to_char 108 | 109 | .. autofunction:: spotify.utils.to_char_or_null 110 | 111 | 112 | Country code utils 113 | ================== 114 | 115 | .. autofunction:: spotify.utils.to_country 116 | 117 | .. autofunction:: spotify.utils.to_country_code 118 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ********* 2 | pyspotify 3 | ********* 4 | 5 | WARNING: This library no longer works 6 | ===================================== 7 | 8 | pyspotify is a Python wrapper around the libspotify C library, and thus depends 9 | on libspotify for everything it does. 10 | 11 | In May 2015, libspotify was deprecated by Spotify and active maintenance 12 | stopped. At this point, libspotify had been the main way to integrate with 13 | Spotify for six years, and was part of numerous open source projects and 14 | commercial applications, including many receivers and even cars. It remained 15 | the only API for playback outside Android and iOS. 16 | 17 | In February 2016, server side changes to the Spotify API caused the search 18 | functionality to stop working, without Spotify ever acknowledging it. Users of 19 | pyspotify could work around this by using the Spotify web API for searches and 20 | pyspotify for playback. 21 | 22 | In April 2022, `Spotify announced 23 | `_ 24 | that they would sunset the libspotify API one month later. 25 | 26 | In May 2022, new libspotify connections to Spotify started failing. With 27 | libspotify dead, pyspotify was dead too. 28 | 29 | After two years in development from May 2013 to May 2015, and seven years of 30 | loyal service this project has reached its end. 31 | 32 | **There will be no further updates to pyspotify.** 33 | 34 | Hopefully, the pyspotify source code can still serve as a complete example of 35 | how to successfully wrap a large C library in Python using CFFI. 36 | 37 | 38 | Introduction 39 | ============ 40 | 41 | pyspotify provides a Python interface to 42 | `Spotify's `__ online music streaming service. 43 | 44 | With pyspotify you can access music metadata, search in Spotify's library of 45 | 20+ million tracks, manage your Spotify playlists, and play music from 46 | Spotify. All from your own Python applications. 47 | 48 | pyspotify uses `CFFI `_ to make a pure Python 49 | wrapper around the official libspotify library. It works on CPython 2.7 and 50 | 3.5+, as well as PyPy 2.7 and 3.5+. It is known to work on Linux and 51 | macOS. Windows support should be possible, but is awaiting a contributor with 52 | the interest and knowledge to maintain it. 53 | 54 | 55 | Project resources 56 | ================= 57 | 58 | - `Documentation `_ 59 | - `Source code `_ 60 | - `Issue tracker `_ 61 | 62 | .. image:: https://img.shields.io/pypi/v/pyspotify 63 | :target: https://pypi.org/project/pyspotify/ 64 | :alt: Latest PyPI version 65 | 66 | .. image:: https://img.shields.io/github/workflow/status/jodal/pyspotify/CI 67 | :target: https://github.com/jodal/pyspotify/actions?workflow=CI 68 | :alt: CI build status 69 | 70 | .. image:: https://img.shields.io/readthedocs/pyspotify.svg 71 | :target: https://pyspotify.readthedocs.io/ 72 | :alt: Read the Docs build status 73 | 74 | .. image:: https://img.shields.io/codecov/c/gh/jodal/pyspotify 75 | :target: https://codecov.io/gh/jodal/pyspotify 76 | :alt: Test coverage 77 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Contributing 3 | ************ 4 | 5 | Contributions to pyspotify are welcome! Here are some tips to get you started 6 | hacking on pyspotify and contributing back your patches. 7 | 8 | 9 | Development setup 10 | ================= 11 | 12 | 1. Make sure you have the following Python versions installed: 13 | 14 | - CPython 2.7 15 | - CPython 3.5 16 | - CPython 3.6 17 | - CPython 3.7 18 | - PyPy2.7 6.0+ 19 | - PyPy3.5 6.0+ 20 | 21 | If you're on Ubuntu, the `Dead Snakes PPA 22 | `_ has packages of both 23 | old and new Python versions. 24 | 25 | 2. Install the following with development headers: Python, libffi, and 26 | libspotify. 27 | 28 | On Debian/Ubuntu, make sure you have `apt.mopidy.com 29 | `_ in your APT sources to get the libspotify 30 | package, then run:: 31 | 32 | sudo apt-get install python-all-dev python3-all-dev libffi-dev libspotify-dev 33 | 34 | 3. Create and activate a virtualenv:: 35 | 36 | virtualenv ve 37 | source ve/bin/activate 38 | 39 | 4. Install development dependencies:: 40 | 41 | pip install -e ".[dev] 42 | 43 | 5. Run tests. 44 | 45 | For a quick test suite run, using the virtualenv's Python version:: 46 | 47 | py.test 48 | 49 | For a complete test suite run, using all the Python implementations:: 50 | 51 | tox 52 | 53 | 6. For some more development task helpers, install ``invoke``:: 54 | 55 | pip install invoke 56 | 57 | To list available tasks, run:: 58 | 59 | invoke --list 60 | 61 | For example, to run tests on any file change, run:: 62 | 63 | invoke test --watch 64 | 65 | Or, to build docs when any file changes, run:: 66 | 67 | invoke docs --watch 68 | 69 | See the file ``tasks.py`` for the task definitions. 70 | 71 | 72 | Submitting changes 73 | ================== 74 | 75 | - Code should be accompanied by tests and documentation. Maintain our excellent 76 | test coverage. 77 | 78 | - Follow the existing code style, especially make sure ``flake8`` does not 79 | complain about anything. 80 | 81 | - Write good commit messages. Here's three blog posts on how to do it right: 82 | 83 | - `Writing Git commit messages 84 | `_ 85 | 86 | - `A Note About Git Commit Messages 87 | `_ 88 | 89 | - `On commit messages 90 | `_ 91 | 92 | - One branch per feature or fix. Keep branches small and on topic. 93 | 94 | - Send a pull request to the ``v2.x/master`` branch. See the `GitHub pull 95 | request docs `_ for 96 | help. 97 | 98 | 99 | Additional resources 100 | ==================== 101 | 102 | - `Issue tracker `_ 103 | 104 | - `GitHub documentation `_ 105 | 106 | - `libspotify downloads and documentation archive 107 | `_ 108 | -------------------------------------------------------------------------------- /tests/test_error.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | from spotify import compat 7 | 8 | 9 | class ErrorTest(unittest.TestCase): 10 | def test_error_is_an_exception(self): 11 | error = spotify.Error(0) 12 | self.assertIsInstance(error, Exception) 13 | 14 | def test_maybe_raise(self): 15 | with self.assertRaises(spotify.LibError): 16 | spotify.Error.maybe_raise(spotify.ErrorType.BAD_API_VERSION) 17 | 18 | def test_maybe_raise_does_not_raise_if_ok(self): 19 | spotify.Error.maybe_raise(spotify.ErrorType.OK) 20 | 21 | def test_maybe_raise_does_not_raise_if_error_is_ignored(self): 22 | spotify.Error.maybe_raise( 23 | spotify.ErrorType.BAD_API_VERSION, 24 | ignores=[spotify.ErrorType.BAD_API_VERSION], 25 | ) 26 | 27 | def test_maybe_raise_works_with_any_iterable(self): 28 | spotify.Error.maybe_raise( 29 | spotify.ErrorType.BAD_API_VERSION, 30 | ignores=(spotify.ErrorType.BAD_API_VERSION,), 31 | ) 32 | 33 | 34 | class LibErrorTest(unittest.TestCase): 35 | def test_is_an_error(self): 36 | error = spotify.LibError(0) 37 | self.assertIsInstance(error, spotify.Error) 38 | 39 | def test_has_error_type(self): 40 | error = spotify.LibError(0) 41 | self.assertEqual(error.error_type, 0) 42 | 43 | error = spotify.LibError(1) 44 | self.assertEqual(error.error_type, 1) 45 | 46 | def test_is_equal_if_same_error_type(self): 47 | self.assertEqual(spotify.LibError(0), spotify.LibError(0)) 48 | 49 | def test_is_not_equal_if_different_error_type(self): 50 | self.assertNotEqual(spotify.LibError(0), spotify.LibError(1)) 51 | 52 | def test_error_has_useful_repr(self): 53 | error = spotify.LibError(0) 54 | self.assertIn("No error", repr(error)) 55 | 56 | def test_error_has_useful_string_representation(self): 57 | error = spotify.LibError(0) 58 | self.assertEqual("%s" % error, "No error") 59 | self.assertIsInstance("%s" % error, compat.text_type) 60 | 61 | error = spotify.LibError(1) 62 | self.assertEqual("%s" % error, "Invalid library version") 63 | 64 | def test_has_error_constants(self): 65 | self.assertEqual(spotify.LibError.OK, spotify.LibError(spotify.ErrorType.OK)) 66 | self.assertEqual( 67 | spotify.LibError.BAD_API_VERSION, 68 | spotify.LibError(spotify.ErrorType.BAD_API_VERSION), 69 | ) 70 | 71 | 72 | class ErrorTypeTest(unittest.TestCase): 73 | def test_has_error_type_constants(self): 74 | self.assertEqual(spotify.ErrorType.OK, 0) 75 | self.assertEqual(spotify.ErrorType.BAD_API_VERSION, 1) 76 | 77 | 78 | class TimeoutTest(unittest.TestCase): 79 | def test_is_an_error(self): 80 | error = spotify.Timeout(0.5) 81 | self.assertIsInstance(error, spotify.Error) 82 | 83 | def test_has_useful_repr(self): 84 | error = spotify.Timeout(0.5) 85 | self.assertIn("Operation did not complete in 0.500s", repr(error)) 86 | 87 | def test_has_useful_string_representation(self): 88 | error = spotify.Timeout(0.5) 89 | self.assertEqual("%s" % error, "Operation did not complete in 0.500s") 90 | self.assertIsInstance("%s" % error, compat.text_type) 91 | -------------------------------------------------------------------------------- /spotify/eventloop.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | import threading 5 | 6 | try: 7 | # Python 3 8 | import queue 9 | except ImportError: 10 | # Python 2 11 | import Queue as queue 12 | 13 | import spotify 14 | 15 | __all__ = ["EventLoop"] 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class EventLoop(threading.Thread): 21 | 22 | """Event loop for automatically processing events from libspotify. 23 | 24 | The event loop is a :class:`~threading.Thread` that listens to 25 | :attr:`~spotify.SessionEvent.NOTIFY_MAIN_THREAD` events and calls 26 | :meth:`~spotify.Session.process_events` when needed. 27 | 28 | To use it, pass it your :class:`~spotify.Session` instance and call 29 | :meth:`start`:: 30 | 31 | >>> session = spotify.Session() 32 | >>> event_loop = spotify.EventLoop(session) 33 | >>> event_loop.start() 34 | 35 | The event loop thread is a daemon thread, so it will not stop your 36 | application from exiting. If you wish to stop the event loop without 37 | stopping your entire application, call :meth:`stop`. You may call 38 | :meth:`~threading.Thread.join` to block until the event loop thread has 39 | finished, just like for any other thread. 40 | 41 | .. warning:: 42 | 43 | If you use :class:`EventLoop` to process the libspotify events, any 44 | event listeners you've registered will be called from the event loop 45 | thread. pyspotify itself is thread safe, but you'll need to ensure that 46 | you have proper synchronization in your own application code, as always 47 | when working with threads. 48 | """ 49 | 50 | daemon = True 51 | name = "SpotifyEventLoop" 52 | 53 | def __init__(self, session): 54 | threading.Thread.__init__(self) 55 | 56 | self._session = session 57 | self._runnable = True 58 | self._queue = queue.Queue() 59 | 60 | def start(self): 61 | """Start the event loop.""" 62 | self._session.on( 63 | spotify.SessionEvent.NOTIFY_MAIN_THREAD, self._on_notify_main_thread 64 | ) 65 | threading.Thread.start(self) 66 | 67 | def stop(self): 68 | """Stop the event loop.""" 69 | self._runnable = False 70 | self._session.off( 71 | spotify.SessionEvent.NOTIFY_MAIN_THREAD, self._on_notify_main_thread 72 | ) 73 | 74 | def run(self): 75 | logger.debug("Spotify event loop started") 76 | timeout = self._session.process_events() / 1000.0 77 | while self._runnable: 78 | try: 79 | logger.debug("Waiting %.3fs for new events", timeout) 80 | self._queue.get(timeout=timeout) 81 | except queue.Empty: 82 | logger.debug("Timeout reached; processing events") 83 | else: 84 | logger.debug("Notification received; processing events") 85 | finally: 86 | timeout = self._session.process_events() / 1000.0 87 | logger.debug("Spotify event loop stopped") 88 | 89 | def _on_notify_main_thread(self, session): 90 | # WARNING: This event listener is called from an internal libspotify 91 | # thread. It must not block. 92 | try: 93 | self._queue.put_nowait(1) 94 | except queue.Full: 95 | logger.warning( 96 | "pyspotify event loop queue full; dropped notification event" 97 | ) 98 | -------------------------------------------------------------------------------- /spotify/offline.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import spotify 4 | from spotify import ffi, lib 5 | 6 | __all__ = ["OfflineSyncStatus"] 7 | 8 | 9 | class Offline(object): 10 | 11 | """Offline sync controller. 12 | 13 | You'll never need to create an instance of this class yourself. You'll find 14 | it ready to use as the :attr:`~Session.offline` attribute on the 15 | :class:`Session` instance. 16 | """ 17 | 18 | def __init__(self, session): 19 | self._session = session 20 | 21 | @property 22 | def tracks_to_sync(self): 23 | """Total number of tracks that needs download before everything from 24 | all playlists that are marked for offline is fully synchronized. 25 | """ 26 | return lib.sp_offline_tracks_to_sync(self._session._sp_session) 27 | 28 | @property 29 | def num_playlists(self): 30 | """Number of playlists that are marked for offline synchronization.""" 31 | return lib.sp_offline_num_playlists(self._session._sp_session) 32 | 33 | @property 34 | def sync_status(self): 35 | """The :class:`OfflineSyncStatus` or :class:`None` if not syncing. 36 | 37 | The :attr:`~SessionEvent.OFFLINE_STATUS_UPDATED` event is emitted on 38 | the session object when this is updated. 39 | """ 40 | sp_offline_sync_status = ffi.new("sp_offline_sync_status *") 41 | syncing = lib.sp_offline_sync_get_status( 42 | self._session._sp_session, sp_offline_sync_status 43 | ) 44 | if syncing: 45 | return spotify.OfflineSyncStatus(sp_offline_sync_status) 46 | 47 | @property 48 | def time_left(self): 49 | """The number of seconds until the user has to get online and 50 | relogin.""" 51 | return lib.sp_offline_time_left(self._session._sp_session) 52 | 53 | 54 | class OfflineSyncStatus(object): 55 | 56 | """A Spotify offline sync status object. 57 | 58 | You'll never need to create an instance of this class yourself. You'll find 59 | it ready for use as the :attr:`~spotify.Offline.sync_status` attribute on 60 | the :attr:`~spotify.Session.offline` attribute on the 61 | :class:`~spotify.Session` instance. 62 | """ 63 | 64 | def __init__(self, sp_offline_sync_status): 65 | self._sp_offline_sync_status = sp_offline_sync_status 66 | 67 | @property 68 | def queued_tracks(self): 69 | """Number of tracks left to sync in current sync operation.""" 70 | return self._sp_offline_sync_status.queued_tracks 71 | 72 | @property 73 | def done_tracks(self): 74 | """Number of tracks marked for sync that existed on the device before 75 | the current sync operation.""" 76 | return self._sp_offline_sync_status.done_tracks 77 | 78 | @property 79 | def copied_tracks(self): 80 | """Number of tracks copied during the current sync operation.""" 81 | return self._sp_offline_sync_status.copied_tracks 82 | 83 | @property 84 | def willnotcopy_tracks(self): 85 | """Number of tracks marked for sync that will not be copied.""" 86 | return self._sp_offline_sync_status.willnotcopy_tracks 87 | 88 | @property 89 | def error_tracks(self): 90 | """Number of tracks that failed syncing during the current sync 91 | operation.""" 92 | return self._sp_offline_sync_status.error_tracks 93 | 94 | @property 95 | def syncing(self): 96 | """If sync operation is in progress.""" 97 | return bool(self._sp_offline_sync_status.syncing) 98 | -------------------------------------------------------------------------------- /spotify/inbox.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | import threading 5 | 6 | import spotify 7 | from spotify import ffi, lib, serialized, utils 8 | 9 | __all__ = ["InboxPostResult"] 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class InboxPostResult(object): 15 | 16 | """The result object returned by :meth:`Session.inbox_post_tracks`.""" 17 | 18 | @serialized 19 | def __init__( 20 | self, 21 | session, 22 | canonical_username=None, 23 | tracks=None, 24 | message="", 25 | callback=None, 26 | sp_inbox=None, 27 | add_ref=True, 28 | ): 29 | 30 | assert ( 31 | canonical_username and tracks or sp_inbox 32 | ), "canonical_username and tracks, or sp_inbox, is required" 33 | 34 | self._session = session 35 | self.loaded_event = threading.Event() 36 | 37 | if sp_inbox is None: 38 | canonical_username = utils.to_char(canonical_username) 39 | 40 | if isinstance(tracks, spotify.Track): 41 | tracks = [tracks] 42 | 43 | message = utils.to_char(message) 44 | 45 | handle = ffi.new_handle((self._session, self, callback)) 46 | self._session._callback_handles.add(handle) 47 | 48 | sp_inbox = lib.sp_inbox_post_tracks( 49 | self._session._sp_session, 50 | canonical_username, 51 | [t._sp_track for t in tracks], 52 | len(tracks), 53 | message, 54 | _inboxpost_complete_callback, 55 | handle, 56 | ) 57 | add_ref = True 58 | 59 | if sp_inbox == ffi.NULL: 60 | raise spotify.Error("Inbox post request failed to initialize") 61 | 62 | if add_ref: 63 | lib.sp_inbox_add_ref(sp_inbox) 64 | self._sp_inbox = ffi.gc(sp_inbox, lib.sp_inbox_release) 65 | 66 | loaded_event = None 67 | """:class:`threading.Event` that is set when the inbox post result is 68 | loaded. 69 | """ 70 | 71 | def __repr__(self): 72 | if not self.loaded_event.is_set(): 73 | return "InboxPostResult()" 74 | else: 75 | return "InboxPostResult(%s)" % self.error._name 76 | 77 | def __eq__(self, other): 78 | if isinstance(other, self.__class__): 79 | return self._sp_inbox == other._sp_inbox 80 | else: 81 | return False 82 | 83 | def __ne__(self, other): 84 | return not self.__eq__(other) 85 | 86 | def __hash__(self): 87 | return hash(self._sp_inbox) 88 | 89 | @property 90 | def error(self): 91 | """An :class:`ErrorType` associated with the inbox post result. 92 | 93 | Check to see if there was problems posting to the inbox. 94 | """ 95 | return spotify.ErrorType(lib.sp_inbox_error(self._sp_inbox)) 96 | 97 | 98 | @ffi.callback("void(sp_inbox *, void *)") 99 | @serialized 100 | def _inboxpost_complete_callback(sp_inbox, handle): 101 | logger.debug("inboxpost_complete_callback called") 102 | if handle == ffi.NULL: 103 | logger.warning("pyspotify inboxpost_complete_callback called without userdata") 104 | return 105 | (session, inbox_post_result, callback) = ffi.from_handle(handle) 106 | session._callback_handles.remove(handle) 107 | inbox_post_result.loaded_event.set() 108 | if callback is not None: 109 | callback(inbox_post_result) 110 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | """pyspotify documentation build configuration file""" 4 | 5 | from __future__ import unicode_literals 6 | 7 | import configparser 8 | import os 9 | import sys 10 | import types 11 | 12 | try: 13 | # Python 3.5+ 14 | from unittest import mock 15 | except ImportError: 16 | # Python 2.7 17 | import mock 18 | 19 | 20 | def get_version(): 21 | # Get current library version without requiring the library to be 22 | # installed, like ``pkg_resources.get_distribution(...).version`` requires. 23 | cp = configparser.ConfigParser() 24 | cp.read(os.path.join(os.path.dirname(__file__), "..", "setup.cfg")) 25 | return cp["metadata"]["version"] 26 | 27 | 28 | # -- Workarounds to have autodoc generate API docs ---------------------------- 29 | 30 | sys.path.insert(0, os.path.abspath("..")) 31 | 32 | 33 | # Mock cffi module 34 | module = mock.Mock() 35 | module.ffi = mock.Mock() 36 | module.ffi.CData = bytes 37 | module.lib = mock.Mock() 38 | module.lib.sp_error_message.return_value = b"" 39 | module.lib.sp_error_message.__name__ = str("sp_error_message") 40 | sys.modules["spotify._spotify"] = module 41 | 42 | 43 | # Mock pkg_resources module 44 | module = mock.Mock() 45 | module.get_distribution.return_value.version = get_version() 46 | sys.modules["pkg_resources"] = module 47 | 48 | 49 | # Add all libspotify constants to the lib mock 50 | with open("sp-constants.csv") as fh: 51 | for line in fh.readlines(): 52 | key, value = line.split(",", 1) 53 | setattr(module.lib, key, value) 54 | 55 | 56 | # Unwrap decorated methods so Sphinx can inspect their signatures 57 | import spotify # noqa 58 | 59 | for mod_name, mod in vars(spotify).items(): 60 | if not isinstance(mod, types.ModuleType) or mod_name in ("threading",): 61 | continue 62 | for cls in vars(mod).values(): 63 | if not isinstance(cls, type) or not cls.__module__.startswith("spotify"): 64 | continue 65 | for method_name, method in vars(cls).items(): 66 | if hasattr(method, "__wrapped__"): 67 | setattr(cls, method_name, method.__wrapped__) 68 | 69 | 70 | # -- General configuration ---------------------------------------------------- 71 | 72 | needs_sphinx = "1.0" 73 | 74 | extensions = [ 75 | "sphinx.ext.autodoc", 76 | "sphinx.ext.extlinks", 77 | "sphinx.ext.intersphinx", 78 | "sphinx.ext.viewcode", 79 | ] 80 | 81 | templates_path = ["_templates"] 82 | source_suffix = ".rst" 83 | master_doc = "index" 84 | 85 | project = "pyspotify" 86 | copyright = "2013-2022, Stein Magnus Jodal and contributors" 87 | 88 | release = get_version() 89 | version = ".".join(release.split(".")[:2]) 90 | 91 | exclude_patterns = ["_build"] 92 | 93 | pygments_style = "sphinx" 94 | 95 | modindex_common_prefix = ["spotify."] 96 | 97 | autodoc_default_flags = ["members", "undoc-members", "inherited-members"] 98 | autodoc_member_order = "bysource" 99 | 100 | intersphinx_mapping = { 101 | "python": ("http://docs.python.org/3/", None), 102 | "pyalsaaudio": ("https://larsimmisch.github.io/pyalsaaudio/", None), 103 | } 104 | 105 | 106 | # -- Options for HTML output -------------------------------------------------- 107 | 108 | # html_theme = 'default' 109 | html_static_path = ["_static"] 110 | 111 | html_use_modindex = True 112 | html_use_index = True 113 | html_split_index = False 114 | html_show_sourcelink = True 115 | 116 | htmlhelp_basename = "pyspotify" 117 | 118 | # -- Options for extlink extension -------------------------------------------- 119 | 120 | extlinks = { 121 | "issue": ("https://github.com/jodal/pyspotify/issues/%s", "#"), 122 | "ms-issue": ( 123 | "https://github.com/mopidy/mopidy-spotify/issues/%s", 124 | "mopidy-spotify#", 125 | ), 126 | } 127 | -------------------------------------------------------------------------------- /docs/sp-constants.csv: -------------------------------------------------------------------------------- 1 | SP_ALBUMTYPE_ALBUM,0 2 | SP_ALBUMTYPE_COMPILATION,2 3 | SP_ALBUMTYPE_SINGLE,1 4 | SP_ALBUMTYPE_UNKNOWN,3 5 | SP_ARTISTBROWSE_FULL,0 6 | SP_ARTISTBROWSE_NO_ALBUMS,2 7 | SP_ARTISTBROWSE_NO_TRACKS,1 8 | SP_BITRATE_160k,0 9 | SP_BITRATE_320k,1 10 | SP_BITRATE_96k,2 11 | SP_CONNECTION_RULE_ALLOW_SYNC_OVER_MOBILE,4 12 | SP_CONNECTION_RULE_ALLOW_SYNC_OVER_WIFI,8 13 | SP_CONNECTION_RULE_NETWORK,1 14 | SP_CONNECTION_RULE_NETWORK_IF_ROAMING,2 15 | SP_CONNECTION_STATE_DISCONNECTED,2 16 | SP_CONNECTION_STATE_LOGGED_IN,1 17 | SP_CONNECTION_STATE_LOGGED_OUT,0 18 | SP_CONNECTION_STATE_OFFLINE,4 19 | SP_CONNECTION_STATE_UNDEFINED,3 20 | SP_CONNECTION_TYPE_MOBILE,2 21 | SP_CONNECTION_TYPE_MOBILE_ROAMING,3 22 | SP_CONNECTION_TYPE_NONE,1 23 | SP_CONNECTION_TYPE_UNKNOWN,0 24 | SP_CONNECTION_TYPE_WIFI,4 25 | SP_CONNECTION_TYPE_WIRED,5 26 | SP_ERROR_API_INITIALIZATION_FAILED,2 27 | SP_ERROR_APPLICATION_BANNED,27 28 | SP_ERROR_BAD_API_VERSION,1 29 | SP_ERROR_BAD_APPLICATION_KEY,5 30 | SP_ERROR_BAD_USERNAME_OR_PASSWORD,6 31 | SP_ERROR_BAD_USER_AGENT,11 32 | SP_ERROR_CANT_OPEN_TRACE_FILE,26 33 | SP_ERROR_CLIENT_TOO_OLD,9 34 | SP_ERROR_INBOX_IS_FULL,20 35 | SP_ERROR_INDEX_OUT_OF_RANGE,14 36 | SP_ERROR_INVALID_ARGUMENT,40 37 | SP_ERROR_INVALID_DEVICE_ID,25 38 | SP_ERROR_INVALID_INDATA,13 39 | SP_ERROR_IS_LOADING,17 40 | SP_ERROR_LASTFM_AUTH_ERROR,39 41 | SP_ERROR_MISSING_CALLBACK,12 42 | SP_ERROR_NETWORK_DISABLED,24 43 | SP_ERROR_NO_CACHE,21 44 | SP_ERROR_NO_CREDENTIALS,23 45 | SP_ERROR_NO_STREAM_AVAILABLE,18 46 | SP_ERROR_NO_SUCH_USER,22 47 | SP_ERROR_OFFLINE_DISK_CACHE,32 48 | SP_ERROR_OFFLINE_EXPIRED,33 49 | SP_ERROR_OFFLINE_LICENSE_ERROR,36 50 | SP_ERROR_OFFLINE_LICENSE_LOST,35 51 | SP_ERROR_OFFLINE_NOT_ALLOWED,34 52 | SP_ERROR_OFFLINE_TOO_MANY_TRACKS,31 53 | SP_ERROR_OK,0 54 | SP_ERROR_OTHER_PERMANENT,10 55 | SP_ERROR_OTHER_TRANSIENT,16 56 | SP_ERROR_PERMISSION_DENIED,19 57 | SP_ERROR_SYSTEM_FAILURE,41 58 | SP_ERROR_TRACK_NOT_PLAYABLE,3 59 | SP_ERROR_UNABLE_TO_CONTACT_SERVER,8 60 | SP_ERROR_USER_BANNED,7 61 | SP_ERROR_USER_NEEDS_PREMIUM,15 62 | SP_IMAGE_FORMAT_JPEG,0 63 | SP_IMAGE_FORMAT_UNKNOWN,-1 64 | SP_IMAGE_SIZE_LARGE,2 65 | SP_IMAGE_SIZE_NORMAL,0 66 | SP_IMAGE_SIZE_SMALL,1 67 | SP_LINKTYPE_ALBUM,2 68 | SP_LINKTYPE_ARTIST,3 69 | SP_LINKTYPE_IMAGE,9 70 | SP_LINKTYPE_INVALID,0 71 | SP_LINKTYPE_LOCALTRACK,8 72 | SP_LINKTYPE_PLAYLIST,5 73 | SP_LINKTYPE_PROFILE,6 74 | SP_LINKTYPE_SEARCH,4 75 | SP_LINKTYPE_STARRED,7 76 | SP_LINKTYPE_TRACK,1 77 | SP_PLAYLIST_OFFLINE_STATUS_DOWNLOADING,2 78 | SP_PLAYLIST_OFFLINE_STATUS_NO,0 79 | SP_PLAYLIST_OFFLINE_STATUS_WAITING,3 80 | SP_PLAYLIST_OFFLINE_STATUS_YES,1 81 | SP_PLAYLIST_TYPE_END_FOLDER,2 82 | SP_PLAYLIST_TYPE_PLACEHOLDER,3 83 | SP_PLAYLIST_TYPE_PLAYLIST,0 84 | SP_PLAYLIST_TYPE_START_FOLDER,1 85 | SP_RELATION_TYPE_BIDIRECTIONAL,3 86 | SP_RELATION_TYPE_NONE,1 87 | SP_RELATION_TYPE_UNIDIRECTIONAL,2 88 | SP_RELATION_TYPE_UNKNOWN,0 89 | SP_SAMPLETYPE_INT16_NATIVE_ENDIAN,0 90 | SP_SCROBBLING_STATE_GLOBAL_DISABLED,4 91 | SP_SCROBBLING_STATE_GLOBAL_ENABLED,3 92 | SP_SCROBBLING_STATE_LOCAL_DISABLED,2 93 | SP_SCROBBLING_STATE_LOCAL_ENABLED,1 94 | SP_SCROBBLING_STATE_USE_GLOBAL_SETTING,0 95 | SP_SEARCH_STANDARD,0 96 | SP_SEARCH_SUGGEST,1 97 | SP_SOCIAL_PROVIDER_FACEBOOK,1 98 | SP_SOCIAL_PROVIDER_LASTFM,2 99 | SP_SOCIAL_PROVIDER_SPOTIFY,0 100 | SP_TOPLIST_REGION_EVERYWHERE,0 101 | SP_TOPLIST_REGION_USER,1 102 | SP_TOPLIST_TYPE_ALBUMS,1 103 | SP_TOPLIST_TYPE_ARTISTS,0 104 | SP_TOPLIST_TYPE_TRACKS,2 105 | SP_TRACK_AVAILABILITY_AVAILABLE,1 106 | SP_TRACK_AVAILABILITY_BANNED_BY_ARTIST,3 107 | SP_TRACK_AVAILABILITY_NOT_STREAMABLE,2 108 | SP_TRACK_AVAILABILITY_UNAVAILABLE,0 109 | SP_TRACK_OFFLINE_DONE,3 110 | SP_TRACK_OFFLINE_DONE_EXPIRED,5 111 | SP_TRACK_OFFLINE_DONE_RESYNC,7 112 | SP_TRACK_OFFLINE_DOWNLOADING,2 113 | SP_TRACK_OFFLINE_ERROR,4 114 | SP_TRACK_OFFLINE_LIMIT_EXCEEDED,6 115 | SP_TRACK_OFFLINE_NO,0 116 | SP_TRACK_OFFLINE_WAITING,1 117 | -------------------------------------------------------------------------------- /spotify/social.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import spotify 4 | from spotify import ffi, lib, utils 5 | 6 | __all__ = ["ScrobblingState", "SocialProvider"] 7 | 8 | 9 | class Social(object): 10 | 11 | """Social sharing controller. 12 | 13 | You'll never need to create an instance of this class yourself. You'll find 14 | it ready to use as the :attr:`~Session.social` attribute on the 15 | :class:`Session` instance. 16 | """ 17 | 18 | def __init__(self, session): 19 | self._session = session 20 | 21 | @property 22 | def private_session(self): 23 | """Whether the session is private. 24 | 25 | Set to :class:`True` or :class:`False` to change. 26 | """ 27 | return bool(lib.sp_session_is_private_session(self._session._sp_session)) 28 | 29 | @private_session.setter 30 | def private_session(self, value): 31 | # XXX sp_session_set_private_session() segfaults unless we login and 32 | # call process_events() at least once before calling it. If we log out 33 | # again, calling the function still works without segfaults. This bug 34 | # has been reported to Spotify on IRC. 35 | if self._session.connection.state != spotify.ConnectionState.LOGGED_IN: 36 | raise RuntimeError( 37 | "private_session can only be set when the session is logged " 38 | "in. This is temporary workaround of a libspotify bug, " 39 | "causing the application to segfault otherwise." 40 | ) 41 | spotify.Error.maybe_raise( 42 | lib.sp_session_set_private_session(self._session._sp_session, bool(value)) 43 | ) 44 | 45 | def is_scrobbling(self, social_provider): 46 | """Get the :class:`ScrobblingState` for the given 47 | ``social_provider``.""" 48 | scrobbling_state = ffi.new("sp_scrobbling_state *") 49 | spotify.Error.maybe_raise( 50 | lib.sp_session_is_scrobbling( 51 | self._session._sp_session, social_provider, scrobbling_state 52 | ) 53 | ) 54 | return spotify.ScrobblingState(scrobbling_state[0]) 55 | 56 | def is_scrobbling_possible(self, social_provider): 57 | """Check if the scrobbling settings should be shown to the user.""" 58 | out = ffi.new("bool *") 59 | spotify.Error.maybe_raise( 60 | lib.sp_session_is_scrobbling_possible( 61 | self._session._sp_session, social_provider, out 62 | ) 63 | ) 64 | return bool(out[0]) 65 | 66 | def set_scrobbling(self, social_provider, scrobbling_state): 67 | """Set the ``scrobbling_state`` for the given ``social_provider``.""" 68 | spotify.Error.maybe_raise( 69 | lib.sp_session_set_scrobbling( 70 | self._session._sp_session, social_provider, scrobbling_state 71 | ) 72 | ) 73 | 74 | def set_social_credentials(self, social_provider, username, password): 75 | """Set the user's credentials with a social provider. 76 | 77 | Currently this is only relevant for Last.fm. Call 78 | :meth:`set_scrobbling` to force an authentication attempt with the 79 | provider. If authentication fails a 80 | :attr:`~SessionEvent.SCROBBLE_ERROR` event will be emitted on the 81 | :class:`Session` object. 82 | """ 83 | spotify.Error.maybe_raise( 84 | lib.sp_session_set_social_credentials( 85 | self._session._sp_session, 86 | social_provider, 87 | utils.to_char(username), 88 | utils.to_char(password), 89 | ) 90 | ) 91 | 92 | 93 | @utils.make_enum("SP_SCROBBLING_STATE_") 94 | class ScrobblingState(utils.IntEnum): 95 | pass 96 | 97 | 98 | @utils.make_enum("SP_SOCIAL_PROVIDER_") 99 | class SocialProvider(utils.IntEnum): 100 | pass 101 | -------------------------------------------------------------------------------- /spotify/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import threading 4 | 5 | import pkg_resources 6 | 7 | __version__ = pkg_resources.get_distribution("pyspotify").version 8 | 9 | 10 | # Global reentrant lock to be held whenever libspotify functions are called or 11 | # libspotify owned data is worked on. This is the heart of pyspotify's thread 12 | # safety. 13 | _lock = threading.RLock() 14 | 15 | 16 | # Reference to the spotify.Session instance. Used to enforce that one and only 17 | # one session exists in each process. 18 | _session_instance = None 19 | 20 | 21 | def _setup_logging(): 22 | """Setup logging to log to nowhere by default. 23 | 24 | For details, see: 25 | http://docs.python.org/3/howto/logging.html#library-config 26 | 27 | Internal function. 28 | """ 29 | import logging 30 | 31 | logger = logging.getLogger("spotify") 32 | handler = logging.NullHandler() 33 | logger.addHandler(handler) 34 | 35 | 36 | def serialized(f): 37 | """Decorator that serializes access to all decorated functions. 38 | 39 | The decorator acquires pyspotify's single global lock while calling any 40 | wrapped function. It is used to serialize access to: 41 | 42 | - All calls to functions on :attr:`spotify.lib`. 43 | 44 | - All code blocks working on pointers returned from functions on 45 | :attr:`spotify.lib`. 46 | 47 | - All code blocks working on other internal data structures in pyspotify. 48 | 49 | Together this is what makes pyspotify safe to use from multiple threads and 50 | enables convenient features like the :class:`~spotify.EventLoop`. 51 | 52 | Internal function. 53 | """ 54 | import functools 55 | 56 | @functools.wraps(f) 57 | def wrapper(*args, **kwargs): 58 | if _lock is None: 59 | # During process teardown, objects wrapped with `ffi.gc()` might be 60 | # freed and their libspotify release functions called after the lock 61 | # has been freed. When this happens, `_lock` will be `None`. 62 | # Since we're already shutting down the process, we just abort the 63 | # call when the lock is gone. 64 | return 65 | with _lock: 66 | return f(*args, **kwargs) 67 | 68 | if not hasattr(wrapper, "__wrapped__"): 69 | # Workaround for Python < 3.2 70 | wrapper.__wrapped__ = f 71 | return wrapper 72 | 73 | 74 | class _SerializedLib(object): 75 | """CFFI library wrapper to serialize all calls to library functions. 76 | 77 | Internal class. 78 | """ 79 | 80 | def __init__(self, lib): 81 | for name in dir(lib): 82 | attr = getattr(lib, name) 83 | if name.startswith("sp_") and callable(attr): 84 | attr = serialized(attr) 85 | setattr(self, name, attr) 86 | 87 | 88 | _setup_logging() 89 | 90 | from spotify._spotify import ffi, lib # noqa 91 | 92 | lib = _SerializedLib(lib) 93 | 94 | from spotify.album import * # noqa 95 | from spotify.artist import * # noqa 96 | from spotify.audio import * # noqa 97 | from spotify.config import * # noqa 98 | from spotify.connection import * # noqa 99 | from spotify.error import * # noqa 100 | from spotify.eventloop import * # noqa 101 | from spotify.image import * # noqa 102 | from spotify.inbox import * # noqa 103 | from spotify.link import * # noqa 104 | from spotify.offline import * # noqa 105 | from spotify.player import * # noqa 106 | from spotify.playlist import * # noqa 107 | from spotify.playlist_container import * # noqa 108 | from spotify.playlist_track import * # noqa 109 | from spotify.playlist_unseen_tracks import * # noqa 110 | from spotify.search import * # noqa 111 | from spotify.session import * # noqa 112 | from spotify.sink import * # noqa 113 | from spotify.social import * # noqa 114 | from spotify.toplist import * # noqa 115 | from spotify.track import * # noqa 116 | from spotify.user import * # noqa 117 | from spotify.version import * # noqa 118 | -------------------------------------------------------------------------------- /tests/test_offline.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | import tests 7 | from tests import mock 8 | 9 | 10 | @mock.patch("spotify.offline.lib", spec=spotify.lib) 11 | @mock.patch("spotify.session.lib", spec=spotify.lib) 12 | class OfflineTest(unittest.TestCase): 13 | def tearDown(self): 14 | spotify._session_instance = None 15 | 16 | def test_offline_tracks_to_sync(self, session_lib_mock, lib_mock): 17 | lib_mock.sp_offline_tracks_to_sync.return_value = 17 18 | session = tests.create_real_session(session_lib_mock) 19 | 20 | result = session.offline.tracks_to_sync 21 | 22 | lib_mock.sp_offline_tracks_to_sync.assert_called_with(session._sp_session) 23 | self.assertEqual(result, 17) 24 | 25 | def test_offline_num_playlists(self, session_lib_mock, lib_mock): 26 | lib_mock.sp_offline_num_playlists.return_value = 5 27 | session = tests.create_real_session(session_lib_mock) 28 | 29 | result = session.offline.num_playlists 30 | 31 | lib_mock.sp_offline_num_playlists.assert_called_with(session._sp_session) 32 | self.assertEqual(result, 5) 33 | 34 | def test_offline_sync_status(self, session_lib_mock, lib_mock): 35 | def func(sp_session_ptr, sp_offline_sync_status): 36 | sp_offline_sync_status.queued_tracks = 3 37 | return 1 38 | 39 | lib_mock.sp_offline_sync_get_status.side_effect = func 40 | session = tests.create_real_session(session_lib_mock) 41 | 42 | result = session.offline.sync_status 43 | 44 | lib_mock.sp_offline_sync_get_status.assert_called_with( 45 | session._sp_session, mock.ANY 46 | ) 47 | self.assertIsInstance(result, spotify.OfflineSyncStatus) 48 | self.assertEqual(result.queued_tracks, 3) 49 | 50 | def test_offline_sync_status_when_not_syncing(self, session_lib_mock, lib_mock): 51 | lib_mock.sp_offline_sync_get_status.return_value = 0 52 | session = tests.create_real_session(session_lib_mock) 53 | 54 | result = session.offline.sync_status 55 | 56 | lib_mock.sp_offline_sync_get_status.assert_called_with( 57 | session._sp_session, mock.ANY 58 | ) 59 | self.assertIsNone(result) 60 | 61 | def test_offline_time_left(self, session_lib_mock, lib_mock): 62 | lib_mock.sp_offline_time_left.return_value = 3600 63 | session = tests.create_real_session(session_lib_mock) 64 | 65 | result = session.offline.time_left 66 | 67 | lib_mock.sp_offline_time_left.assert_called_with(session._sp_session) 68 | self.assertEqual(result, 3600) 69 | 70 | 71 | class OfflineSyncStatusTest(unittest.TestCase): 72 | def setUp(self): 73 | self._sp_offline_sync_status = spotify.ffi.new("sp_offline_sync_status *") 74 | self._sp_offline_sync_status.queued_tracks = 5 75 | self._sp_offline_sync_status.done_tracks = 16 76 | self._sp_offline_sync_status.copied_tracks = 27 77 | self._sp_offline_sync_status.willnotcopy_tracks = 2 78 | self._sp_offline_sync_status.error_tracks = 3 79 | self._sp_offline_sync_status.syncing = True 80 | 81 | self.offline_sync_status = spotify.OfflineSyncStatus( 82 | self._sp_offline_sync_status 83 | ) 84 | 85 | def test_queued_tracks(self): 86 | self.assertEqual(self.offline_sync_status.queued_tracks, 5) 87 | 88 | def test_done_tracks(self): 89 | self.assertEqual(self.offline_sync_status.done_tracks, 16) 90 | 91 | def test_copied_tracks(self): 92 | self.assertEqual(self.offline_sync_status.copied_tracks, 27) 93 | 94 | def test_willnotcopy_tracks(self): 95 | self.assertEqual(self.offline_sync_status.willnotcopy_tracks, 2) 96 | 97 | def test_error_tracks(self): 98 | self.assertEqual(self.offline_sync_status.error_tracks, 3) 99 | 100 | def test_syncing(self): 101 | self.assertTrue(self.offline_sync_status.syncing) 102 | -------------------------------------------------------------------------------- /tests/test_loadable.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import time 4 | import unittest 5 | 6 | import spotify 7 | import tests 8 | from spotify.utils import load 9 | from tests import mock 10 | 11 | 12 | class Foo(object): 13 | def __init__(self, session): 14 | self._session = session 15 | 16 | @property 17 | def is_loaded(self): 18 | return True 19 | 20 | def load(self, timeout=None): 21 | return load(self._session, self, timeout=timeout) 22 | 23 | 24 | class FooWithError(Foo): 25 | @property 26 | def error(self): 27 | return spotify.Error(spotify.Error.OK) 28 | 29 | 30 | @mock.patch("spotify.utils.time") 31 | @mock.patch.object(Foo, "is_loaded", new_callable=mock.PropertyMock) 32 | class LoadableTest(unittest.TestCase): 33 | def setUp(self): 34 | self.session = tests.create_session_mock() 35 | self.session.connection.state = spotify.ConnectionState.LOGGED_IN 36 | 37 | def test_load_raises_error_if_not_logged_in(self, is_loaded_mock, time_mock): 38 | is_loaded_mock.return_value = False 39 | self.session.connection.state = spotify.ConnectionState.LOGGED_OUT 40 | foo = Foo(self.session) 41 | 42 | with self.assertRaises(spotify.Error): 43 | foo.load() 44 | 45 | def test_load_raises_error_if_offline(self, is_loaded_mock, time_mock): 46 | is_loaded_mock.return_value = False 47 | self.session.connection.state = spotify.ConnectionState.OFFLINE 48 | foo = Foo(self.session) 49 | 50 | with self.assertRaises(spotify.Error): 51 | foo.load() 52 | 53 | def test_load_returns_immediately_if_offline_but_already_loaded( 54 | self, is_loaded_mock, time_mock 55 | ): 56 | is_loaded_mock.return_value = True 57 | self.session.connection.state = spotify.ConnectionState.OFFLINE 58 | foo = Foo(self.session) 59 | 60 | result = foo.load() 61 | 62 | self.assertEqual(result, foo) 63 | self.assertEqual(self.session.process_events.call_count, 0) 64 | 65 | def test_load_raises_error_when_timeout_is_reached(self, is_loaded_mock, time_mock): 66 | is_loaded_mock.return_value = False 67 | time_mock.time.side_effect = time.time 68 | foo = Foo(self.session) 69 | 70 | with self.assertRaises(spotify.Timeout): 71 | foo.load(timeout=0) 72 | 73 | def test_load_processes_events_until_loaded(self, is_loaded_mock, time_mock): 74 | is_loaded_mock.side_effect = [False, False, False, False, False, True] 75 | time_mock.time.side_effect = time.time 76 | 77 | foo = Foo(self.session) 78 | foo.load() 79 | 80 | self.assertEqual(self.session.process_events.call_count, 2) 81 | self.assertEqual(time_mock.sleep.call_count, 2) 82 | 83 | @mock.patch.object(FooWithError, "error", new_callable=mock.PropertyMock) 84 | def test_load_raises_exception_on_error( 85 | self, error_mock, is_loaded_mock, time_mock 86 | ): 87 | error_mock.side_effect = [ 88 | spotify.ErrorType.IS_LOADING, 89 | spotify.ErrorType.OTHER_PERMANENT, 90 | ] 91 | is_loaded_mock.side_effect = [False, False, True] 92 | 93 | foo = FooWithError(self.session) 94 | 95 | with self.assertRaises(spotify.Error): 96 | foo.load() 97 | 98 | self.assertEqual(self.session.process_events.call_count, 1) 99 | self.assertEqual(time_mock.sleep.call_count, 0) 100 | 101 | def test_load_raises_exception_on_error_even_if_already_loaded( 102 | self, is_loaded_mock, time_mock 103 | ): 104 | is_loaded_mock.return_value = True 105 | 106 | foo = Foo(self.session) 107 | foo.error = spotify.ErrorType.OTHER_PERMANENT 108 | 109 | with self.assertRaises(spotify.Error): 110 | foo.load() 111 | 112 | def test_load_does_not_abort_on_is_loading_error(self, is_loaded_mock, time_mock): 113 | is_loaded_mock.side_effect = [False, False, False, False, False, True] 114 | time_mock.time.side_effect = time.time 115 | 116 | foo = Foo(self.session) 117 | foo.error = spotify.ErrorType.IS_LOADING 118 | foo.load() 119 | 120 | self.assertEqual(self.session.process_events.call_count, 2) 121 | self.assertEqual(time_mock.sleep.call_count, 2) 122 | 123 | def test_load_returns_self(self, is_loaded_mock, time_mock): 124 | is_loaded_mock.return_value = True 125 | 126 | foo = Foo(self.session) 127 | result = foo.load() 128 | 129 | self.assertEqual(result, foo) 130 | -------------------------------------------------------------------------------- /tests/regression/bug_123.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import argparse 4 | import json 5 | import logging 6 | import sys 7 | import threading 8 | import time 9 | 10 | import spotify 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def make_parser(): 16 | parser = argparse.ArgumentParser() 17 | parser.add_argument( 18 | "-u", 19 | "--username", 20 | action="store", 21 | required=True, 22 | help="Spotify username", 23 | ) 24 | parser.add_argument( 25 | "-p", 26 | "--password", 27 | action="store", 28 | required=True, 29 | help="Spotify password", 30 | ) 31 | parser.add_argument( 32 | "-v", "--verbose", action="store_true", help="Turn on debug logging" 33 | ) 34 | subparsers = parser.add_subparsers(dest="command", help="sub-command --help") 35 | 36 | subparsers.add_parser("info", help="Get account info") 37 | 38 | create_playlist_parser = subparsers.add_parser( 39 | "create-playlist", help="Create a new playlist" 40 | ) 41 | create_playlist_parser.add_argument( 42 | "name", action="store", help="Name of new playlist" 43 | ) 44 | 45 | add_track_parser = subparsers.add_parser("add-track", help="Add track to playlist") 46 | add_track_parser.add_argument("playlist", action="store", help="URI of playlist") 47 | add_track_parser.add_argument("track", action="store", help="URI of track") 48 | 49 | return parser 50 | 51 | 52 | def login(session, username, password): 53 | logged_in_event = threading.Event() 54 | 55 | def logged_in_listener(session, error_type): 56 | if error_type != spotify.ErrorType.OK: 57 | logger.error("Login failed: %r", error_type) 58 | logged_in_event.set() 59 | 60 | session.on(spotify.SessionEvent.LOGGED_IN, logged_in_listener) 61 | session.login(username, password) 62 | 63 | if not logged_in_event.wait(10): 64 | raise RuntimeError("Login timed out") 65 | logger.debug("Logged in as %r", session.user_name) 66 | 67 | while session.connection.state != spotify.ConnectionState.LOGGED_IN: 68 | logger.debug("Waiting for connection") 69 | time.sleep(0.1) 70 | 71 | 72 | def logout(session): 73 | logged_out_event = threading.Event() 74 | 75 | def logged_out_listener(session): 76 | logged_out_event.set() 77 | 78 | session.on(spotify.SessionEvent.LOGGED_OUT, logged_out_listener) 79 | session.logout() 80 | 81 | if not logged_out_event.wait(10): 82 | raise RuntimeError("Logout timed out") 83 | 84 | 85 | def create_playlist(session, playlist_name): 86 | playlist = session.playlist_container.add_new_playlist(playlist_name) 87 | 88 | return (playlist.name, playlist.link.uri) 89 | 90 | 91 | def add_track(session, playlist_uri, track_uri): 92 | playlist = session.get_playlist(playlist_uri).load() 93 | track = session.get_track(track_uri).load() 94 | 95 | playlist.add_tracks(track) 96 | 97 | return playlist.link.uri, track.link.uri 98 | 99 | 100 | def main(args): 101 | if args.verbose: 102 | logging.basicConfig(level=logging.DEBUG) 103 | 104 | session = spotify.Session() 105 | loop = spotify.EventLoop(session) 106 | loop.start() 107 | 108 | login(session, args.username, args.password) 109 | 110 | try: 111 | if args.command == "info": 112 | session.playlist_container.load() 113 | result = { 114 | "success": True, 115 | "action": args.command, 116 | "response": { 117 | "user_name": session.user_name, 118 | "num_playlists": len(session.playlist_container), 119 | "num_starred": len(session.starred.tracks), 120 | }, 121 | } 122 | elif args.command == "create-playlist": 123 | name, uri = create_playlist(session, args.name) 124 | result = { 125 | "success": True, 126 | "action": args.command, 127 | "response": {"playlist_name": name, "playlist_uri": uri}, 128 | } 129 | elif args.command == "add-track": 130 | playlist_uri, track_uri = add_track(session, args.playlist, args.track) 131 | result = { 132 | "success": True, 133 | "action": args.command, 134 | "response": { 135 | "playlist_uri": playlist_uri, 136 | "track_uri": track_uri, 137 | }, 138 | } 139 | except spotify.Error as error: 140 | logger.exception("%s failed", args.command) 141 | result = {"success": False, "action": args.command, "error": str(error)} 142 | 143 | # Proper logout ensures that all data is persisted properly 144 | logout(session) 145 | 146 | return result 147 | 148 | 149 | if __name__ == "__main__": 150 | parser = make_parser() 151 | args = parser.parse_args() 152 | result = main(args) 153 | print(json.dumps(result, indent=2)) 154 | sys.exit(not result["success"]) 155 | -------------------------------------------------------------------------------- /spotify/connection.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import functools 4 | import operator 5 | 6 | import spotify 7 | from spotify import lib, utils 8 | 9 | __all__ = ["ConnectionRule", "ConnectionState", "ConnectionType"] 10 | 11 | 12 | class Connection(object): 13 | 14 | """Connection controller. 15 | 16 | You'll never need to create an instance of this class yourself. You'll find 17 | it ready to use as the :attr:`~Session.connection` attribute on the 18 | :class:`Session` instance. 19 | """ 20 | 21 | def __init__(self, session): 22 | self._session = session 23 | 24 | # The following defaults are based on the libspotify documentation 25 | self._connection_type = spotify.ConnectionType.UNKNOWN 26 | self._allow_network = True 27 | self._allow_network_if_roaming = False 28 | self._allow_sync_over_wifi = True 29 | self._allow_sync_over_mobile = False 30 | 31 | @property 32 | def state(self): 33 | """The session's current :class:`ConnectionState`. 34 | 35 | The connection state involves two components, authentication and 36 | offline mode. The mapping is as follows 37 | 38 | - :attr:`~ConnectionState.LOGGED_OUT`: not authenticated, offline 39 | - :attr:`~ConnectionState.OFFLINE`: authenticated, offline 40 | - :attr:`~ConnectionState.LOGGED_IN`: authenticated, online 41 | - :attr:`~ConnectionState.DISCONNECTED`: authenticated, offline, was 42 | previously online 43 | 44 | Register listeners for the 45 | :attr:`spotify.SessionEvent.CONNECTION_STATE_UPDATED` event to be 46 | notified when the connection state changes. 47 | """ 48 | return spotify.ConnectionState( 49 | lib.sp_session_connectionstate(self._session._sp_session) 50 | ) 51 | 52 | @property 53 | def type(self): 54 | """The session's :class:`ConnectionType`. 55 | 56 | Defaults to :attr:`ConnectionType.UNKNOWN`. Set to a 57 | :class:`ConnectionType` value to tell libspotify what type of 58 | connection you're using. 59 | 60 | This is used together with :attr:`~Connection.allow_network`, 61 | :attr:`~Connection.allow_network_if_roaming`, 62 | :attr:`~Connection.allow_sync_over_wifi`, and 63 | :attr:`~Connection.allow_sync_over_mobile` to control offline syncing 64 | and network usage. 65 | """ 66 | return self._connection_type 67 | 68 | @type.setter 69 | def type(self, value): 70 | spotify.Error.maybe_raise( 71 | lib.sp_session_set_connection_type(self._session._sp_session, value) 72 | ) 73 | self._connection_type = value 74 | 75 | @property 76 | def allow_network(self): 77 | """Whether or not network access is allowed at all. 78 | 79 | Defaults to :class:`True`. Setting this to :class:`False` turns on 80 | offline mode. 81 | """ 82 | return self._allow_network 83 | 84 | @allow_network.setter 85 | def allow_network(self, value): 86 | self._allow_network = value 87 | self._update_connection_rules() 88 | 89 | @property 90 | def allow_network_if_roaming(self): 91 | """Whether or not network access is allowed if :attr:`~Connection.type` 92 | is set to :attr:`ConnectionType.MOBILE_ROAMING`. 93 | 94 | Defaults to :class:`False`. 95 | """ 96 | return self._allow_network_if_roaming 97 | 98 | @allow_network_if_roaming.setter 99 | def allow_network_if_roaming(self, value): 100 | self._allow_network_if_roaming = value 101 | self._update_connection_rules() 102 | 103 | @property 104 | def allow_sync_over_wifi(self): 105 | """Whether or not offline syncing is allowed when 106 | :attr:`~Connection.type` is set to :attr:`ConnectionType.WIFI`. 107 | 108 | Defaults to :class:`True`. 109 | """ 110 | return self._allow_sync_over_wifi 111 | 112 | @allow_sync_over_wifi.setter 113 | def allow_sync_over_wifi(self, value): 114 | self._allow_sync_over_wifi = value 115 | self._update_connection_rules() 116 | 117 | @property 118 | def allow_sync_over_mobile(self): 119 | """Whether or not offline syncing is allowed when 120 | :attr:`~Connection.type` is set to :attr:`ConnectionType.MOBILE`, or 121 | :attr:`allow_network_if_roaming` is :class:`True` and 122 | :attr:`~Connection.type` is set to 123 | :attr:`ConnectionType.MOBILE_ROAMING`. 124 | 125 | Defaults to :class:`True`. 126 | """ 127 | return self._allow_sync_over_mobile 128 | 129 | @allow_sync_over_mobile.setter 130 | def allow_sync_over_mobile(self, value): 131 | self._allow_sync_over_mobile = value 132 | self._update_connection_rules() 133 | 134 | def _update_connection_rules(self): 135 | rules = [] 136 | if self._allow_network: 137 | rules.append(spotify.ConnectionRule.NETWORK) 138 | if self._allow_network_if_roaming: 139 | rules.append(spotify.ConnectionRule.NETWORK_IF_ROAMING) 140 | if self._allow_sync_over_wifi: 141 | rules.append(spotify.ConnectionRule.ALLOW_SYNC_OVER_WIFI) 142 | if self._allow_sync_over_mobile: 143 | rules.append(spotify.ConnectionRule.ALLOW_SYNC_OVER_MOBILE) 144 | rules = functools.reduce(operator.or_, rules, 0) 145 | spotify.Error.maybe_raise( 146 | lib.sp_session_set_connection_rules(self._session._sp_session, rules) 147 | ) 148 | 149 | 150 | @utils.make_enum("SP_CONNECTION_RULE_") 151 | class ConnectionRule(utils.IntEnum): 152 | pass 153 | 154 | 155 | @utils.make_enum("SP_CONNECTION_STATE_") 156 | class ConnectionState(utils.IntEnum): 157 | pass 158 | 159 | 160 | @utils.make_enum("SP_CONNECTION_TYPE_") 161 | class ConnectionType(utils.IntEnum): 162 | pass 163 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | import tests 7 | from tests import mock 8 | 9 | 10 | @mock.patch("spotify.user.lib", spec=spotify.lib) 11 | class UserTest(unittest.TestCase): 12 | def setUp(self): 13 | self.session = tests.create_session_mock() 14 | 15 | def test_create_without_uri_or_sp_user_fails(self, lib_mock): 16 | with self.assertRaises(AssertionError): 17 | spotify.User(self.session) 18 | 19 | @mock.patch("spotify.Link", spec=spotify.Link) 20 | def test_create_from_uri(self, link_mock, lib_mock): 21 | sp_user = spotify.ffi.cast("sp_user *", 42) 22 | link_instance_mock = link_mock.return_value 23 | link_instance_mock.as_user.return_value = spotify.User( 24 | self.session, sp_user=sp_user 25 | ) 26 | uri = "spotify:user:foo" 27 | 28 | result = spotify.User(self.session, uri=uri) 29 | 30 | link_mock.assert_called_with(self.session, uri=uri) 31 | link_instance_mock.as_user.assert_called_with() 32 | lib_mock.sp_user_add_ref.assert_called_with(sp_user) 33 | self.assertEqual(result._sp_user, sp_user) 34 | 35 | @mock.patch("spotify.Link", spec=spotify.Link) 36 | def test_create_from_uri_fail_raises_error(self, link_mock, lib_mock): 37 | link_instance_mock = link_mock.return_value 38 | link_instance_mock.as_user.return_value = None 39 | uri = "spotify:user:foo" 40 | 41 | with self.assertRaises(ValueError): 42 | spotify.User(self.session, uri=uri) 43 | 44 | def test_adds_ref_to_sp_user_when_created(self, lib_mock): 45 | sp_user = spotify.ffi.cast("sp_user *", 42) 46 | 47 | spotify.User(self.session, sp_user=sp_user) 48 | 49 | lib_mock.sp_user_add_ref.assert_called_once_with(sp_user) 50 | 51 | def test_releases_sp_user_when_user_dies(self, lib_mock): 52 | sp_user = spotify.ffi.cast("sp_user *", 42) 53 | 54 | user = spotify.User(self.session, sp_user=sp_user) 55 | user = None # noqa 56 | tests.gc_collect() 57 | 58 | lib_mock.sp_user_release.assert_called_with(sp_user) 59 | 60 | @mock.patch("spotify.Link", spec=spotify.Link) 61 | def test_repr(self, link_mock, lib_mock): 62 | link_instance_mock = link_mock.return_value 63 | link_instance_mock.uri = "foo" 64 | sp_user = spotify.ffi.cast("sp_user *", 42) 65 | user = spotify.User(self.session, sp_user=sp_user) 66 | 67 | result = repr(user) 68 | 69 | self.assertEqual(result, "User(%r)" % "foo") 70 | 71 | def test_canonical_name(self, lib_mock): 72 | lib_mock.sp_user_canonical_name.return_value = spotify.ffi.new( 73 | "char[]", b"alicefoobar" 74 | ) 75 | sp_user = spotify.ffi.cast("sp_user *", 42) 76 | user = spotify.User(self.session, sp_user=sp_user) 77 | 78 | result = user.canonical_name 79 | 80 | lib_mock.sp_user_canonical_name.assert_called_once_with(sp_user) 81 | self.assertEqual(result, "alicefoobar") 82 | 83 | def test_display_name(self, lib_mock): 84 | lib_mock.sp_user_display_name.return_value = spotify.ffi.new( 85 | "char[]", b"Alice Foobar" 86 | ) 87 | sp_user = spotify.ffi.cast("sp_user *", 42) 88 | user = spotify.User(self.session, sp_user=sp_user) 89 | 90 | result = user.display_name 91 | 92 | lib_mock.sp_user_display_name.assert_called_once_with(sp_user) 93 | self.assertEqual(result, "Alice Foobar") 94 | 95 | def test_is_loaded(self, lib_mock): 96 | lib_mock.sp_user_is_loaded.return_value = 1 97 | sp_user = spotify.ffi.cast("sp_user *", 42) 98 | user = spotify.User(self.session, sp_user=sp_user) 99 | 100 | result = user.is_loaded 101 | 102 | lib_mock.sp_user_is_loaded.assert_called_once_with(sp_user) 103 | self.assertTrue(result) 104 | 105 | @mock.patch("spotify.utils.load") 106 | def test_load(self, load_mock, lib_mock): 107 | sp_user = spotify.ffi.cast("sp_user *", 42) 108 | user = spotify.User(self.session, sp_user=sp_user) 109 | 110 | user.load(10) 111 | 112 | load_mock.assert_called_with(self.session, user, timeout=10) 113 | 114 | @mock.patch("spotify.Link", spec=spotify.Link) 115 | def test_link_creates_link_to_user(self, link_mock, lib_mock): 116 | sp_user = spotify.ffi.cast("sp_user *", 42) 117 | user = spotify.User(self.session, sp_user=sp_user) 118 | sp_link = spotify.ffi.cast("sp_link *", 43) 119 | lib_mock.sp_link_create_from_user.return_value = sp_link 120 | link_mock.return_value = mock.sentinel.link 121 | 122 | result = user.link 123 | 124 | link_mock.assert_called_once_with(self.session, sp_link=sp_link, add_ref=False) 125 | self.assertEqual(result, mock.sentinel.link) 126 | 127 | def test_starred(self, lib_mock): 128 | self.session.get_starred.return_value = mock.sentinel.playlist 129 | lib_mock.sp_user_canonical_name.return_value = spotify.ffi.new( 130 | "char[]", b"alice" 131 | ) 132 | sp_user = spotify.ffi.cast("sp_user *", 42) 133 | user = spotify.User(self.session, sp_user=sp_user) 134 | 135 | result = user.starred 136 | 137 | self.session.get_starred.assert_called_with("alice") 138 | self.assertEqual(result, mock.sentinel.playlist) 139 | 140 | def test_published_playlists(self, lib_mock): 141 | self.session.get_published_playlists.return_value = ( 142 | mock.sentinel.playlist_container 143 | ) 144 | lib_mock.sp_user_canonical_name.return_value = spotify.ffi.new( 145 | "char[]", b"alice" 146 | ) 147 | sp_user = spotify.ffi.cast("sp_user *", 42) 148 | user = spotify.User(self.session, sp_user=sp_user) 149 | 150 | result = user.published_playlists 151 | 152 | self.session.get_published_playlists.assert_called_with("alice") 153 | self.assertEqual(result, mock.sentinel.playlist_container) 154 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pyspotify.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pyspotify.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pyspotify" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pyspotify" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /spotify/image.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import base64 4 | import logging 5 | import threading 6 | 7 | import spotify 8 | from spotify import ffi, lib, serialized, utils 9 | 10 | __all__ = ["Image", "ImageFormat", "ImageSize"] 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Image(object): 16 | 17 | """A Spotify image. 18 | 19 | You can get images from :meth:`Album.cover`, :meth:`Artist.portrait`, 20 | :meth:`Playlist.image`, or you can create an :class:`Image` yourself from a 21 | Spotify URI:: 22 | 23 | >>> session = spotify.Session() 24 | # ... 25 | >>> image = session.get_image( 26 | ... 'spotify:image:a0bdcbe11b5cd126968e519b5ed1050b0e8183d0') 27 | >>> image.load().data_uri[:50] 28 | u'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEBLAEsAAD' 29 | 30 | If ``callback`` isn't :class:`None`, it is expected to be a callable 31 | that accepts a single argument, an :class:`Image` instance, when 32 | the image is done loading. 33 | """ 34 | 35 | def __init__(self, session, uri=None, sp_image=None, add_ref=True, callback=None): 36 | 37 | assert uri or sp_image, "uri or sp_image is required" 38 | 39 | self._session = session 40 | 41 | if uri is not None: 42 | image = spotify.Link(self._session, uri=uri).as_image() 43 | if image is None: 44 | raise ValueError("Failed to get image from Spotify URI: %r" % uri) 45 | sp_image = image._sp_image 46 | add_ref = True 47 | 48 | if add_ref: 49 | lib.sp_image_add_ref(sp_image) 50 | self._sp_image = ffi.gc(sp_image, lib.sp_image_release) 51 | 52 | self.loaded_event = threading.Event() 53 | 54 | handle = ffi.new_handle((self._session, self, callback)) 55 | self._session._callback_handles.add(handle) 56 | spotify.Error.maybe_raise( 57 | lib.sp_image_add_load_callback(self._sp_image, _image_load_callback, handle) 58 | ) 59 | 60 | def __repr__(self): 61 | return "Image(%r)" % self.link.uri 62 | 63 | def __eq__(self, other): 64 | if isinstance(other, self.__class__): 65 | return self._sp_image == other._sp_image 66 | else: 67 | return False 68 | 69 | def __ne__(self, other): 70 | return not self.__eq__(other) 71 | 72 | def __hash__(self): 73 | return hash(self._sp_image) 74 | 75 | loaded_event = None 76 | """:class:`threading.Event` that is set when the image is loaded.""" 77 | 78 | @property 79 | def is_loaded(self): 80 | """Whether the image's data is loaded.""" 81 | return bool(lib.sp_image_is_loaded(self._sp_image)) 82 | 83 | @property 84 | def error(self): 85 | """An :class:`ErrorType` associated with the image. 86 | 87 | Check to see if there was problems loading the image. 88 | """ 89 | return spotify.ErrorType(lib.sp_image_error(self._sp_image)) 90 | 91 | def load(self, timeout=None): 92 | """Block until the image's data is loaded. 93 | 94 | After ``timeout`` seconds with no results :exc:`~spotify.Timeout` is 95 | raised. If ``timeout`` is :class:`None` the default timeout is used. 96 | 97 | The method returns ``self`` to allow for chaining of calls. 98 | """ 99 | return utils.load(self._session, self, timeout=timeout) 100 | 101 | @property 102 | def format(self): 103 | """The :class:`ImageFormat` of the image. 104 | 105 | Will always return :class:`None` if the image isn't loaded. 106 | """ 107 | if not self.is_loaded: 108 | return None 109 | return ImageFormat(lib.sp_image_format(self._sp_image)) 110 | 111 | @property 112 | @serialized 113 | def data(self): 114 | """The raw image data as a bytestring. 115 | 116 | Will always return :class:`None` if the image isn't loaded. 117 | """ 118 | if not self.is_loaded: 119 | return None 120 | data_size_ptr = ffi.new("size_t *") 121 | data = lib.sp_image_data(self._sp_image, data_size_ptr) 122 | buffer_ = ffi.buffer(data, data_size_ptr[0]) 123 | data_bytes = buffer_[:] 124 | assert len(data_bytes) == data_size_ptr[0], "%r == %r" % ( 125 | len(data_bytes), 126 | data_size_ptr[0], 127 | ) 128 | return data_bytes 129 | 130 | @property 131 | def data_uri(self): 132 | """The raw image data as a data: URI. 133 | 134 | Will always return :class:`None` if the image isn't loaded. 135 | """ 136 | if not self.is_loaded: 137 | return None 138 | if self.format is not ImageFormat.JPEG: 139 | raise ValueError("Unknown image format: %r" % self.format) 140 | return "data:image/jpeg;base64,%s" % ( 141 | base64.b64encode(self.data).decode("ascii") 142 | ) 143 | 144 | @property 145 | def link(self): 146 | """A :class:`Link` to the image.""" 147 | return spotify.Link( 148 | self._session, 149 | sp_link=lib.sp_link_create_from_image(self._sp_image), 150 | add_ref=False, 151 | ) 152 | 153 | 154 | @ffi.callback("void(sp_image *, void *)") 155 | @serialized 156 | def _image_load_callback(sp_image, handle): 157 | logger.debug("image_load_callback called") 158 | if handle == ffi.NULL: 159 | logger.warning("pyspotify image_load_callback called without userdata") 160 | return 161 | (session, image, callback) = ffi.from_handle(handle) 162 | session._callback_handles.remove(handle) 163 | image.loaded_event.set() 164 | if callback is not None: 165 | callback(image) 166 | 167 | # Load callbacks are by nature only called once per image, so we clean up 168 | # and remove the load callback the first time it is called. 169 | lib.sp_image_remove_load_callback(sp_image, _image_load_callback, handle) 170 | 171 | 172 | @utils.make_enum("SP_IMAGE_FORMAT_") 173 | class ImageFormat(utils.IntEnum): 174 | pass 175 | 176 | 177 | @utils.make_enum("SP_IMAGE_SIZE_") 178 | class ImageSize(utils.IntEnum): 179 | pass 180 | -------------------------------------------------------------------------------- /tests/test_playlist_track.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | import tests 7 | from tests import mock 8 | 9 | 10 | @mock.patch("spotify.playlist_track.lib", spec=spotify.lib) 11 | class PlaylistTrackTest(unittest.TestCase): 12 | def setUp(self): 13 | self.session = tests.create_session_mock() 14 | 15 | @mock.patch("spotify.track.lib", spec=spotify.lib) 16 | def test_track(self, track_lib_mock, lib_mock): 17 | sp_track = spotify.ffi.cast("sp_track *", 43) 18 | lib_mock.sp_playlist_track.return_value = sp_track 19 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 20 | playlist_track = spotify.PlaylistTrack(self.session, sp_playlist, 0) 21 | 22 | result = playlist_track.track 23 | 24 | lib_mock.sp_playlist_track.assert_called_with(sp_playlist, 0) 25 | track_lib_mock.sp_track_add_ref.assert_called_with(sp_track) 26 | self.assertIsInstance(result, spotify.Track) 27 | self.assertEqual(result._sp_track, sp_track) 28 | 29 | @mock.patch("spotify.Track", spec=spotify.Track) 30 | @mock.patch("spotify.User", spec=spotify.User) 31 | def test_repr(self, user_mock, track_mock, lib_mock): 32 | sp_track = spotify.ffi.cast("sp_track *", 43) 33 | lib_mock.sp_playlist_track.return_value = sp_track 34 | track_instance_mock = track_mock.return_value 35 | track_instance_mock.link.uri = "foo" 36 | 37 | lib_mock.sp_playlist_track_create_time.return_value = 1234567890 38 | 39 | sp_user = spotify.ffi.cast("sp_user *", 44) 40 | lib_mock.sp_playlist_track_creator.return_value = sp_user 41 | user_mock.return_value = "alice-user-object" 42 | 43 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 44 | playlist_track = spotify.PlaylistTrack(self.session, sp_playlist, 0) 45 | 46 | result = repr(playlist_track) 47 | 48 | self.assertEqual( 49 | result, 50 | "PlaylistTrack(uri=%r, creator=%r, create_time=%d)" 51 | % ("foo", "alice-user-object", 1234567890), 52 | ) 53 | 54 | def test_eq(self, lib_mock): 55 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 56 | track1 = spotify.PlaylistTrack(self.session, sp_playlist, 0) 57 | track2 = spotify.PlaylistTrack(self.session, sp_playlist, 0) 58 | 59 | self.assertTrue(track1 == track2) 60 | self.assertFalse(track1 == "foo") 61 | 62 | def test_ne(self, lib_mock): 63 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 64 | track1 = spotify.PlaylistTrack(self.session, sp_playlist, 0) 65 | track2 = spotify.PlaylistTrack(self.session, sp_playlist, 0) 66 | track3 = spotify.PlaylistTrack(self.session, sp_playlist, 1) 67 | 68 | self.assertFalse(track1 != track2) 69 | self.assertTrue(track1 != track3) 70 | 71 | def test_hash(self, lib_mock): 72 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 73 | track1 = spotify.PlaylistTrack(self.session, sp_playlist, 0) 74 | track2 = spotify.PlaylistTrack(self.session, sp_playlist, 0) 75 | track3 = spotify.PlaylistTrack(self.session, sp_playlist, 1) 76 | 77 | self.assertEqual(hash(track1), hash(track2)) 78 | self.assertNotEqual(hash(track1), hash(track3)) 79 | 80 | def test_create_time(self, lib_mock): 81 | lib_mock.sp_playlist_track_create_time.return_value = 1234567890 82 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 83 | playlist_track = spotify.PlaylistTrack(self.session, sp_playlist, 0) 84 | 85 | result = playlist_track.create_time 86 | 87 | lib_mock.sp_playlist_track_create_time.assert_called_with(sp_playlist, 0) 88 | self.assertEqual(result, 1234567890) 89 | 90 | @mock.patch("spotify.user.lib", spec=spotify.lib) 91 | def test_creator(self, user_lib_mock, lib_mock): 92 | sp_user = spotify.ffi.cast("sp_user *", 43) 93 | lib_mock.sp_playlist_track_creator.return_value = sp_user 94 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 95 | playlist_track = spotify.PlaylistTrack(self.session, sp_playlist, 0) 96 | 97 | result = playlist_track.creator 98 | 99 | lib_mock.sp_playlist_track_creator.assert_called_with(sp_playlist, 0) 100 | user_lib_mock.sp_user_add_ref.assert_called_with(sp_user) 101 | self.assertIsInstance(result, spotify.User) 102 | self.assertEqual(result._sp_user, sp_user) 103 | 104 | def test_is_seen(self, lib_mock): 105 | lib_mock.sp_playlist_track_seen.return_value = 0 106 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 107 | playlist_track = spotify.PlaylistTrack(self.session, sp_playlist, 0) 108 | 109 | result = playlist_track.seen 110 | 111 | lib_mock.sp_playlist_track_seen.assert_called_with(sp_playlist, 0) 112 | self.assertEqual(result, False) 113 | 114 | def test_set_seen(self, lib_mock): 115 | lib_mock.sp_playlist_track_set_seen.return_value = int(spotify.ErrorType.OK) 116 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 117 | playlist_track = spotify.PlaylistTrack(self.session, sp_playlist, 0) 118 | 119 | playlist_track.seen = True 120 | 121 | lib_mock.sp_playlist_track_set_seen.assert_called_with(sp_playlist, 0, 1) 122 | 123 | def test_set_seen_fails_if_error(self, lib_mock): 124 | lib_mock.sp_playlist_track_set_seen.return_value = int( 125 | spotify.ErrorType.BAD_API_VERSION 126 | ) 127 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 128 | playlist_track = spotify.PlaylistTrack(self.session, sp_playlist, 0) 129 | 130 | with self.assertRaises(spotify.Error): 131 | playlist_track.seen = True 132 | 133 | def test_message(self, lib_mock): 134 | lib_mock.sp_playlist_track_message.return_value = spotify.ffi.new( 135 | "char[]", b"foo bar" 136 | ) 137 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 138 | playlist_track = spotify.PlaylistTrack(self.session, sp_playlist, 0) 139 | 140 | result = playlist_track.message 141 | 142 | lib_mock.sp_playlist_track_message.assert_called_with(sp_playlist, 0) 143 | self.assertEqual(result, "foo bar") 144 | 145 | def test_message_is_none_when_null(self, lib_mock): 146 | lib_mock.sp_playlist_track_message.return_value = spotify.ffi.NULL 147 | sp_playlist = spotify.ffi.cast("sp_playlist *", 42) 148 | playlist_track = spotify.PlaylistTrack(self.session, sp_playlist, 0) 149 | 150 | result = playlist_track.message 151 | 152 | lib_mock.sp_playlist_track_message.assert_called_with(sp_playlist, 0) 153 | self.assertIsNone(result) 154 | -------------------------------------------------------------------------------- /tests/test_player.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | import tests 7 | from tests import mock 8 | 9 | 10 | @mock.patch("spotify.player.lib", spec=spotify.lib) 11 | @mock.patch("spotify.session.lib", spec=spotify.lib) 12 | class PlayerTest(unittest.TestCase): 13 | def tearDown(self): 14 | spotify._session_instance = None 15 | 16 | def test_player_state_is_unloaded_initially(self, session_lib_mock, lib_mock): 17 | session = tests.create_real_session(session_lib_mock) 18 | 19 | self.assertEqual(session.player.state, spotify.PlayerState.UNLOADED) 20 | 21 | @mock.patch("spotify.track.lib", spec=spotify.lib) 22 | def test_player_load(self, track_lib_mock, session_lib_mock, lib_mock): 23 | lib_mock.sp_session_player_load.return_value = spotify.ErrorType.OK 24 | session = tests.create_real_session(session_lib_mock) 25 | sp_track = spotify.ffi.cast("sp_track *", 42) 26 | track = spotify.Track(session, sp_track=sp_track) 27 | 28 | session.player.load(track) 29 | 30 | lib_mock.sp_session_player_load.assert_called_once_with( 31 | session._sp_session, sp_track 32 | ) 33 | self.assertEqual(session.player.state, spotify.PlayerState.LOADED) 34 | 35 | @mock.patch("spotify.track.lib", spec=spotify.lib) 36 | def test_player_load_fail_raises_error( 37 | self, track_lib_mock, session_lib_mock, lib_mock 38 | ): 39 | lib_mock.sp_session_player_load.return_value = ( 40 | spotify.ErrorType.TRACK_NOT_PLAYABLE 41 | ) 42 | session = tests.create_real_session(session_lib_mock) 43 | player_state = session.player.state 44 | sp_track = spotify.ffi.cast("sp_track *", 42) 45 | track = spotify.Track(session, sp_track=sp_track) 46 | 47 | with self.assertRaises(spotify.Error): 48 | session.player.load(track) 49 | self.assertEqual(session.player.state, player_state) 50 | 51 | def test_player_seek(self, session_lib_mock, lib_mock): 52 | lib_mock.sp_session_player_seek.return_value = spotify.ErrorType.OK 53 | session = tests.create_real_session(session_lib_mock) 54 | player_state = session.player.state 55 | 56 | session.player.seek(45000) 57 | 58 | lib_mock.sp_session_player_seek.assert_called_once_with( 59 | session._sp_session, 45000 60 | ) 61 | self.assertEqual(session.player.state, player_state) 62 | 63 | def test_player_seek_fail_raises_error(self, session_lib_mock, lib_mock): 64 | lib_mock.sp_session_player_seek.return_value = spotify.ErrorType.BAD_API_VERSION 65 | session = tests.create_real_session(session_lib_mock) 66 | player_state = session.player.state 67 | 68 | with self.assertRaises(spotify.Error): 69 | session.player.seek(45000) 70 | self.assertEqual(session.player.state, player_state) 71 | 72 | def test_player_play(self, session_lib_mock, lib_mock): 73 | lib_mock.sp_session_player_play.return_value = spotify.ErrorType.OK 74 | session = tests.create_real_session(session_lib_mock) 75 | 76 | session.player.play(True) 77 | 78 | lib_mock.sp_session_player_play.assert_called_once_with(session._sp_session, 1) 79 | self.assertEqual(session.player.state, spotify.PlayerState.PLAYING) 80 | 81 | def test_player_play_with_false_to_pause(self, session_lib_mock, lib_mock): 82 | lib_mock.sp_session_player_play.return_value = spotify.ErrorType.OK 83 | session = tests.create_real_session(session_lib_mock) 84 | 85 | session.player.play(False) 86 | 87 | lib_mock.sp_session_player_play.assert_called_once_with(session._sp_session, 0) 88 | self.assertEqual(session.player.state, spotify.PlayerState.PAUSED) 89 | 90 | def test_player_play_fail_raises_error(self, session_lib_mock, lib_mock): 91 | lib_mock.sp_session_player_play.return_value = spotify.ErrorType.BAD_API_VERSION 92 | session = tests.create_real_session(session_lib_mock) 93 | player_state = session.player.state 94 | 95 | with self.assertRaises(spotify.Error): 96 | session.player.play(True) 97 | self.assertEqual(session.player.state, player_state) 98 | 99 | def test_player_pause(self, session_lib_mock, lib_mock): 100 | lib_mock.sp_session_player_play.return_value = spotify.ErrorType.OK 101 | session = tests.create_real_session(session_lib_mock) 102 | 103 | session.player.pause() 104 | 105 | lib_mock.sp_session_player_play.assert_called_once_with(session._sp_session, 0) 106 | self.assertEqual(session.player.state, spotify.PlayerState.PAUSED) 107 | 108 | def test_player_unload(self, session_lib_mock, lib_mock): 109 | lib_mock.sp_session_player_unload.return_value = spotify.ErrorType.OK 110 | session = tests.create_real_session(session_lib_mock) 111 | 112 | session.player.unload() 113 | 114 | lib_mock.sp_session_player_unload.assert_called_once_with(session._sp_session) 115 | self.assertEqual(session.player.state, spotify.PlayerState.UNLOADED) 116 | 117 | def test_player_unload_fail_raises_error(self, session_lib_mock, lib_mock): 118 | lib_mock.sp_session_player_unload.return_value = ( 119 | spotify.ErrorType.BAD_API_VERSION 120 | ) 121 | session = tests.create_real_session(session_lib_mock) 122 | player_state = session.player.state 123 | 124 | with self.assertRaises(spotify.Error): 125 | session.player.unload() 126 | self.assertEqual(session.player.state, player_state) 127 | 128 | @mock.patch("spotify.track.lib", spec=spotify.lib) 129 | def test_player_prefetch(self, track_lib_mock, session_lib_mock, lib_mock): 130 | lib_mock.sp_session_player_prefetch.return_value = spotify.ErrorType.OK 131 | session = tests.create_real_session(session_lib_mock) 132 | sp_track = spotify.ffi.cast("sp_track *", 42) 133 | track = spotify.Track(session, sp_track=sp_track) 134 | 135 | session.player.prefetch(track) 136 | 137 | lib_mock.sp_session_player_prefetch.assert_called_once_with( 138 | session._sp_session, sp_track 139 | ) 140 | 141 | @mock.patch("spotify.track.lib", spec=spotify.lib) 142 | def test_player_prefetch_fail_raises_error( 143 | self, track_lib_mock, session_lib_mock, lib_mock 144 | ): 145 | lib_mock.sp_session_player_prefetch.return_value = spotify.ErrorType.NO_CACHE 146 | session = tests.create_real_session(session_lib_mock) 147 | sp_track = spotify.ffi.cast("sp_track *", 42) 148 | track = spotify.Track(session, sp_track=sp_track) 149 | 150 | with self.assertRaises(spotify.Error): 151 | session.player.prefetch(track) 152 | -------------------------------------------------------------------------------- /spotify/sink.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import sys 4 | 5 | import spotify 6 | 7 | __all__ = ["AlsaSink", "PortAudioSink"] 8 | 9 | 10 | class Sink(object): 11 | def on(self): 12 | """Turn on the audio sink. 13 | 14 | This is done automatically when the sink is instantiated, so you'll 15 | only need to call this method if you ever call :meth:`off` and want to 16 | turn the sink back on. 17 | """ 18 | assert self._session.num_listeners(spotify.SessionEvent.MUSIC_DELIVERY) == 0 19 | self._session.on(spotify.SessionEvent.MUSIC_DELIVERY, self._on_music_delivery) 20 | 21 | def off(self): 22 | """Turn off the audio sink. 23 | 24 | This disconnects the sink from the relevant session events. 25 | """ 26 | self._session.off(spotify.SessionEvent.MUSIC_DELIVERY, self._on_music_delivery) 27 | assert self._session.num_listeners(spotify.SessionEvent.MUSIC_DELIVERY) == 0 28 | self._close() 29 | 30 | def _on_music_delivery(self, session, audio_format, frames, num_frames): 31 | # This method is called from an internal libspotify thread and must 32 | # not block in any way. 33 | raise NotImplementedError 34 | 35 | def _close(self): 36 | pass 37 | 38 | 39 | class AlsaSink(Sink): 40 | 41 | """Audio sink for systems using ALSA, e.g. most Linux systems. 42 | 43 | This audio sink requires `pyalsaaudio 44 | `_. pyalsaaudio is probably 45 | packaged in your Linux distribution. 46 | 47 | For example, on Debian/Ubuntu you can install it from APT:: 48 | 49 | sudo apt-get install python-alsaaudio 50 | 51 | Or, if you want to install pyalsaaudio inside a virtualenv, install the 52 | ALSA development headers from APT, then pyalsaaudio:: 53 | 54 | sudo apt-get install libasound2-dev 55 | pip install pyalsaaudio 56 | 57 | The ``device`` keyword argument is passed on to :class:`alsaaudio.PCM`. 58 | Please refer to the pyalsaaudio documentation for details. 59 | 60 | Example:: 61 | 62 | >>> import spotify 63 | >>> session = spotify.Session() 64 | >>> audio = spotify.AlsaSink(session) 65 | >>> loop = spotify.EventLoop(session) 66 | >>> loop.start() 67 | # Login, etc... 68 | >>> track = session.get_track('spotify:track:3N2UhXZI4Gf64Ku3cCjz2g') 69 | >>> track.load() 70 | >>> session.player.load(track) 71 | >>> session.player.play() 72 | # Listen to music... 73 | """ 74 | 75 | def __init__(self, session, device="default"): 76 | self._session = session 77 | self._device_name = device 78 | 79 | import alsaaudio # Crash early if not available 80 | 81 | self._alsaaudio = alsaaudio 82 | self._device = None 83 | 84 | self.on() 85 | 86 | def _on_music_delivery(self, session, audio_format, frames, num_frames): 87 | assert audio_format.sample_type == spotify.SampleType.INT16_NATIVE_ENDIAN 88 | 89 | if self._device is None: 90 | if hasattr(self._alsaaudio, "pcms"): # pyalsaaudio >= 0.8 91 | self._device = self._alsaaudio.PCM( 92 | mode=self._alsaaudio.PCM_NONBLOCK, device=self._device_name 93 | ) 94 | else: # pyalsaaudio == 0.7 95 | self._device = self._alsaaudio.PCM( 96 | mode=self._alsaaudio.PCM_NONBLOCK, card=self._device_name 97 | ) 98 | if sys.byteorder == "little": 99 | self._device.setformat(self._alsaaudio.PCM_FORMAT_S16_LE) 100 | else: 101 | self._device.setformat(self._alsaaudio.PCM_FORMAT_S16_BE) 102 | self._device.setrate(audio_format.sample_rate) 103 | self._device.setchannels(audio_format.channels) 104 | self._device.setperiodsize(num_frames * audio_format.frame_size()) 105 | 106 | return self._device.write(frames) 107 | 108 | def _close(self): 109 | if self._device is not None: 110 | self._device.close() 111 | self._device = None 112 | 113 | 114 | class PortAudioSink(Sink): 115 | 116 | """Audio sink for `PortAudio `_. 117 | 118 | PortAudio is available for many platforms, including Linux, macOS, and 119 | Windows. This audio sink requires `PyAudio 120 | `_. PyAudio is probably packaged in 121 | your Linux distribution. 122 | 123 | On Debian/Ubuntu you can install PyAudio from APT:: 124 | 125 | sudo apt-get install python-pyaudio 126 | 127 | Or, if you want to install PyAudio inside a virtualenv, install the 128 | PortAudio development headers from APT, then PyAudio:: 129 | 130 | sudo apt-get install portaudio19-dev 131 | pip install --allow-unverified=pyaudio pyaudio 132 | 133 | On macOS you can install PortAudio using Homebrew:: 134 | 135 | brew install portaudio 136 | pip install --allow-unverified=pyaudio pyaudio 137 | 138 | For an example of how to use this class, see the :class:`AlsaSink` example. 139 | Just replace ``AlsaSink`` with ``PortAudioSink``. 140 | """ 141 | 142 | def __init__(self, session): 143 | self._session = session 144 | 145 | import pyaudio # Crash early if not available 146 | 147 | self._pyaudio = pyaudio 148 | self._device = self._pyaudio.PyAudio() 149 | self._stream = None 150 | 151 | self.on() 152 | 153 | def _on_music_delivery(self, session, audio_format, frames, num_frames): 154 | assert audio_format.sample_type == spotify.SampleType.INT16_NATIVE_ENDIAN 155 | 156 | if self._stream is None: 157 | self._stream = self._device.open( 158 | format=self._pyaudio.paInt16, 159 | channels=audio_format.channels, 160 | rate=audio_format.sample_rate, 161 | output=True, 162 | ) 163 | 164 | # XXX write() is a blocking call. There are two non-blocking 165 | # alternatives: 166 | # 1) Only feed write() with the number of frames returned by 167 | # self._stream.get_write_available() on each call. This causes buffer 168 | # underruns every third or fourth write(). 169 | # 2) Let pyaudio call a callback function when it needs data, but then 170 | # we need to introduce a thread safe buffer here which is filled when 171 | # libspotify got data and drained when pyaudio needs data. 172 | self._stream.write(frames, num_frames=num_frames) 173 | return num_frames 174 | 175 | def _close(self): 176 | if self._stream is not None: 177 | self._stream.close() 178 | self._stream = None 179 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Installation 3 | ************ 4 | 5 | pyspotify is packaged for various operating systems and in multiple Linux 6 | distributions. What way to install pyspotify is best for you depends upon your 7 | OS and/or distribution. 8 | 9 | 10 | .. _debian-install: 11 | 12 | Debian/Ubuntu: Install from apt.mopidy.com 13 | ========================================== 14 | 15 | The `Mopidy `_ project runs its own APT archive which 16 | includes pyspotify built for: 17 | 18 | - Debian 9 (Stretch), which also works for Ubuntu 18.04 LTS. 19 | - Debian 10 (Buster), which also works for Ubuntu 19.10 and newer. 20 | 21 | The packages are available for multiple CPU architectures: i386, amd64, armel, 22 | and armhf (compatible with Raspbian and all Raspberry Pi models). 23 | 24 | To install and receive future updates: 25 | 26 | 1. Add the archive's GPG key:: 27 | 28 | wget -q -O - https://apt.mopidy.com/mopidy.gpg | sudo apt-key add - 29 | 30 | 2. If you run Debian stretch or Ubuntu 16.04 LTS:: 31 | 32 | sudo wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/stretch.list 33 | 34 | Or, if you run any newer Debian/Ubuntu distro:: 35 | 36 | sudo wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/buster.list 37 | 38 | 3. Install pyspotify and all dependencies:: 39 | 40 | sudo apt-get update 41 | sudo apt-get install python-spotify 42 | 43 | 44 | Arch Linux: Install from AUR 45 | ============================ 46 | 47 | If you are running Arch Linux on x86 or x86_64, you can install pyspotify using 48 | the `python2-pyspotify package 49 | `_ found in AUR. 50 | 51 | 1. To install pyspotify with all dependencies, run:: 52 | 53 | yay -S python2-pyspotify 54 | 55 | .. note:: 56 | 57 | AUR does not provide libspotify for all CPU architectures e.g. arm. See 58 | :ref:`installing from source ` in these cases. 59 | 60 | 61 | macOS: Install wheel package from PyPI with pip 62 | =============================================== 63 | 64 | From PyPI, you can install precompiled wheel packages of pyspotify that bundle 65 | libspotify. The packages should work on all combinations of: 66 | 67 | - macOS 10.6 and newer 68 | - 32-bit and 64-bit 69 | - Apple-Python, Python.org-Python, Homebrew-Python 70 | 71 | 1. Make sure you have a recent version of pip, which will default to installing 72 | a wheel package if available:: 73 | 74 | pip install --upgrade pip 75 | 76 | 2. Install pyspotify:: 77 | 78 | pip install pyspotify 79 | 80 | 81 | macOS: Install from Homebrew 82 | ============================ 83 | 84 | The `Mopidy `__ project maintains its own `Homebrew 85 | tap `_ which includes pyspotify and 86 | its dependencies. 87 | 88 | 1. Install `Homebrew `_. 89 | 90 | 2. Make sure your installation is up to date:: 91 | 92 | brew update 93 | brew upgrade --all 94 | 95 | 3. Install pyspotify from the mopidy/mopidy tap:: 96 | 97 | brew install mopidy/mopidy/pyspotify 98 | 99 | 100 | .. _source-install: 101 | 102 | Install from source 103 | =================== 104 | 105 | If you are on Linux, but your distro don't package pyspotify, you can install 106 | pyspotify from PyPI using the pip installer. However, since pyspotify is a 107 | Python wrapper around the libspotify library, pyspotify necessarily depends on 108 | libspotify and it must be installed first. 109 | 110 | 111 | libspotify 112 | ---------- 113 | 114 | libspotify is provided as a binary download for a selection of operating 115 | systems and CPU architectures from our `unofficial libspotify archive 116 | `__. If libspotify 117 | isn't available for your OS or architecture, then you're out of luck and can't 118 | use pyspotify either. 119 | 120 | To install libspotify, use one of the options below, or follow the instructions 121 | in the README file of the libspotify tarball. 122 | 123 | 124 | Debian/Ubuntu 125 | ~~~~~~~~~~~~~ 126 | 127 | If you're running a Debian-based Linux distribution, like Ubuntu, 128 | you can get Debian packages of libspotify from `apt.mopidy.com 129 | `__. Follow the instructions :ref:`above 130 | ` to make the apt.mopidy.com archive available on your system, 131 | then install libspotify:: 132 | 133 | sudo apt-get install libspotify-dev 134 | 135 | 136 | Arch Linux 137 | ~~~~~~~~~~ 138 | 139 | libspotify for x86 and x86_64 is packaged in `AUR 140 | `_. To install libspotify, 141 | run:: 142 | 143 | yay -S libspotify 144 | 145 | .. note:: 146 | 147 | AUR only provides libspotify binaries for x86 and x86_64 CPUs. If you 148 | require libspotify for a different CPU architecture you'll need to download 149 | it from our `unofficial libspotify archive 150 | `__ instead. 151 | 152 | 153 | macOS 154 | ~~~~~ 155 | 156 | If you're using `Homebrew `_, it has a formula for 157 | libspotify in the mopidy/mopidy tap:: 158 | 159 | brew install mopidy/mopidy/libspotify 160 | 161 | 162 | Build tools 163 | ----------- 164 | 165 | To build pyspotify, you need a C compiler, Python development headers, and 166 | libffi development headers. All of this is easily installed using your system's 167 | package manager. 168 | 169 | 170 | Debian/Ubuntu 171 | ~~~~~~~~~~~~~ 172 | 173 | If you're on a Debian-based system, you can install the pyspotify build 174 | dependencies by running:: 175 | 176 | sudo apt install build-essential python-dev python3-dev libffi-dev 177 | 178 | 179 | Arch Linux 180 | ~~~~~~~~~~ 181 | 182 | If you're on Arch Linux, you can install the pyspotify build dependencies by 183 | running:: 184 | 185 | sudo pacman -S base-devel python2 python 186 | 187 | 188 | macOS 189 | ~~~~~ 190 | 191 | If you're on macOS, you'll need to install the Xcode command line developer 192 | tools. Even if you've already installed Xcode from the App Store, e.g. to get 193 | Homebrew working, you should run this command:: 194 | 195 | xcode-select --install 196 | 197 | .. note:: 198 | 199 | If you get an error about ``ffi.h`` not being found when installing the 200 | cffi Python package, try running the above command. 201 | 202 | 203 | pyspotify 204 | --------- 205 | 206 | With libspotify and the build tools in place, you can finally build pyspotify. 207 | 208 | To download and build pyspotify from PyPI, run:: 209 | 210 | pip install pyspotify 211 | 212 | Or, if you have a checkout of the pyspotify git repo, run:: 213 | 214 | pip install -e path/to/my/pyspotify/git/clone 215 | 216 | Once you have pyspotify installed, you should head over to :doc:`quickstart` 217 | for a short introduction to pyspotify. 218 | -------------------------------------------------------------------------------- /tests/test_playlist_unseen_tracks.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | import tests 7 | from tests import mock 8 | 9 | 10 | @mock.patch("spotify.playlist_unseen_tracks.lib", spec=spotify.lib) 11 | class PlaylistUnseenTracksTest(unittest.TestCase): 12 | 13 | # TODO Test that the collection releases sp_playlistcontainer and 14 | # sp_playlist when no longer referenced. 15 | 16 | def setUp(self): 17 | self.session = tests.create_session_mock() 18 | 19 | @mock.patch("spotify.track.lib", spec=spotify.lib) 20 | def test_normal_usage(self, track_lib_mock, lib_mock): 21 | sp_playlistcontainer = spotify.ffi.cast("sp_playlistcontainer *", 42) 22 | sp_playlist = spotify.ffi.cast("sp_playlist *", 43) 23 | 24 | total_num_tracks = 3 25 | sp_tracks = [ 26 | spotify.ffi.cast("sp_track *", 44 + i) for i in range(total_num_tracks) 27 | ] 28 | 29 | def func(sp_pc, sp_p, sp_t, num_t): 30 | for i in range(min(total_num_tracks, num_t)): 31 | sp_t[i] = sp_tracks[i] 32 | return total_num_tracks 33 | 34 | lib_mock.sp_playlistcontainer_get_unseen_tracks.side_effect = func 35 | 36 | tracks = spotify.PlaylistUnseenTracks( 37 | self.session, sp_playlistcontainer, sp_playlist 38 | ) 39 | 40 | # Collection keeps references to container and playlist: 41 | lib_mock.sp_playlistcontainer_add_ref.assert_called_with(sp_playlistcontainer) 42 | lib_mock.sp_playlist_add_ref.assert_called_with(sp_playlist) 43 | 44 | # Getting collection and length causes no tracks to be retrieved: 45 | self.assertEqual(len(tracks), total_num_tracks) 46 | self.assertEqual(lib_mock.sp_playlistcontainer_get_unseen_tracks.call_count, 1) 47 | lib_mock.sp_playlistcontainer_get_unseen_tracks.assert_called_with( 48 | sp_playlistcontainer, sp_playlist, mock.ANY, 0 49 | ) 50 | 51 | # Getting items causes more tracks to be retrieved: 52 | track0 = tracks[0] 53 | self.assertEqual(lib_mock.sp_playlistcontainer_get_unseen_tracks.call_count, 2) 54 | lib_mock.sp_playlistcontainer_get_unseen_tracks.assert_called_with( 55 | sp_playlistcontainer, sp_playlist, mock.ANY, total_num_tracks 56 | ) 57 | self.assertIsInstance(track0, spotify.Track) 58 | self.assertEqual(track0._sp_track, sp_tracks[0]) 59 | 60 | # Getting already retrieved tracks causes no new retrieval: 61 | track1 = tracks[1] 62 | self.assertEqual(lib_mock.sp_playlistcontainer_get_unseen_tracks.call_count, 2) 63 | self.assertIsInstance(track1, spotify.Track) 64 | self.assertEqual(track1._sp_track, sp_tracks[1]) 65 | 66 | # Getting item with negative index 67 | track2 = tracks[-3] 68 | self.assertEqual(track2._sp_track, track0._sp_track) 69 | self.assertEqual(lib_mock.sp_playlistcontainer_get_unseen_tracks.call_count, 2) 70 | 71 | def test_raises_error_on_failure(self, lib_mock): 72 | sp_playlistcontainer = spotify.ffi.cast("sp_playlistcontainer *", 42) 73 | sp_playlist = spotify.ffi.cast("sp_playlist *", 43) 74 | lib_mock.sp_playlistcontainer_get_unseen_tracks.return_value = -3 75 | 76 | with self.assertRaises(spotify.Error): 77 | spotify.PlaylistUnseenTracks( 78 | self.session, sp_playlistcontainer, sp_playlist 79 | ) 80 | 81 | @mock.patch("spotify.track.lib", spec=spotify.lib) 82 | def test_getitem_with_slice(self, track_lib_mock, lib_mock): 83 | sp_playlistcontainer = spotify.ffi.cast("sp_playlistcontainer *", 42) 84 | sp_playlist = spotify.ffi.cast("sp_playlist *", 43) 85 | 86 | total_num_tracks = 3 87 | sp_tracks = [ 88 | spotify.ffi.cast("sp_track *", 44 + i) for i in range(total_num_tracks) 89 | ] 90 | 91 | def func(sp_pc, sp_p, sp_t, num_t): 92 | for i in range(min(total_num_tracks, num_t)): 93 | sp_t[i] = sp_tracks[i] 94 | return total_num_tracks 95 | 96 | lib_mock.sp_playlistcontainer_get_unseen_tracks.side_effect = func 97 | 98 | tracks = spotify.PlaylistUnseenTracks( 99 | self.session, sp_playlistcontainer, sp_playlist 100 | ) 101 | 102 | result = tracks[0:2] 103 | 104 | # Only a subslice of length 2 is returned 105 | self.assertIsInstance(result, list) 106 | self.assertEqual(len(result), 2) 107 | self.assertIsInstance(result[0], spotify.Track) 108 | self.assertEqual(result[0]._sp_track, sp_tracks[0]) 109 | self.assertIsInstance(result[1], spotify.Track) 110 | self.assertEqual(result[1]._sp_track, sp_tracks[1]) 111 | 112 | def test_getitem_raises_index_error_on_too_low_index(self, lib_mock): 113 | sp_playlistcontainer = spotify.ffi.cast("sp_playlistcontainer *", 42) 114 | sp_playlist = spotify.ffi.cast("sp_playlist *", 43) 115 | lib_mock.sp_playlistcontainer_get_unseen_tracks.return_value = 0 116 | tracks = spotify.PlaylistUnseenTracks( 117 | self.session, sp_playlistcontainer, sp_playlist 118 | ) 119 | 120 | with self.assertRaises(IndexError) as ctx: 121 | tracks[-1] 122 | 123 | self.assertEqual(str(ctx.exception), "list index out of range") 124 | 125 | def test_getitem_raises_index_error_on_too_high_index(self, lib_mock): 126 | sp_playlistcontainer = spotify.ffi.cast("sp_playlistcontainer *", 42) 127 | sp_playlist = spotify.ffi.cast("sp_playlist *", 43) 128 | lib_mock.sp_playlistcontainer_get_unseen_tracks.return_value = 0 129 | tracks = spotify.PlaylistUnseenTracks( 130 | self.session, sp_playlistcontainer, sp_playlist 131 | ) 132 | 133 | with self.assertRaises(IndexError) as ctx: 134 | tracks[1] 135 | 136 | self.assertEqual(str(ctx.exception), "list index out of range") 137 | 138 | def test_getitem_raises_type_error_on_non_integral_index(self, lib_mock): 139 | sp_playlistcontainer = spotify.ffi.cast("sp_playlistcontainer *", 42) 140 | sp_playlist = spotify.ffi.cast("sp_playlist *", 43) 141 | lib_mock.sp_playlistcontainer_get_unseen_tracks.return_value = 0 142 | tracks = spotify.PlaylistUnseenTracks( 143 | self.session, sp_playlistcontainer, sp_playlist 144 | ) 145 | 146 | with self.assertRaises(TypeError): 147 | tracks["abc"] 148 | 149 | def test_repr(self, lib_mock): 150 | sp_playlistcontainer = spotify.ffi.cast("sp_playlistcontainer *", 42) 151 | sp_playlist = spotify.ffi.cast("sp_playlist *", 43) 152 | lib_mock.sp_playlistcontainer_get_unseen_tracks.return_value = 0 153 | tracks = spotify.PlaylistUnseenTracks( 154 | self.session, sp_playlistcontainer, sp_playlist 155 | ) 156 | 157 | self.assertEqual(repr(tracks), "PlaylistUnseenTracks([])") 158 | -------------------------------------------------------------------------------- /examples/shell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | This is an example of a simple command line client for Spotify using pyspotify. 5 | 6 | You can run this file directly:: 7 | 8 | python shell.py 9 | 10 | Then run the ``help`` command on the ``spotify>`` prompt to view all available 11 | commands. 12 | """ 13 | 14 | from __future__ import print_function, unicode_literals 15 | 16 | import cmd 17 | import logging 18 | import threading 19 | 20 | import spotify 21 | 22 | 23 | class Commander(cmd.Cmd): 24 | 25 | doc_header = "Commands" 26 | prompt = "spotify> " 27 | 28 | logger = logging.getLogger("shell.commander") 29 | 30 | def __init__(self): 31 | cmd.Cmd.__init__(self) 32 | 33 | self.logged_in = threading.Event() 34 | self.logged_out = threading.Event() 35 | self.logged_out.set() 36 | 37 | self.session = spotify.Session() 38 | self.session.on( 39 | spotify.SessionEvent.CONNECTION_STATE_UPDATED, 40 | self.on_connection_state_changed, 41 | ) 42 | self.session.on(spotify.SessionEvent.END_OF_TRACK, self.on_end_of_track) 43 | 44 | try: 45 | self.audio_driver = spotify.AlsaSink(self.session) 46 | except ImportError: 47 | self.logger.warning("No audio sink found; audio playback unavailable.") 48 | 49 | self.event_loop = spotify.EventLoop(self.session) 50 | self.event_loop.start() 51 | 52 | def on_connection_state_changed(self, session): 53 | if session.connection.state is spotify.ConnectionState.LOGGED_IN: 54 | self.logged_in.set() 55 | self.logged_out.clear() 56 | elif session.connection.state is spotify.ConnectionState.LOGGED_OUT: 57 | self.logged_in.clear() 58 | self.logged_out.set() 59 | 60 | def on_end_of_track(self, session): 61 | self.session.player.play(False) 62 | 63 | def precmd(self, line): 64 | if line: 65 | self.logger.debug("New command: %s", line) 66 | return line 67 | 68 | def emptyline(self): 69 | pass 70 | 71 | def do_debug(self, line): 72 | "Show more logging output" 73 | print("Logging at DEBUG level") 74 | logger = logging.getLogger() 75 | logger.setLevel(logging.DEBUG) 76 | 77 | def do_info(self, line): 78 | "Show normal logging output" 79 | print("Logging at INFO level") 80 | logger = logging.getLogger() 81 | logger.setLevel(logging.INFO) 82 | 83 | def do_warning(self, line): 84 | "Show less logging output" 85 | print("Logging at WARNING level") 86 | logger = logging.getLogger() 87 | logger.setLevel(logging.WARNING) 88 | 89 | def do_EOF(self, line): 90 | "Exit" 91 | if self.logged_in.is_set(): 92 | print("Logging out...") 93 | self.session.logout() 94 | self.logged_out.wait() 95 | self.event_loop.stop() 96 | print("") 97 | return True 98 | 99 | def do_login(self, line): 100 | "login " 101 | tokens = line.split(" ", 1) 102 | if len(tokens) != 2: 103 | self.logger.warning("Wrong number of arguments") 104 | return 105 | username, password = tokens 106 | self.session.login(username, password, remember_me=True) 107 | self.logged_in.wait() 108 | 109 | def do_relogin(self, line): 110 | "relogin -- login as the previous logged in user" 111 | try: 112 | self.session.relogin() 113 | self.logged_in.wait() 114 | except spotify.Error as e: 115 | self.logger.error(e) 116 | 117 | def do_forget_me(self, line): 118 | "forget_me -- forget the previous logged in user" 119 | self.session.forget_me() 120 | 121 | def do_logout(self, line): 122 | "logout" 123 | self.session.logout() 124 | self.logged_out.wait() 125 | 126 | def do_whoami(self, line): 127 | "whoami" 128 | if self.logged_in.is_set(): 129 | self.logger.info( 130 | "I am %s aka %s. You can find me at %s", 131 | self.session.user.canonical_name, 132 | self.session.user.display_name, 133 | self.session.user.link, 134 | ) 135 | else: 136 | self.logger.info( 137 | "I am not logged in, but I may be %s", 138 | self.session.remembered_user, 139 | ) 140 | 141 | def do_play_uri(self, line): 142 | "play " 143 | if not self.logged_in.is_set(): 144 | self.logger.warning("You must be logged in to play") 145 | return 146 | try: 147 | track = self.session.get_track(line) 148 | track.load() 149 | except (ValueError, spotify.Error) as e: 150 | self.logger.warning(e) 151 | return 152 | self.logger.info("Loading track into player") 153 | self.session.player.load(track) 154 | self.logger.info("Playing track") 155 | self.session.player.play() 156 | 157 | def do_pause(self, line): 158 | self.logger.info("Pausing track") 159 | self.session.player.play(False) 160 | 161 | def do_resume(self, line): 162 | self.logger.info("Resuming track") 163 | self.session.player.play() 164 | 165 | def do_stop(self, line): 166 | self.logger.info("Stopping track") 167 | self.session.player.play(False) 168 | self.session.player.unload() 169 | 170 | def do_seek(self, seconds): 171 | "seek " 172 | if not self.logged_in.is_set(): 173 | self.logger.warning("You must be logged in to seek") 174 | return 175 | if self.session.player.state is spotify.PlayerState.UNLOADED: 176 | self.logger.warning("A track must be loaded before seeking") 177 | return 178 | self.session.player.seek(int(seconds) * 1000) 179 | 180 | def do_search(self, query): 181 | "search " 182 | if not self.logged_in.is_set(): 183 | self.logger.warning("You must be logged in to search") 184 | return 185 | try: 186 | result = self.session.search(query) 187 | result.load() 188 | except spotify.Error as e: 189 | self.logger.warning(e) 190 | return 191 | self.logger.info( 192 | "%d tracks, %d albums, %d artists, and %d playlists found.", 193 | result.track_total, 194 | result.album_total, 195 | result.artist_total, 196 | result.playlist_total, 197 | ) 198 | self.logger.info("Top tracks:") 199 | for track in result.tracks: 200 | self.logger.info( 201 | "[%s] %s - %s", track.link, track.artists[0].name, track.name 202 | ) 203 | 204 | 205 | if __name__ == "__main__": 206 | logging.basicConfig(level=logging.INFO) 207 | Commander().cmdloop() 208 | -------------------------------------------------------------------------------- /tests/test_social.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | import tests 7 | from tests import mock 8 | 9 | 10 | @mock.patch("spotify.social.lib", spec=spotify.lib) 11 | @mock.patch("spotify.session.lib", spec=spotify.lib) 12 | class SocialTest(unittest.TestCase): 13 | def tearDown(self): 14 | spotify._session_instance = None 15 | 16 | def test_is_private_session(self, session_lib_mock, lib_mock): 17 | lib_mock.sp_session_is_private_session.return_value = 0 18 | session = tests.create_real_session(session_lib_mock) 19 | 20 | result = session.social.private_session 21 | 22 | lib_mock.sp_session_is_private_session.assert_called_with(session._sp_session) 23 | self.assertFalse(result) 24 | 25 | @mock.patch("spotify.connection.lib", spec=spotify.lib) 26 | def test_set_private_session(self, conn_lib_mock, session_lib_mock, lib_mock): 27 | lib_mock.sp_session_set_private_session.return_value = spotify.ErrorType.OK 28 | session = tests.create_real_session(session_lib_mock) 29 | 30 | session.social.private_session = True 31 | 32 | lib_mock.sp_session_set_private_session.assert_called_with( 33 | session._sp_session, 1 34 | ) 35 | 36 | @mock.patch("spotify.connection.lib", spec=spotify.lib) 37 | def test_set_private_session_fail_raises_error( 38 | self, conn_lib_mock, session_lib_mock, lib_mock 39 | ): 40 | lib_mock.sp_session_set_private_session.return_value = ( 41 | spotify.ErrorType.BAD_API_VERSION 42 | ) 43 | session = tests.create_real_session(session_lib_mock) 44 | 45 | with self.assertRaises(spotify.Error): 46 | session.social.private_session = True 47 | 48 | def test_is_scrobbling(self, session_lib_mock, lib_mock): 49 | def func(sp_session_ptr, sp_social_provider, sp_scrobbling_state_ptr): 50 | sp_scrobbling_state_ptr[0] = spotify.ScrobblingState.USE_GLOBAL_SETTING 51 | return spotify.ErrorType.OK 52 | 53 | lib_mock.sp_session_is_scrobbling.side_effect = func 54 | session = tests.create_real_session(session_lib_mock) 55 | 56 | result = session.social.is_scrobbling(spotify.SocialProvider.SPOTIFY) 57 | 58 | lib_mock.sp_session_is_scrobbling.assert_called_with( 59 | session._sp_session, spotify.SocialProvider.SPOTIFY, mock.ANY 60 | ) 61 | self.assertIs(result, spotify.ScrobblingState.USE_GLOBAL_SETTING) 62 | 63 | def test_is_scrobbling_fail_raises_error(self, session_lib_mock, lib_mock): 64 | lib_mock.sp_session_is_scrobbling.return_value = ( 65 | spotify.ErrorType.BAD_API_VERSION 66 | ) 67 | session = tests.create_real_session(session_lib_mock) 68 | 69 | with self.assertRaises(spotify.Error): 70 | session.social.is_scrobbling(spotify.SocialProvider.SPOTIFY) 71 | 72 | def test_set_scrobbling(self, session_lib_mock, lib_mock): 73 | lib_mock.sp_session_set_scrobbling.return_value = spotify.ErrorType.OK 74 | session = tests.create_real_session(session_lib_mock) 75 | 76 | session.social.set_scrobbling( 77 | spotify.SocialProvider.SPOTIFY, 78 | spotify.ScrobblingState.USE_GLOBAL_SETTING, 79 | ) 80 | 81 | lib_mock.sp_session_set_scrobbling.assert_called_with( 82 | session._sp_session, 83 | spotify.SocialProvider.SPOTIFY, 84 | spotify.ScrobblingState.USE_GLOBAL_SETTING, 85 | ) 86 | 87 | def test_set_scrobbling_fail_raises_error(self, session_lib_mock, lib_mock): 88 | lib_mock.sp_session_set_scrobbling.return_value = ( 89 | spotify.ErrorType.BAD_API_VERSION 90 | ) 91 | session = tests.create_real_session(session_lib_mock) 92 | 93 | with self.assertRaises(spotify.Error): 94 | session.social.set_scrobbling( 95 | spotify.SocialProvider.SPOTIFY, 96 | spotify.ScrobblingState.USE_GLOBAL_SETTING, 97 | ) 98 | 99 | def test_is_scrobbling_possible(self, session_lib_mock, lib_mock): 100 | def func(sp_session_ptr, sp_social_provider, out_ptr): 101 | out_ptr[0] = 1 102 | return spotify.ErrorType.OK 103 | 104 | lib_mock.sp_session_is_scrobbling_possible.side_effect = func 105 | session = tests.create_real_session(session_lib_mock) 106 | 107 | result = session.social.is_scrobbling_possible(spotify.SocialProvider.FACEBOOK) 108 | 109 | lib_mock.sp_session_is_scrobbling_possible.assert_called_with( 110 | session._sp_session, spotify.SocialProvider.FACEBOOK, mock.ANY 111 | ) 112 | self.assertTrue(result) 113 | 114 | def test_is_scrobbling_possible_fail_raises_error(self, session_lib_mock, lib_mock): 115 | lib_mock.sp_session_is_scrobbling_possible.return_value = ( 116 | spotify.ErrorType.BAD_API_VERSION 117 | ) 118 | session = tests.create_real_session(session_lib_mock) 119 | 120 | with self.assertRaises(spotify.Error): 121 | session.social.is_scrobbling_possible(spotify.SocialProvider.FACEBOOK) 122 | 123 | def test_set_social_credentials(self, session_lib_mock, lib_mock): 124 | lib_mock.sp_session_set_social_credentials.return_value = spotify.ErrorType.OK 125 | session = tests.create_real_session(session_lib_mock) 126 | 127 | session.social.set_social_credentials( 128 | spotify.SocialProvider.LASTFM, "alice", "secret" 129 | ) 130 | 131 | lib_mock.sp_session_set_social_credentials.assert_called_once_with( 132 | session._sp_session, 133 | spotify.SocialProvider.LASTFM, 134 | mock.ANY, 135 | mock.ANY, 136 | ) 137 | self.assertEqual( 138 | spotify.ffi.string( 139 | lib_mock.sp_session_set_social_credentials.call_args[0][2] 140 | ), 141 | b"alice", 142 | ) 143 | self.assertEqual( 144 | spotify.ffi.string( 145 | lib_mock.sp_session_set_social_credentials.call_args[0][3] 146 | ), 147 | b"secret", 148 | ) 149 | 150 | def test_set_social_credentials_fail_raises_error(self, session_lib_mock, lib_mock): 151 | lib_mock.sp_session_login.return_value = spotify.ErrorType.BAD_API_VERSION 152 | session = tests.create_real_session(session_lib_mock) 153 | 154 | with self.assertRaises(spotify.Error): 155 | session.social.set_social_credentials( 156 | spotify.SocialProvider.LASTFM, "alice", "secret" 157 | ) 158 | 159 | 160 | class ScrobblingStateTest(unittest.TestCase): 161 | def test_has_constants(self): 162 | self.assertEqual(spotify.ScrobblingState.USE_GLOBAL_SETTING, 0) 163 | self.assertEqual(spotify.ScrobblingState.LOCAL_ENABLED, 1) 164 | 165 | 166 | class SocialProviderTest(unittest.TestCase): 167 | def test_has_constants(self): 168 | self.assertEqual(spotify.SocialProvider.SPOTIFY, 0) 169 | self.assertEqual(spotify.SocialProvider.FACEBOOK, 1) 170 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | import tests 7 | from tests import mock 8 | 9 | 10 | @mock.patch("spotify.connection.lib", spec=spotify.lib) 11 | @mock.patch("spotify.session.lib", spec=spotify.lib) 12 | class ConnectionTest(unittest.TestCase): 13 | def tearDown(self): 14 | spotify._session_instance = None 15 | 16 | def test_connection_state(self, session_lib_mock, lib_mock): 17 | lib_mock.sp_session_connectionstate.return_value = int( 18 | spotify.ConnectionState.LOGGED_OUT 19 | ) 20 | session = tests.create_real_session(session_lib_mock) 21 | 22 | self.assertIs(session.connection.state, spotify.ConnectionState.LOGGED_OUT) 23 | 24 | lib_mock.sp_session_connectionstate.assert_called_once_with(session._sp_session) 25 | 26 | def test_connection_type_defaults_to_unknown(self, session_lib_mock, lib_mock): 27 | lib_mock.sp_session_set_connection_type.return_value = spotify.ErrorType.OK 28 | session = tests.create_real_session(session_lib_mock) 29 | 30 | result = session.connection.type 31 | 32 | self.assertIs(result, spotify.ConnectionType.UNKNOWN) 33 | self.assertEqual(lib_mock.sp_session_set_connection_type.call_count, 0) 34 | 35 | def test_set_connection_type(self, session_lib_mock, lib_mock): 36 | lib_mock.sp_session_set_connection_type.return_value = spotify.ErrorType.OK 37 | session = tests.create_real_session(session_lib_mock) 38 | 39 | session.connection.type = spotify.ConnectionType.MOBILE_ROAMING 40 | 41 | lib_mock.sp_session_set_connection_type.assert_called_with( 42 | session._sp_session, spotify.ConnectionType.MOBILE_ROAMING 43 | ) 44 | 45 | result = session.connection.type 46 | 47 | self.assertIs(result, spotify.ConnectionType.MOBILE_ROAMING) 48 | 49 | def test_set_connection_type_fail_raises_error(self, session_lib_mock, lib_mock): 50 | lib_mock.sp_session_set_connection_type.return_value = ( 51 | spotify.ErrorType.BAD_API_VERSION 52 | ) 53 | session = tests.create_real_session(session_lib_mock) 54 | 55 | with self.assertRaises(spotify.Error): 56 | session.connection.type = spotify.ConnectionType.MOBILE_ROAMING 57 | 58 | result = session.connection.type 59 | 60 | self.assertIs(result, spotify.ConnectionType.UNKNOWN) 61 | 62 | def test_allow_network_defaults_to_true(self, session_lib_mock, lib_mock): 63 | session = tests.create_real_session(session_lib_mock) 64 | 65 | self.assertTrue(session.connection.allow_network) 66 | 67 | def test_set_allow_network(self, session_lib_mock, lib_mock): 68 | lib_mock.sp_session_set_connection_rules.return_value = spotify.ErrorType.OK 69 | session = tests.create_real_session(session_lib_mock) 70 | 71 | session.connection.allow_network = False 72 | 73 | self.assertFalse(session.connection.allow_network) 74 | lib_mock.sp_session_set_connection_rules.assert_called_with( 75 | session._sp_session, 76 | int(spotify.ConnectionRule.ALLOW_SYNC_OVER_WIFI), 77 | ) 78 | 79 | def test_allow_network_if_roaming_defaults_to_false( 80 | self, session_lib_mock, lib_mock 81 | ): 82 | session = tests.create_real_session(session_lib_mock) 83 | 84 | self.assertFalse(session.connection.allow_network_if_roaming) 85 | 86 | def test_set_allow_network_if_roaming(self, session_lib_mock, lib_mock): 87 | lib_mock.sp_session_set_connection_rules.return_value = spotify.ErrorType.OK 88 | session = tests.create_real_session(session_lib_mock) 89 | 90 | session.connection.allow_network_if_roaming = True 91 | 92 | self.assertTrue(session.connection.allow_network_if_roaming) 93 | lib_mock.sp_session_set_connection_rules.assert_called_with( 94 | session._sp_session, 95 | spotify.ConnectionRule.NETWORK 96 | | spotify.ConnectionRule.NETWORK_IF_ROAMING 97 | | spotify.ConnectionRule.ALLOW_SYNC_OVER_WIFI, 98 | ) 99 | 100 | def test_allow_sync_over_wifi_defaults_to_true(self, session_lib_mock, lib_mock): 101 | session = tests.create_real_session(session_lib_mock) 102 | 103 | self.assertTrue(session.connection.allow_sync_over_wifi) 104 | 105 | def test_set_allow_sync_over_wifi(self, session_lib_mock, lib_mock): 106 | lib_mock.sp_session_set_connection_rules.return_value = spotify.ErrorType.OK 107 | session = tests.create_real_session(session_lib_mock) 108 | 109 | session.connection.allow_sync_over_wifi = False 110 | 111 | self.assertFalse(session.connection.allow_sync_over_wifi) 112 | lib_mock.sp_session_set_connection_rules.assert_called_with( 113 | session._sp_session, int(spotify.ConnectionRule.NETWORK) 114 | ) 115 | 116 | def test_allow_sync_over_mobile_defaults_to_false(self, session_lib_mock, lib_mock): 117 | session = tests.create_real_session(session_lib_mock) 118 | 119 | self.assertFalse(session.connection.allow_sync_over_mobile) 120 | 121 | def test_set_allow_sync_over_mobile(self, session_lib_mock, lib_mock): 122 | lib_mock.sp_session_set_connection_rules.return_value = spotify.ErrorType.OK 123 | session = tests.create_real_session(session_lib_mock) 124 | 125 | session.connection.allow_sync_over_mobile = True 126 | 127 | self.assertTrue(session.connection.allow_sync_over_mobile) 128 | lib_mock.sp_session_set_connection_rules.assert_called_with( 129 | session._sp_session, 130 | spotify.ConnectionRule.NETWORK 131 | | spotify.ConnectionRule.ALLOW_SYNC_OVER_WIFI 132 | | spotify.ConnectionRule.ALLOW_SYNC_OVER_MOBILE, 133 | ) 134 | 135 | def test_set_connection_rules_without_rules(self, session_lib_mock, lib_mock): 136 | lib_mock.sp_session_set_connection_rules.return_value = spotify.ErrorType.OK 137 | session = tests.create_real_session(session_lib_mock) 138 | 139 | session.connection.allow_network = False 140 | session.connection.allow_sync_over_wifi = False 141 | 142 | lib_mock.sp_session_set_connection_rules.assert_called_with( 143 | session._sp_session, 0 144 | ) 145 | 146 | def test_set_connection_rules_fail_raises_error(self, session_lib_mock, lib_mock): 147 | lib_mock.sp_session_set_connection_rules.return_value = ( 148 | spotify.ErrorType.BAD_API_VERSION 149 | ) 150 | session = tests.create_real_session(session_lib_mock) 151 | 152 | with self.assertRaises(spotify.Error): 153 | session.connection.allow_network = False 154 | 155 | 156 | class ConnectionRuleTest(unittest.TestCase): 157 | def test_has_constants(self): 158 | self.assertEqual(spotify.ConnectionRule.NETWORK, 1) 159 | self.assertEqual(spotify.ConnectionRule.ALLOW_SYNC_OVER_WIFI, 8) 160 | 161 | 162 | class ConnectionStateTest(unittest.TestCase): 163 | def test_has_constants(self): 164 | self.assertEqual(spotify.ConnectionState.LOGGED_OUT, 0) 165 | 166 | 167 | class ConnectionTypeTest(unittest.TestCase): 168 | def test_has_constants(self): 169 | self.assertEqual(spotify.ConnectionType.UNKNOWN, 0) 170 | -------------------------------------------------------------------------------- /spotify/link.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import re 4 | 5 | try: 6 | # Python 3 7 | from urllib.parse import urlparse # noqa 8 | except ImportError: 9 | # Python 2 10 | from urlparse import urlparse # noqa 11 | 12 | import spotify 13 | from spotify import ffi, lib, serialized, utils 14 | 15 | __all__ = ["Link", "LinkType"] 16 | 17 | 18 | class Link(object): 19 | 20 | """A Spotify object link. 21 | 22 | Call the :meth:`~Session.get_link` method on your :class:`Session` instance 23 | to get a :class:`Link` object from a Spotify URI. You can also get links 24 | from the ``link`` attribute on most objects, e.g. :attr:`Track.link`. 25 | 26 | To get the URI from the link object you can use the :attr:`uri` attribute, 27 | or simply use the link as a string:: 28 | 29 | >>> session = spotify.Session() 30 | # ... 31 | >>> link = session.get_link( 32 | ... 'spotify:track:2Foc5Q5nqNiosCNqttzHof') 33 | >>> link 34 | Link('spotify:track:2Foc5Q5nqNiosCNqttzHof') 35 | >>> link.uri 36 | 'spotify:track:2Foc5Q5nqNiosCNqttzHof' 37 | >>> str(link) 38 | 'spotify:track:2Foc5Q5nqNiosCNqttzHof' 39 | >>> link.type 40 | 41 | >>> track = link.as_track() 42 | >>> track.link 43 | Link('spotify:track:2Foc5Q5nqNiosCNqttzHof') 44 | >>> track.load().name 45 | u'Get Lucky' 46 | 47 | You can also get :class:`Link` objects from open.spotify.com and 48 | play.spotify.com URLs:: 49 | 50 | >>> session.get_link( 51 | ... 'http://open.spotify.com/track/4wl1dK5dHGp3Ig51stvxb0') 52 | Link('spotify:track:4wl1dK5dHGp3Ig51stvxb0') 53 | >>> session.get_link( 54 | ... 'https://play.spotify.com/track/4wl1dK5dHGp3Ig51stvxb0' 55 | ... '?play=true&utm_source=open.spotify.com&utm_medium=open') 56 | Link('spotify:track:4wl1dK5dHGp3Ig51stvxb0') 57 | """ 58 | 59 | def __init__(self, session, uri=None, sp_link=None, add_ref=True): 60 | assert uri or sp_link, "uri or sp_link is required" 61 | 62 | self._session = session 63 | 64 | if uri is not None: 65 | sp_link = lib.sp_link_create_from_string( 66 | utils.to_char(Link._normalize_uri(uri)) 67 | ) 68 | add_ref = False 69 | if sp_link == ffi.NULL: 70 | raise ValueError("Failed to get link from Spotify URI: %r" % uri) 71 | 72 | if add_ref: 73 | lib.sp_link_add_ref(sp_link) 74 | self._sp_link = ffi.gc(sp_link, lib.sp_link_release) 75 | 76 | @staticmethod 77 | def _normalize_uri(uri): 78 | if uri.startswith("spotify:"): 79 | return uri 80 | parsed = urlparse(uri) 81 | if parsed.netloc not in ("open.spotify.com", "play.spotify.com"): 82 | return uri 83 | return "spotify%s" % parsed.path.strip().replace("/", ":") 84 | 85 | def __repr__(self): 86 | return "Link(%r)" % self.uri 87 | 88 | def __str__(self): 89 | return self.uri 90 | 91 | def __eq__(self, other): 92 | if isinstance(other, self.__class__): 93 | return self._sp_link == other._sp_link 94 | else: 95 | return False 96 | 97 | def __ne__(self, other): 98 | return not self.__eq__(other) 99 | 100 | def __hash__(self): 101 | return hash(self._sp_link) 102 | 103 | @property 104 | def uri(self): 105 | """The link's Spotify URI.""" 106 | return utils.get_with_growing_buffer(lib.sp_link_as_string, self._sp_link) 107 | 108 | @property 109 | def url(self): 110 | """The link's HTTP URL.""" 111 | return "https://open.spotify.com%s" % ( 112 | self.uri[len("spotify") :].replace(":", "/") 113 | ) 114 | 115 | @property 116 | def type(self): 117 | """The link's :class:`LinkType`.""" 118 | return LinkType(lib.sp_link_type(self._sp_link)) 119 | 120 | @serialized 121 | def as_track(self): 122 | """Make a :class:`Track` from the link.""" 123 | sp_track = lib.sp_link_as_track(self._sp_link) 124 | if sp_track == ffi.NULL: 125 | return None 126 | return spotify.Track(self._session, sp_track=sp_track, add_ref=True) 127 | 128 | def as_track_offset(self): 129 | """Get the track offset in milliseconds from the link.""" 130 | offset = ffi.new("int *") 131 | sp_track = lib.sp_link_as_track_and_offset(self._sp_link, offset) 132 | if sp_track == ffi.NULL: 133 | return None 134 | return offset[0] 135 | 136 | @serialized 137 | def as_album(self): 138 | """Make an :class:`Album` from the link.""" 139 | sp_album = lib.sp_link_as_album(self._sp_link) 140 | if sp_album == ffi.NULL: 141 | return None 142 | return spotify.Album(self._session, sp_album=sp_album, add_ref=True) 143 | 144 | @serialized 145 | def as_artist(self): 146 | """Make an :class:`Artist` from the link.""" 147 | sp_artist = lib.sp_link_as_artist(self._sp_link) 148 | if sp_artist == ffi.NULL: 149 | return None 150 | return spotify.Artist(self._session, sp_artist=sp_artist, add_ref=True) 151 | 152 | def as_playlist(self): 153 | """Make a :class:`Playlist` from the link.""" 154 | sp_playlist = self._as_sp_playlist() 155 | if sp_playlist is None: 156 | return None 157 | return spotify.Playlist._cached(self._session, sp_playlist, add_ref=False) 158 | 159 | def _as_sp_playlist(self): 160 | sp_playlist = None 161 | if self.type == LinkType.PLAYLIST: 162 | sp_playlist = lib.sp_playlist_create( 163 | self._session._sp_session, self._sp_link 164 | ) 165 | elif self.type == LinkType.STARRED: 166 | matches = re.match(r"^spotify:user:([^:]+):starred$", self.uri) 167 | if matches: 168 | username = matches.group(1) 169 | sp_playlist = lib.sp_session_starred_for_user_create( 170 | self._session._sp_session, utils.to_bytes(username) 171 | ) 172 | if sp_playlist is None or sp_playlist == ffi.NULL: 173 | return None 174 | return sp_playlist 175 | 176 | @serialized 177 | def as_user(self): 178 | """Make an :class:`User` from the link.""" 179 | sp_user = lib.sp_link_as_user(self._sp_link) 180 | if sp_user == ffi.NULL: 181 | return None 182 | return spotify.User(self._session, sp_user=sp_user, add_ref=True) 183 | 184 | def as_image(self, callback=None): 185 | """Make an :class:`Image` from the link. 186 | 187 | If ``callback`` isn't :class:`None`, it is expected to be a callable 188 | that accepts a single argument, an :class:`Image` instance, when 189 | the image is done loading. 190 | """ 191 | if self.type is not LinkType.IMAGE: 192 | return None 193 | sp_image = lib.sp_image_create_from_link( 194 | self._session._sp_session, self._sp_link 195 | ) 196 | if sp_image == ffi.NULL: 197 | return None 198 | return spotify.Image( 199 | self._session, sp_image=sp_image, add_ref=False, callback=callback 200 | ) 201 | 202 | 203 | @utils.make_enum("SP_LINKTYPE_") 204 | class LinkType(utils.IntEnum): 205 | pass 206 | -------------------------------------------------------------------------------- /tests/test_sink.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import unittest 4 | 5 | import spotify 6 | from tests import mock 7 | 8 | 9 | class BaseSinkTest(object): 10 | def test_init_connects_to_music_delivery_event(self): 11 | self.session.on.assert_called_with( 12 | spotify.SessionEvent.MUSIC_DELIVERY, self.sink._on_music_delivery 13 | ) 14 | 15 | def test_off_disconnects_from_music_delivery_event(self): 16 | self.assertEqual(self.session.off.call_count, 0) 17 | 18 | self.sink.off() 19 | 20 | self.session.off.assert_called_with( 21 | spotify.SessionEvent.MUSIC_DELIVERY, mock.ANY 22 | ) 23 | 24 | def test_on_connects_to_music_delivery_event(self): 25 | self.assertEqual(self.session.on.call_count, 1) 26 | 27 | self.sink.off() 28 | self.sink.on() 29 | 30 | self.assertEqual(self.session.on.call_count, 2) 31 | 32 | 33 | class AlsaSinkTest(unittest.TestCase, BaseSinkTest): 34 | def setUp(self): 35 | self.session = mock.Mock() 36 | self.session.num_listeners.return_value = 0 37 | self.alsaaudio = mock.Mock() 38 | self.alsaaudio.pcms = mock.Mock() 39 | with mock.patch.dict("sys.modules", {"alsaaudio": self.alsaaudio}): 40 | self.sink = spotify.AlsaSink(self.session) 41 | 42 | def test_off_closes_audio_device(self): 43 | device_mock = mock.Mock() 44 | self.sink._device = device_mock 45 | 46 | self.sink.off() 47 | 48 | device_mock.close.assert_called_with() 49 | self.assertIsNone(self.sink._device) 50 | 51 | def test_music_delivery_creates_device_if_needed(self): 52 | device = mock.Mock() 53 | self.alsaaudio.PCM.return_value = device 54 | audio_format = mock.Mock() 55 | audio_format.frame_size.return_value = 4 56 | audio_format.sample_type = spotify.SampleType.INT16_NATIVE_ENDIAN 57 | num_frames = 2048 58 | 59 | self.sink._on_music_delivery( 60 | mock.sentinel.session, 61 | audio_format, 62 | mock.sentinel.frames, 63 | num_frames, 64 | ) 65 | 66 | # The ``device`` kwarg was added in pyalsaaudio 0.8 67 | self.alsaaudio.PCM.assert_called_with( 68 | mode=self.alsaaudio.PCM_NONBLOCK, device="default" 69 | ) 70 | 71 | device.setformat.assert_called_with(mock.ANY) 72 | device.setrate.assert_called_with(audio_format.sample_rate) 73 | device.setchannels.assert_called_with(audio_format.channels) 74 | device.setperiodsize.assert_called_with(2048 * 4) 75 | 76 | def test_music_delivery_creates_device_with_alsaaudio_0_7(self): 77 | del self.alsaaudio.pcms # Remove pyalsaudio 0.8 version marker 78 | device = mock.Mock() 79 | self.alsaaudio.PCM.return_value = device 80 | audio_format = mock.Mock() 81 | audio_format.frame_size.return_value = 4 82 | audio_format.sample_type = spotify.SampleType.INT16_NATIVE_ENDIAN 83 | num_frames = 2048 84 | 85 | self.sink._on_music_delivery( 86 | mock.sentinel.session, 87 | audio_format, 88 | mock.sentinel.frames, 89 | num_frames, 90 | ) 91 | 92 | # The ``card`` kwarg was deprecated in pyalsaaudio 0.8 93 | self.alsaaudio.PCM.assert_called_with( 94 | mode=self.alsaaudio.PCM_NONBLOCK, card="default" 95 | ) 96 | 97 | def test_sets_little_endian_format_if_little_endian_system(self): 98 | device = mock.Mock() 99 | self.alsaaudio.PCM.return_value = device 100 | audio_format = mock.Mock() 101 | audio_format.frame_size.return_value = 4 102 | audio_format.sample_type = spotify.SampleType.INT16_NATIVE_ENDIAN 103 | num_frames = 2048 104 | 105 | with mock.patch("spotify.sink.sys") as sys_mock: 106 | sys_mock.byteorder = "little" 107 | 108 | self.sink._on_music_delivery( 109 | mock.sentinel.session, 110 | audio_format, 111 | mock.sentinel.frames, 112 | num_frames, 113 | ) 114 | 115 | device.setformat.assert_called_with(self.alsaaudio.PCM_FORMAT_S16_LE) 116 | 117 | def test_sets_big_endian_format_if_big_endian_system(self): 118 | device = mock.Mock() 119 | self.alsaaudio.PCM.return_value = device 120 | audio_format = mock.Mock() 121 | audio_format.frame_size.return_value = 4 122 | audio_format.sample_type = spotify.SampleType.INT16_NATIVE_ENDIAN 123 | num_frames = 2048 124 | 125 | with mock.patch("spotify.sink.sys") as sys_mock: 126 | sys_mock.byteorder = "big" 127 | 128 | self.sink._on_music_delivery( 129 | mock.sentinel.session, 130 | audio_format, 131 | mock.sentinel.frames, 132 | num_frames, 133 | ) 134 | 135 | device.setformat.assert_called_with(self.alsaaudio.PCM_FORMAT_S16_BE) 136 | 137 | def test_music_delivery_writes_frames_to_stream(self): 138 | self.sink._device = mock.Mock() 139 | audio_format = mock.Mock() 140 | audio_format.sample_type = spotify.SampleType.INT16_NATIVE_ENDIAN 141 | 142 | num_consumed_frames = self.sink._on_music_delivery( 143 | mock.sentinel.session, 144 | audio_format, 145 | mock.sentinel.frames, 146 | mock.sentinel.num_frames, 147 | ) 148 | 149 | self.sink._device.write.assert_called_with(mock.sentinel.frames) 150 | self.assertEqual(num_consumed_frames, self.sink._device.write.return_value) 151 | 152 | 153 | class PortAudioSinkTest(unittest.TestCase, BaseSinkTest): 154 | def setUp(self): 155 | self.session = mock.Mock() 156 | self.session.num_listeners.return_value = 0 157 | self.pyaudio = mock.Mock() 158 | with mock.patch.dict("sys.modules", {"pyaudio": self.pyaudio}): 159 | self.sink = spotify.PortAudioSink(self.session) 160 | 161 | def test_init_creates_device(self): 162 | self.pyaudio.PyAudio.assert_called_with() 163 | self.assertEqual(self.sink._device, self.pyaudio.PyAudio.return_value) 164 | 165 | def test_off_closes_audio_stream(self): 166 | stream_mock = mock.Mock() 167 | self.sink._stream = stream_mock 168 | 169 | self.sink.off() 170 | 171 | stream_mock.close.assert_called_with() 172 | self.assertIsNone(self.sink._stream) 173 | 174 | def test_music_delivery_creates_stream_if_needed(self): 175 | audio_format = mock.Mock() 176 | audio_format.sample_type = spotify.SampleType.INT16_NATIVE_ENDIAN 177 | 178 | self.sink._on_music_delivery( 179 | mock.sentinel.session, 180 | audio_format, 181 | mock.sentinel.frames, 182 | mock.sentinel.num_frames, 183 | ) 184 | 185 | self.sink._device.open.assert_called_with( 186 | format=self.pyaudio.paInt16, 187 | channels=audio_format.channels, 188 | rate=audio_format.sample_rate, 189 | output=True, 190 | ) 191 | self.assertEqual(self.sink._stream, self.sink._device.open.return_value) 192 | 193 | def test_music_delivery_writes_frames_to_stream(self): 194 | self.sink._stream = mock.Mock() 195 | audio_format = mock.Mock() 196 | audio_format.sample_type = spotify.SampleType.INT16_NATIVE_ENDIAN 197 | 198 | num_consumed_frames = self.sink._on_music_delivery( 199 | mock.sentinel.session, 200 | audio_format, 201 | mock.sentinel.frames, 202 | mock.sentinel.num_frames, 203 | ) 204 | 205 | self.sink._stream.write.assert_called_with( 206 | mock.sentinel.frames, num_frames=mock.sentinel.num_frames 207 | ) 208 | self.assertEqual(num_consumed_frames, mock.sentinel.num_frames) 209 | -------------------------------------------------------------------------------- /spotify/toplist.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | import threading 5 | 6 | import spotify 7 | from spotify import ffi, lib, serialized, utils 8 | 9 | __all__ = ["Toplist", "ToplistRegion", "ToplistType"] 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class Toplist(object): 15 | 16 | """A Spotify toplist of artists, albums or tracks that are currently most 17 | popular worldwide or in a specific region. 18 | 19 | Call the :meth:`~Session.get_toplist` method on your :class:`Session` 20 | instance to get a :class:`Toplist` back. 21 | """ 22 | 23 | type = None 24 | """A :class:`ToplistType` instance that specifies what kind of toplist this 25 | is: top artists, top albums, or top tracks. 26 | 27 | Changing this field has no effect on existing toplists. 28 | """ 29 | 30 | region = None 31 | """Either a :class:`ToplistRegion` instance, or a 2-letter ISO 3166-1 32 | country code, that specifies the geographical region this toplist is for. 33 | 34 | Changing this field has no effect on existing toplists. 35 | """ 36 | 37 | canonical_username = None 38 | """If :attr:`region` is :attr:`ToplistRegion.USER`, then this field 39 | specifies which user the toplist is for. 40 | 41 | Changing this field has no effect on existing toplists. 42 | """ 43 | 44 | loaded_event = None 45 | """:class:`threading.Event` that is set when the toplist is loaded.""" 46 | 47 | def __init__( 48 | self, 49 | session, 50 | type=None, 51 | region=None, 52 | canonical_username=None, 53 | callback=None, 54 | sp_toplistbrowse=None, 55 | add_ref=True, 56 | ): 57 | 58 | assert ( 59 | type is not None and region is not None 60 | ) or sp_toplistbrowse, "type and region, or sp_toplistbrowse, is required" 61 | 62 | self._session = session 63 | self.type = type 64 | self.region = region 65 | self.canonical_username = canonical_username 66 | self.loaded_event = threading.Event() 67 | 68 | if sp_toplistbrowse is None: 69 | if isinstance(region, ToplistRegion): 70 | region = int(region) 71 | else: 72 | region = utils.to_country_code(region) 73 | 74 | handle = ffi.new_handle((self._session, self, callback)) 75 | self._session._callback_handles.add(handle) 76 | 77 | sp_toplistbrowse = lib.sp_toplistbrowse_create( 78 | self._session._sp_session, 79 | int(type), 80 | region, 81 | utils.to_char_or_null(canonical_username), 82 | _toplistbrowse_complete_callback, 83 | handle, 84 | ) 85 | add_ref = False 86 | 87 | if add_ref: 88 | lib.sp_toplistbrowse_add_ref(sp_toplistbrowse) 89 | self._sp_toplistbrowse = ffi.gc(sp_toplistbrowse, lib.sp_toplistbrowse_release) 90 | 91 | def __repr__(self): 92 | return "Toplist(type=%r, region=%r, canonical_username=%r)" % ( 93 | self.type, 94 | self.region, 95 | self.canonical_username, 96 | ) 97 | 98 | def __eq__(self, other): 99 | if isinstance(other, self.__class__): 100 | return self._sp_toplistbrowse == other._sp_toplistbrowse 101 | else: 102 | return False 103 | 104 | def __ne__(self, other): 105 | return not self.__eq__(other) 106 | 107 | def __hash__(self): 108 | return hash(self._sp_toplistbrowse) 109 | 110 | @property 111 | def is_loaded(self): 112 | """Whether the toplist's data is loaded yet.""" 113 | return bool(lib.sp_toplistbrowse_is_loaded(self._sp_toplistbrowse)) 114 | 115 | def load(self, timeout=None): 116 | """Block until the user's data is loaded. 117 | 118 | After ``timeout`` seconds with no results :exc:`~spotify.Timeout` is 119 | raised. If ``timeout`` is :class:`None` the default timeout is used. 120 | 121 | The method returns ``self`` to allow for chaining of calls. 122 | """ 123 | return utils.load(self._session, self, timeout=timeout) 124 | 125 | @property 126 | def error(self): 127 | """An :class:`ErrorType` associated with the toplist. 128 | 129 | Check to see if there was problems creating the toplist. 130 | """ 131 | return spotify.ErrorType(lib.sp_toplistbrowse_error(self._sp_toplistbrowse)) 132 | 133 | @property 134 | def backend_request_duration(self): 135 | """The time in ms that was spent waiting for the Spotify backend to 136 | create the toplist. 137 | 138 | Returns ``-1`` if the request was served from local cache. Returns 139 | :class:`None` if the toplist isn't loaded yet. 140 | """ 141 | if not self.is_loaded: 142 | return None 143 | return lib.sp_toplistbrowse_backend_request_duration(self._sp_toplistbrowse) 144 | 145 | @property 146 | @serialized 147 | def tracks(self): 148 | """The tracks in the toplist. 149 | 150 | Will always return an empty list if the toplist isn't loaded. 151 | """ 152 | spotify.Error.maybe_raise(self.error) 153 | if not self.is_loaded: 154 | return [] 155 | 156 | @serialized 157 | def get_track(sp_toplistbrowse, key): 158 | return spotify.Track( 159 | self._session, 160 | sp_track=lib.sp_toplistbrowse_track(sp_toplistbrowse, key), 161 | add_ref=True, 162 | ) 163 | 164 | return utils.Sequence( 165 | sp_obj=self._sp_toplistbrowse, 166 | add_ref_func=lib.sp_toplistbrowse_add_ref, 167 | release_func=lib.sp_toplistbrowse_release, 168 | len_func=lib.sp_toplistbrowse_num_tracks, 169 | getitem_func=get_track, 170 | ) 171 | 172 | @property 173 | @serialized 174 | def albums(self): 175 | """The albums in the toplist. 176 | 177 | Will always return an empty list if the toplist isn't loaded. 178 | """ 179 | spotify.Error.maybe_raise(self.error) 180 | if not self.is_loaded: 181 | return [] 182 | 183 | @serialized 184 | def get_album(sp_toplistbrowse, key): 185 | return spotify.Album( 186 | self._session, 187 | sp_album=lib.sp_toplistbrowse_album(sp_toplistbrowse, key), 188 | add_ref=True, 189 | ) 190 | 191 | return utils.Sequence( 192 | sp_obj=self._sp_toplistbrowse, 193 | add_ref_func=lib.sp_toplistbrowse_add_ref, 194 | release_func=lib.sp_toplistbrowse_release, 195 | len_func=lib.sp_toplistbrowse_num_albums, 196 | getitem_func=get_album, 197 | ) 198 | 199 | @property 200 | @serialized 201 | def artists(self): 202 | """The artists in the toplist. 203 | 204 | Will always return an empty list if the toplist isn't loaded. 205 | """ 206 | spotify.Error.maybe_raise(self.error) 207 | if not self.is_loaded: 208 | return [] 209 | 210 | @serialized 211 | def get_artist(sp_toplistbrowse, key): 212 | return spotify.Artist( 213 | self._session, 214 | sp_artist=lib.sp_toplistbrowse_artist(sp_toplistbrowse, key), 215 | add_ref=True, 216 | ) 217 | 218 | return utils.Sequence( 219 | sp_obj=self._sp_toplistbrowse, 220 | add_ref_func=lib.sp_toplistbrowse_add_ref, 221 | release_func=lib.sp_toplistbrowse_release, 222 | len_func=lib.sp_toplistbrowse_num_artists, 223 | getitem_func=get_artist, 224 | ) 225 | 226 | 227 | @ffi.callback("void(sp_toplistbrowse *, void *)") 228 | @serialized 229 | def _toplistbrowse_complete_callback(sp_toplistbrowse, handle): 230 | logger.debug("toplistbrowse_complete_callback called") 231 | if handle == ffi.NULL: 232 | logger.warning( 233 | "pyspotify toplistbrowse_complete_callback " "called without userdata" 234 | ) 235 | return 236 | (session, toplist, callback) = ffi.from_handle(handle) 237 | session._callback_handles.remove(handle) 238 | toplist.loaded_event.set() 239 | if callback is not None: 240 | callback(toplist) 241 | 242 | 243 | @utils.make_enum("SP_TOPLIST_REGION_") 244 | class ToplistRegion(utils.IntEnum): 245 | pass 246 | 247 | 248 | @utils.make_enum("SP_TOPLIST_TYPE_") 249 | class ToplistType(utils.IntEnum): 250 | pass 251 | -------------------------------------------------------------------------------- /tests/test_inbox.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | from __future__ import unicode_literals 4 | 5 | import unittest 6 | 7 | import spotify 8 | import tests 9 | from tests import mock 10 | 11 | 12 | @mock.patch("spotify.inbox.lib", spec=spotify.lib) 13 | class InboxPostResultTest(unittest.TestCase): 14 | def setUp(self): 15 | self.session = tests.create_session_mock() 16 | spotify._session_instance = self.session 17 | 18 | def tearDown(self): 19 | spotify._session_instance = None 20 | 21 | def test_create_without_user_and_tracks_or_sp_inbox_fails(self, lib_mock): 22 | with self.assertRaises(AssertionError): 23 | spotify.InboxPostResult(self.session) 24 | 25 | def test_adds_ref_to_sp_inbox_when_created(self, lib_mock): 26 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 27 | 28 | spotify.InboxPostResult(self.session, sp_inbox=sp_inbox) 29 | 30 | lib_mock.sp_inbox_add_ref.assert_called_with(sp_inbox) 31 | 32 | def test_releases_sp_inbox_when_result_dies(self, lib_mock): 33 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 34 | 35 | inbox_post_result = spotify.InboxPostResult(self.session, sp_inbox=sp_inbox) 36 | inbox_post_result = None # noqa 37 | tests.gc_collect() 38 | 39 | lib_mock.sp_inbox_release.assert_called_with(sp_inbox) 40 | 41 | @mock.patch("spotify.track.lib", spec=spotify.lib) 42 | def test_inbox_post_tracks(self, track_lib_mock, lib_mock): 43 | sp_track1 = spotify.ffi.cast("sp_track *", 43) 44 | track1 = spotify.Track(self.session, sp_track=sp_track1) 45 | sp_track2 = spotify.ffi.cast("sp_track *", 44) 46 | track2 = spotify.Track(self.session, sp_track=sp_track2) 47 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 48 | lib_mock.sp_inbox_post_tracks.return_value = sp_inbox 49 | 50 | result = spotify.InboxPostResult(self.session, "alice", [track1, track2], "♥") 51 | 52 | lib_mock.sp_inbox_post_tracks.assert_called_with( 53 | self.session._sp_session, 54 | mock.ANY, 55 | mock.ANY, 56 | 2, 57 | mock.ANY, 58 | mock.ANY, 59 | mock.ANY, 60 | ) 61 | self.assertEqual( 62 | spotify.ffi.string(lib_mock.sp_inbox_post_tracks.call_args[0][1]), 63 | b"alice", 64 | ) 65 | self.assertIn(sp_track1, lib_mock.sp_inbox_post_tracks.call_args[0][2]) 66 | self.assertIn(sp_track2, lib_mock.sp_inbox_post_tracks.call_args[0][2]) 67 | self.assertEqual( 68 | spotify.ffi.string(lib_mock.sp_inbox_post_tracks.call_args[0][4]), 69 | b"\xe2\x99\xa5", 70 | ) 71 | self.assertIsInstance(result, spotify.InboxPostResult) 72 | self.assertEqual(result._sp_inbox, sp_inbox) 73 | 74 | self.assertFalse(result.loaded_event.is_set()) 75 | inboxpost_complete_cb = lib_mock.sp_inbox_post_tracks.call_args[0][5] 76 | userdata = lib_mock.sp_inbox_post_tracks.call_args[0][6] 77 | inboxpost_complete_cb(sp_inbox, userdata) 78 | self.assertTrue(result.loaded_event.wait(3)) 79 | 80 | @mock.patch("spotify.track.lib", spec=spotify.lib) 81 | def test_inbox_post_with_single_track(self, track_lib_mock, lib_mock): 82 | sp_track1 = spotify.ffi.cast("sp_track *", 43) 83 | track1 = spotify.Track(self.session, sp_track=sp_track1) 84 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 85 | lib_mock.sp_inbox_post_tracks.return_value = sp_inbox 86 | 87 | result = spotify.InboxPostResult(self.session, "alice", track1, "Enjoy!") 88 | 89 | lib_mock.sp_inbox_post_tracks.assert_called_with( 90 | self.session._sp_session, 91 | mock.ANY, 92 | mock.ANY, 93 | 1, 94 | mock.ANY, 95 | mock.ANY, 96 | mock.ANY, 97 | ) 98 | self.assertIn(sp_track1, lib_mock.sp_inbox_post_tracks.call_args[0][2]) 99 | self.assertIsInstance(result, spotify.InboxPostResult) 100 | self.assertEqual(result._sp_inbox, sp_inbox) 101 | 102 | @mock.patch("spotify.track.lib", spec=spotify.lib) 103 | def test_inbox_post_with_callback(self, track_lib_mock, lib_mock): 104 | sp_track1 = spotify.ffi.cast("sp_track *", 43) 105 | track1 = spotify.Track(self.session, sp_track=sp_track1) 106 | sp_track2 = spotify.ffi.cast("sp_track *", 44) 107 | track2 = spotify.Track(self.session, sp_track=sp_track2) 108 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 109 | lib_mock.sp_inbox_post_tracks.return_value = sp_inbox 110 | callback = mock.Mock() 111 | 112 | result = spotify.InboxPostResult( 113 | self.session, "alice", [track1, track2], callback=callback 114 | ) 115 | 116 | inboxpost_complete_cb = lib_mock.sp_inbox_post_tracks.call_args[0][5] 117 | userdata = lib_mock.sp_inbox_post_tracks.call_args[0][6] 118 | inboxpost_complete_cb(sp_inbox, userdata) 119 | 120 | result.loaded_event.wait(3) 121 | callback.assert_called_with(result) 122 | 123 | @mock.patch("spotify.track.lib", spec=spotify.lib) 124 | def test_inbox_post_where_result_is_gone_before_callback_is_called( 125 | self, track_lib_mock, lib_mock 126 | ): 127 | 128 | sp_track1 = spotify.ffi.cast("sp_track *", 43) 129 | track1 = spotify.Track(self.session, sp_track=sp_track1) 130 | sp_track2 = spotify.ffi.cast("sp_track *", 44) 131 | track2 = spotify.Track(self.session, sp_track=sp_track2) 132 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 133 | lib_mock.sp_inbox_post_tracks.return_value = sp_inbox 134 | callback = mock.Mock() 135 | 136 | result = spotify.InboxPostResult( 137 | self.session, "alice", [track1, track2], callback=callback 138 | ) 139 | loaded_event = result.loaded_event 140 | result = None # noqa 141 | tests.gc_collect() 142 | 143 | # The mock keeps the handle/userdata alive, thus this test doesn't 144 | # really test that session._callback_handles keeps the handle alive. 145 | inboxpost_complete_cb = lib_mock.sp_inbox_post_tracks.call_args[0][5] 146 | userdata = lib_mock.sp_inbox_post_tracks.call_args[0][6] 147 | inboxpost_complete_cb(sp_inbox, userdata) 148 | 149 | loaded_event.wait(3) 150 | self.assertEqual(callback.call_count, 1) 151 | self.assertEqual(callback.call_args[0][0]._sp_inbox, sp_inbox) 152 | 153 | @mock.patch("spotify.track.lib", spec=spotify.lib) 154 | def test_fail_to_init_raises_error(self, track_lib_mock, lib_mock): 155 | sp_track1 = spotify.ffi.cast("sp_track *", 43) 156 | track1 = spotify.Track(self.session, sp_track=sp_track1) 157 | sp_track2 = spotify.ffi.cast("sp_track *", 44) 158 | track2 = spotify.Track(self.session, sp_track=sp_track2) 159 | lib_mock.sp_inbox_post_tracks.return_value = spotify.ffi.NULL 160 | 161 | with self.assertRaises(spotify.Error): 162 | spotify.InboxPostResult(self.session, "alice", [track1, track2], "Enjoy!") 163 | 164 | def test_repr(self, lib_mock): 165 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 166 | inbox_post_result = spotify.InboxPostResult(self.session, sp_inbox=sp_inbox) 167 | 168 | self.assertEqual(repr(inbox_post_result), "InboxPostResult()") 169 | 170 | inbox_post_result.loaded_event.set() 171 | lib_mock.sp_inbox_error.return_value = int(spotify.ErrorType.INBOX_IS_FULL) 172 | 173 | self.assertEqual(repr(inbox_post_result), "InboxPostResult(INBOX_IS_FULL)") 174 | 175 | lib_mock.sp_inbox_error.return_value = int(spotify.ErrorType.OK) 176 | 177 | self.assertEqual(repr(inbox_post_result), "InboxPostResult(OK)") 178 | 179 | def test_eq(self, lib_mock): 180 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 181 | inbox1 = spotify.InboxPostResult(self.session, sp_inbox=sp_inbox) 182 | inbox2 = spotify.InboxPostResult(self.session, sp_inbox=sp_inbox) 183 | 184 | self.assertTrue(inbox1 == inbox2) 185 | self.assertFalse(inbox1 == "foo") 186 | 187 | def test_ne(self, lib_mock): 188 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 189 | inbox1 = spotify.InboxPostResult(self.session, sp_inbox=sp_inbox) 190 | inbox2 = spotify.InboxPostResult(self.session, sp_inbox=sp_inbox) 191 | 192 | self.assertFalse(inbox1 != inbox2) 193 | 194 | def test_hash(self, lib_mock): 195 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 196 | inbox1 = spotify.InboxPostResult(self.session, sp_inbox=sp_inbox) 197 | inbox2 = spotify.InboxPostResult(self.session, sp_inbox=sp_inbox) 198 | 199 | self.assertEqual(hash(inbox1), hash(inbox2)) 200 | 201 | def test_error(self, lib_mock): 202 | lib_mock.sp_inbox_error.return_value = int(spotify.ErrorType.INBOX_IS_FULL) 203 | sp_inbox = spotify.ffi.cast("sp_inbox *", 42) 204 | inbox_post_result = spotify.InboxPostResult(self.session, sp_inbox=sp_inbox) 205 | 206 | result = inbox_post_result.error 207 | 208 | lib_mock.sp_inbox_error.assert_called_once_with(sp_inbox) 209 | self.assertIs(result, spotify.ErrorType.INBOX_IS_FULL) 210 | --------------------------------------------------------------------------------