From 89975fec88eec0605c512eefb68db41c42c4e763 Mon Sep 17 00:00:00 2001 From: Julian Lobbes Date: Tue, 23 Aug 2022 15:45:55 +0200 Subject: [PATCH] feat: create project and readme --- .gitignore | 1 + README.md | 67 ++++++++++++- spotifyctl.py | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 spotifyctl.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eeb8a6e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/__pycache__ diff --git a/README.md b/README.md index 90acc76..e390859 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ # spotifyctl -A python API to query and control Spotify via dbus. \ No newline at end of file +**spotifyctl** is a python module/API with which you can query and control the _ +Spotify executable on linux. + +It works by talking to Spotify via dbus, letting you control the current playback +or getting metadata about the currently playing track. + +## Dependencies + +You need [dbus](https://dbus.freedesktop.org) to be installed and enabled on your system. +Dbus is preinstalled on most Linux distros. + +**spotifyctl** depends on the [dbus-python](https://pypi.org/project/dbus-python/) package. + +Obviously, you need to have Spotify installed as well if you want **spotifyctl** to be useful. + +## Getting started + +**spotifyctl** comes as a single, self-contained python script/module. + +Simply import `spotifyctl` in your python program to access the API: + +```python +import spotifyctl + +# Get the current state of the Spotify player +spotifyctl.player_get_playback_status() +# returns: "playing" + +# Toggle bewteen play/pause +spotifyctl.player_action_toggle_play_pause() + +# Skip to the next track +spotifyctl.player_action_next() + +# Get some metadata for the current track in a python dict +spotifyctl.player_get_track_metadata() +# returns: +# { +# 'album_artist_names': ['Rick Astley'], +# 'album_name': 'Whenever You Need Somebody', +# 'artist_names': ['Rick Astley'], +# 'artwork_url': 'https://i.scdn.co/image/ab67616d0000b2735755e164993798e0c9ef7d7a', +# 'auto_rating': 0.8, +# 'disc_number': 1, +# 'length': datetime.timedelta(seconds=213, microseconds=573000), +# 'title': 'Never Gonna Give You Up', +# 'track_id': '4cOdK2wGLETKBW3PvgPWqT', +# 'track_number': 1, +# 'track_url': 'https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT' +# } +``` + +Have a look at [the complete API documentation](https://docs.skyforest.net/spotifyctl/index.html) _ +for a more complete list of API calls. + +Note that if the Spotify executable is not running, API calls will throw exceptions. + +## Documentation + +The API documentation is [hosted here](https://docs.skyforest.net/spotifyctl/index.html). +To build it yourself, you can install [pdoc](https://pypi.org/project/pdoc/) and run: + +```bash +mkdir doc +pdoc --docformat numpy --output-dir ./doc ./spotify.py +``` diff --git a/spotifyctl.py b/spotifyctl.py new file mode 100644 index 0000000..ebbde9d --- /dev/null +++ b/spotifyctl.py @@ -0,0 +1,268 @@ +"""This is an API for interacting with the Spotify executable via dbus on linux. + +It allows you to control playback in Spotify (play, pause, next track, etc.) and +retrieve metadata about the currently playing track. + +The functions in this module require the Spotify executable to be running. +If it isn't, they will throw a `SpotifyNotRunningException`. + +Dependencies: +- [dbus-python](https://pypi.org/project/dbus-python/) +""" + +import dbus +from datetime import timedelta + + +class SpotifyNotRunningException(RuntimeError): + """Raised when spotify is not running. + + Specifically, this exception is raised when a query to dbus fails to find + the dbus object used to communicate with the spotify executable. + """ + + +class NoActiveTrackException(RuntimeError): + """Raised when there is currently no track playing. + + This exception is raised when a method related to the currently playing + track is called (such as getting the current track's metadata), but there + is no active track playing. + """ + + +def _get_spotify_dbus_obj(): + """Gets the spotify object from the current dbus session bus. + + Returns + ------- + `spotify_dbus_object` : (dbus.proxies.ProxyObject)[https://dbus.freedesktop.org/doc/dbus-python/dbus.proxies.html#dbus.proxies.ProxyObject] + A dbus-python proxy object for the spotify dbus object. + + Raises + ------ + `SpotifyNotRunningException` + When dbus fails to locate the spotify object. + """ + + session_bus = dbus.SessionBus() + bus_data = ("org.mpris.MediaPlayer2.spotify", "/org/mpris/MediaPlayer2") + try: + spotify_dbus_object = session_bus.get_object(*bus_data) + except dbus.exceptions.DBusException as e: + raise SpotifyNotRunningException( + "The spotify executable is not running." + ) from e + + return spotify_dbus_object + + +def _get_interface(interface_name): + """Gets a dbus interface to the specified name in the spotify dbus object. + + Parameters + ---------- + `interface_name` : str + The name of the interface to get. + + Returns + ------- + `interface` : [dbus.Interface](https://dbus.freedesktop.org/doc/dbus-python/dbus.html#dbus.Interface) + A python-dbus interface having the specified name, connected to the + spotify bus object. + + Raises + ------ + `SpotifyNotRunningException` + When the spotify executable is not running. + """ + + interface = dbus.Interface(_get_spotify_dbus_obj(), interface_name) + return interface + + +_INTERFACE_NAME_PROPERTIES = "org.freedesktop.DBus.Properties" +_INTERFACE_NAME_PLAYER = "org.mpris.MediaPlayer2.Player" + + +def player_get_playback_status(): + """Returns the current playback status as a string. + + Returns + ------- + `playback_status` : str + - `"stopped"` if the player is currently stopped (no active track). + - `"playing"` if the player is currently playing something. + - `"paused"` if the player is currently paused. + + Raises + ------ + `SpotifyNotRunningException` + When the spotify executable is not running. + """ + + interface = _get_interface(_INTERFACE_NAME_PROPERTIES) + playback_status = interface.Get(_INTERFACE_NAME_PLAYER, "PlaybackStatus") + + return str(playback_status).lower() + + +def player_action_toggle_play_pause(): + """Toggles the spotify player's state between "playing" and "paused". + + Raises + ------ + `SpotifyNotRunningException` + When the spotify executable is not running. + `NoActiveTrackException` + When there is no currently active track. + """ + + interface = _get_interface(_INTERFACE_NAME_PLAYER) + + if player_get_playback_status() == "stopped": + raise NoActiveTrackException( + "Failed to toggle player state: no active track." + ) + + interface.PlayPause() + + +def player_action_play(): + """Sets the spotify player's state to "playing". + + Raises + ------ + `SpotifyNotRunningException` + When the spotify executable is not running. + `NoActiveTrackException` + When there is no currently active track. + """ + + interface = _get_interface(_INTERFACE_NAME_PLAYER) + + if player_get_playback_status() == "stopped": + raise NoActiveTrackException( + "Failed to change player state: no active track." + ) + + interface.Play() + + +def player_action_pause(): + """Sets the spotify player's state to "paused". + + Raises + ------ + `SpotifyNotRunningException` + When the spotify executable is not running. + `NoActiveTrackException` + When there is no currently active track. + """ + + interface = _get_interface(_INTERFACE_NAME_PLAYER) + + if player_get_playback_status() == "stopped": + raise NoActiveTrackException( + "Failed to change player state: no active track." + ) + + interface.Pause() + + +def player_action_previous(): + """Instructs the spotify player to play the previous track. + + Raises + ------ + `SpotifyNotRunningException` + When the spotify executable is not running. + `NoActiveTrackException` + When there is no currently active track. + """ + + interface = _get_interface(_INTERFACE_NAME_PLAYER) + + if player_get_playback_status() == "stopped": + raise NoActiveTrackException( + "Failed to change player state: no active track." + ) + + interface.Previous() + + +def player_action_next(): + """Instructs the spotify player to play the next track. + + Raises + ------ + `SpotifyNotRunningException` + When the spotify executable is not running. + `NoActiveTrackException` + When there is no currently active track. + """ + + interface = _get_interface(_INTERFACE_NAME_PLAYER) + + if player_get_playback_status() == "stopped": + raise NoActiveTrackException( + "Failed to change player state: no active track." + ) + + interface.Next() + + +def player_get_track_metadata(): + """Returns a dict containing the currently playing track's metadata. + + The returned dict contains the following key-value-pairs: + + Returns + ------- + `metadata` : dict + A dictionary containing track metadata, in the following format: + - `'album_artist_names'`: album artist names (list of str) + - `'album_name'`: album name (str) + - `'artwork_url'`: URL to album art image on Spotify's CDN (str) + - `'auto_rating'`: not sure what this is (float) + - `'disc_number'`: the album's disc number on which this track is (int) + - `'length'`: length of the track ([datetime.timedelta](https://docs.python.org/3/library/datetime.html#timedelta-objects)) + - `'title'`: title of the track (str) + - `'track_id'`: Spotify track ID (str) + - `'track_number'`: number of the track within the album disc (int) + - `'track_url'`: URL to the track at `https://open.spotify.com/track/` (str) + + Raises + ------ + `SpotifyNotRunningException` + When the spotify executable is not running. + `NoActiveTrackException` + When there is no currently active track. + """ + + interface = _get_interface(_INTERFACE_NAME_PROPERTIES) + + if player_get_playback_status() == "stopped": + raise NoActiveTrackException( + "Failed to get current track metadata: no active track." + ) + + # Get metadata as a dbus.Dictionary + raw = interface.Get(_INTERFACE_NAME_PLAYER, "Metadata") + + # HACK manually transfer metadata into a normal python dict + metadata = dict() + metadata["track_id"] = str(raw["mpris:trackid"]).split("/")[-1] + metadata["length"] = timedelta(microseconds=int(str(raw["mpris:length"]))) + metadata["artwork_url"] = str(raw["mpris:artUrl"]) + metadata["album_name"] = str(raw["xesam:album"]) + metadata["album_artist_names"] = [str(name) for name in raw["xesam:albumArtist"]] + metadata["artist_names"] = [str(name) for name in raw["xesam:artist"]] + metadata["album_name"] = str(raw["xesam:album"]) + metadata["auto_rating"] = float(raw["xesam:autoRating"]) + metadata["disc_number"] = int(raw["xesam:discNumber"]) + metadata["track_number"] = int(raw["xesam:trackNumber"]) + metadata["title"] = str(raw["xesam:title"]) + metadata["track_url"] = str(raw["xesam:url"]) + + return metadata