class SpotifyWrapper: SEARCH_LIMIT = 50 def __init__(self, token): self.token = token self.spotify = Spotify(auth=self.token) def search_songs(self, q, limit=SEARCH_LIMIT): results = self.spotify.search(q, limit=limit, type='track', market=None) sr = SearchResult(results) return sr.get_data() def valid_token(self): try: self.spotify.search('test call', limit=1, type='track') print('Token OK') return True except SpotifyException as e: print('INVALID TOKEN') return False def get_playback_devices(self): devices = self.spotify.devices() print(devices) return devices @staticmethod def get_token(code): pass
def get_device(session: spotipy.Spotify): # print(session.devices()) # TODO: need to guess the device id of the web player? devices = session.devices()['devices'] # print(devices) for device in devices: if device['name'] == 'Web Playback SDK Quick Start Player': # if device['name'] == 'ONEPLUS A5010': return device['id']
def get_playback_devices(): user_id = request.args.get('spotify_id') if not user_id: return abort(400, 'Did not specify spotify id') db = Database(Config.DATABASE_PATH) token = db.get_user_token(user_id) if token: spotify = Spotify(auth=token) devices = spotify.devices() return create_response(devices) return abort(401, 'Authentication required')
from spotipy import Spotify from spotipy.oauth2 import SpotifyOAuth from utils import env auth_manager = SpotifyOAuth(client_id=env.spotify_client_id, client_secret=env.spotify_client_secret, redirect_uri=env.spotify_redirect_uri, scope=env.spotify_api_scope, username=env.spotify_username) spotify = Spotify(auth_manager=auth_manager) devices = spotify.devices() device_id = None for d in devices['devices']: d['name'] = d['name'].replace('’', '\'') if d['name'] == env.spotify_device_name: device_id = d['id'] break selected_track = None class InvalidSearchError(Exception): pass def get_track_uri(name): original = name
class SpotifyMediaPlayer(MediaPlayerEntity): """Representation of a Spotify controller.""" def __init__( self, session: OAuth2Session, spotify: Spotify, me: dict, user_id: str, name: str, ): """Initialize.""" self._id = user_id self._me = me self._name = f"Spotify {name}" self._session = session self._spotify = spotify self._scope_ok = set( session.token["scope"].split(" ")) == set(SPOTIFY_SCOPES) self._currently_playing: Optional[dict] = {} self._devices: Optional[List[dict]] = [] self._playlist: Optional[dict] = None self._spotify: Spotify = None self.player_available = False @property def name(self) -> str: """Return the name.""" return self._name @property def icon(self) -> str: """Return the icon.""" return ICON @property def available(self) -> bool: """Return True if entity is available.""" return self.player_available @property def unique_id(self) -> str: """Return the unique ID.""" return self._id @property def device_info(self) -> Dict[str, Any]: """Return device information about this entity.""" if self._me is not None: model = self._me["product"] return { "identifiers": {(DOMAIN, self._id)}, "manufacturer": "Spotify AB", "model": f"Spotify {model}".rstrip(), "name": self._name, } @property def state(self) -> Optional[str]: """Return the playback state.""" if not self._currently_playing: return STATE_IDLE if self._currently_playing["is_playing"]: return STATE_PLAYING return STATE_PAUSED @property def volume_level(self) -> Optional[float]: """Return the device volume.""" return self._currently_playing.get("device", {}).get( "volume_percent", 0) / 100 @property def media_content_id(self) -> Optional[str]: """Return the media URL.""" item = self._currently_playing.get("item") or {} return item.get("uri") @property def media_content_type(self) -> Optional[str]: """Return the media type.""" return MEDIA_TYPE_MUSIC @property def media_duration(self) -> Optional[int]: """Duration of current playing media in seconds.""" if self._currently_playing.get("item") is None: return None return self._currently_playing["item"]["duration_ms"] / 1000 @property def media_position(self) -> Optional[str]: """Position of current playing media in seconds.""" if not self._currently_playing: return None return self._currently_playing["progress_ms"] / 1000 @property def media_position_updated_at(self) -> Optional[dt.datetime]: """When was the position of the current playing media valid.""" if not self._currently_playing: return None return utc_from_timestamp(self._currently_playing["timestamp"] / 1000) @property def media_image_url(self) -> Optional[str]: """Return the media image URL.""" if (self._currently_playing.get("item") is None or not self._currently_playing["item"]["album"]["images"]): return None return fetch_image_url(self._currently_playing["item"]["album"]) @property def media_image_remotely_accessible(self) -> bool: """If the image url is remotely accessible.""" return False @property def media_title(self) -> Optional[str]: """Return the media title.""" item = self._currently_playing.get("item") or {} return item.get("name") @property def media_artist(self) -> Optional[str]: """Return the media artist.""" if self._currently_playing.get("item") is None: return None return ", ".join([ artist["name"] for artist in self._currently_playing["item"]["artists"] ]) @property def media_album_name(self) -> Optional[str]: """Return the media album.""" if self._currently_playing.get("item") is None: return None return self._currently_playing["item"]["album"]["name"] @property def media_track(self) -> Optional[int]: """Track number of current playing media, music track only.""" item = self._currently_playing.get("item") or {} return item.get("track_number") @property def media_playlist(self): """Title of Playlist currently playing.""" if self._playlist is None: return None return self._playlist["name"] @property def source(self) -> Optional[str]: """Return the current playback device.""" return self._currently_playing.get("device", {}).get("name") @property def source_list(self) -> Optional[List[str]]: """Return a list of source devices.""" if not self._devices: return None return [device["name"] for device in self._devices] @property def shuffle(self) -> bool: """Shuffling state.""" return bool(self._currently_playing.get("shuffle_state")) @property def supported_features(self) -> int: """Return the media player features that are supported.""" if self._me["product"] != "premium": return 0 return SUPPORT_SPOTIFY @spotify_exception_handler def set_volume_level(self, volume: int) -> None: """Set the volume level.""" self._spotify.volume(int(volume * 100)) @spotify_exception_handler def media_play(self) -> None: """Start or resume playback.""" self._spotify.start_playback() @spotify_exception_handler def media_pause(self) -> None: """Pause playback.""" self._spotify.pause_playback() @spotify_exception_handler def media_previous_track(self) -> None: """Skip to previous track.""" self._spotify.previous_track() @spotify_exception_handler def media_next_track(self) -> None: """Skip to next track.""" self._spotify.next_track() @spotify_exception_handler def media_seek(self, position): """Send seek command.""" self._spotify.seek_track(int(position * 1000)) @spotify_exception_handler def play_media(self, media_type: str, media_id: str, **kwargs) -> None: """Play media.""" kwargs = {} # Spotify can't handle URI's with query strings or anchors # Yet, they do generate those types of URI in their official clients. media_id = str(URL(media_id).with_query(None).with_fragment(None)) if media_type in (MEDIA_TYPE_TRACK, MEDIA_TYPE_EPISODE, MEDIA_TYPE_MUSIC): kwargs["uris"] = [media_id] elif media_type in PLAYABLE_MEDIA_TYPES: kwargs["context_uri"] = media_id else: _LOGGER.error("Media type %s is not supported", media_type) return if not self._currently_playing.get("device") and self._devices: kwargs["device_id"] = self._devices[0].get("id") self._spotify.start_playback(**kwargs) @spotify_exception_handler def select_source(self, source: str) -> None: """Select playback device.""" for device in self._devices: if device["name"] == source: self._spotify.transfer_playback(device["id"], self.state == STATE_PLAYING) return @spotify_exception_handler def set_shuffle(self, shuffle: bool) -> None: """Enable/Disable shuffle mode.""" self._spotify.shuffle(shuffle) @spotify_exception_handler def update(self) -> None: """Update state and attributes.""" if not self.enabled: return if not self._session.valid_token or self._spotify is None: run_coroutine_threadsafe(self._session.async_ensure_token_valid(), self.hass.loop).result() self._spotify = Spotify(auth=self._session.token["access_token"]) current = self._spotify.current_playback() self._currently_playing = current or {} self._playlist = None context = self._currently_playing.get("context") if context is not None and context["type"] == MEDIA_TYPE_PLAYLIST: self._playlist = self._spotify.playlist(current["context"]["uri"]) devices = self._spotify.devices() or {} self._devices = devices.get("devices", []) async def async_browse_media(self, media_content_type=None, media_content_id=None): """Implement the websocket media browsing helper.""" if not self._scope_ok: raise NotImplementedError if media_content_type in [None, "library"]: return await self.hass.async_add_executor_job(library_payload) payload = { "media_content_type": media_content_type, "media_content_id": media_content_id, } response = await self.hass.async_add_executor_job( build_item_response, self._spotify, self._me, payload) if response is None: raise BrowseError( f"Media not found: {media_content_type} / {media_content_id}") return response
def main(): parser = argparse.ArgumentParser( description="Control Spotify Connect devices using MPRIS2") parser.add_argument('-d', '--devices', nargs='+', metavar="DEVICE", help="Only create interfaces for the listed devices") parser.add_argument('-i', '--ignore', nargs='+', metavar="DEVICE", help="Ignore the listed devices") parser.add_argument('-a', '--auto', action="store_true", help="Automatically control the active device") parser.add_argument('-l', '--list', nargs='?', choices=["name", "id"], const="name", help="List available devices and exit") parser.add_argument( '-s', '--steal-bus', action="store_true", help="Steal the dbus bus name from spotify to prevent " "it from also offering an MPRIS2 interface. If --auto is used use the spotify bus name as own " "bus name (experimental)") args = parser.parse_args() MediaPlayer2.dbus = [ pkg_resources.resource_string(__name__, f"mpris/{iface}.xml").decode('utf-8') for iface in ifaces ] loop = GLib.MainLoop() oauth = authenticate() sp = Spotify(oauth_manager=oauth) if args.list: devices = sp.devices() for devices in devices["devices"]: print(devices[args.list]) return exclusive_count = 0 for arg in [args.devices, args.ignore, args.auto]: if arg: exclusive_count += 1 if exclusive_count >= 2: parser.error( "Only one of --devices, --ignore and --auto can be used at the same time" ) return if args.steal_bus: bus = SessionBus() try: # This sets the bus name for the SessionBus singleton which is also used by SingleBusManager bus.request_name("org.mpris.MediaPlayer2.spotify", allow_replacement=False, replace=True) except RuntimeError: print( "Failed to steal spotify bus name. You need to start spotPRIS2 before spotify" ) exit(1) if not args.auto: manager = MultiBusManager(sp, args.devices, args.ignore) else: if args.steal_bus: manager = SingleBusManager(sp, bus=bus) else: manager = SingleBusManager(sp) def timeout_handler(): try: manager.main_loop() except Exception as e: print(e) finally: return True GLib.timeout_add_seconds(1, timeout_handler) try: loop.run() except KeyboardInterrupt: pass
class SpotifyService(MusicService): """ Spotify client music service. """ def __init__(self, state, client_id=None, client_secret=None, redirect_uri=None, device_name=None): """ @see Service.__init__() For the real meanings of the kwargs, read the Spotipy documentation: https://spotipy.readthedocs.io You can also set them as the environment variables:: * ``SPOTIPY_CLIENT_ID`` * ``SPOTIPY_CLIENT_SECRET`` * ``SPOTIPY_REDIRECT_URI`` :type client_id: str :param client_id: The Spotify client ID. :type client_secret: str :param client_secret: The Spotify client secret. :type redirect_uri: str :param redirect_uri: The Spotify redirect URI. :type device_name: str :param device_name: The name of the device to control, if not the default one. """ super(SpotifyService, self).__init__("SpotifyService", state, "Spotify") self._client_id = client_id self._client_secret = client_secret self._redirect_uri = redirect_uri self._device_name = device_name self._device_id = None self._spotify = None self._volume = None def set_volume(self, volume): """ @see MusicService.set_volume() """ if MIN_VOLUME <= volume <= MAX_VOLUME: self._volume = volume self._spotify.volume(100.0 * (volume - MIN_VOLUME) / (MAX_VOLUME - MIN_VOLUME), device_id=self._device_id) else: raise ValueError("Bad volume: %s", volume) def get_volume(): """ @see MusicService.get_volume() """ return self._volume def is_playing(self): """ Whether the player is playing. :rtype: bool :return: Whether the player is playing. """ try: cur = self._spotify.currently_playing() return cur['is_playing'] except: return False def play(self, uris): """ Set the list of URIs playing. """ LOG.info("Playing: %s", ' '.join(uris)) self._spotify.start_playback(uris=uris, device_id=self._device_id) def pause(self): """ Pause any currently playing music. """ self._spotify.pause_playback(device_id=self._device_id) def unpause(self): """ Resume any currently paused music. """ self._spotify.start_playback(device_id=self._device_id) def _start(self): """ @see Startable._start() """ # This is what we need to be able to do scope = ','.join(('user-library-read', 'user-read-playback-state', 'user-modify-playback-state')) # Create the authorization manager, and then use that to create the # client auth_manager = SpotifyOAuth(client_id=self._client_id, client_secret=self._client_secret, redirect_uri=self._redirect_uri, scope=scope) self._spotify = Spotify(auth_manager=auth_manager) # See what devices we have to hand try: if self._device_name is not None: LOG.info("Looking for device named '%s'", self._device_name) devices = self._spotify.devices() for device in devices['devices']: # Say what we see name = device['name'] id_ = device['id'] type_ = device['type'] active = device['is_active'] vol = device['volume_percent'] LOG.info("Found %sactive %s device: '%s'", '' if active else 'in', type_, name) # See if we're looking for a specific device, if not just snoop # the volume from the first active one if self._device_name is not None: if name == self._device_name: LOG.info("Matched '%s' to ID '%s'", name, id_) self._device_id = id_ self._volume = vol / 100.0 * MAX_VOLUME else: if active and self._volume is None: self._volume = vol / 100.0 * MAX_VOLUME except Exception as e: LOG.warning("Unable to determine active Spoify devices: %s", e) # If we were looking for a specific device then make sure that we found # it in the list if self._device_name is not None and self._device_id is None: raise ValueError("Failed to find device with name '%s'" % (self._device_name, )) def _stop(self): """ @see Startable._stop() """ try: self._spotify.pause_playback(device_id=self._device_id) except: # Best effort pass def _match_artist(self, artist): """ @see MusicService._match_artist() """ artist = ' '.join(artist).lower() LOG.debug("Matching artist '%s'", artist) result = self._spotify.search(artist, type='artist') if 'artists' in result and 'items' in result['artists']: items = result['artists']['items'] LOG.debug("Checking %d results", len(items)) for item in items: name = item.get('name', '').lower() LOG.debug("Matching against '%s'", name) if fuzz.ratio(name, artist) > 80: return True return False def _get_stop_handler(self, tokens): """ @see MusicService._get_stop_handler() """ return _SpotifyServicePauseHandler(self, tokens) def _get_play_handler(self, tokens): """ @see MusicService._get_play_handler() """ return _SpotifyServiceUnpauseHandler(self, tokens) def _get_toggle_pause_handler(self, tokens): """ @see MusicService._get_toggle_pause_handler() """ return _SpotifyServiceTogglePauseHandler(self, tokens) def _get_handler_for(self, tokens, platform_match, genre, artist, song_or_album): """ @see MusicService._get_handler_for() """ # Do nothing if we have no name if song_or_album is None or len(song_or_album) == 0: return None # Normalise to strings name = ' '.join(song_or_album).lower() if artist is None or len(artist) == 0: artist = None else: artist = ' '.join(artist).lower() # We will put all the track URIs in here uris = [] # Search by track name then album name, these are essentially the same # logic for which in ('track', 'album'): LOG.info("Looking for '%s'%s as a %s", name, " by '%s'" % artist if artist else '', which) # This is the key in the results plural = which + 's' # Try using the song_or_album as the name result = self._spotify.search(name, type=which) if not result: LOG.info("No results") continue # Did we get back any tracks if plural not in result: LOG.error("%s was not in result keys: %s", plural, result.keys()) continue # We got some results back, let's assign scores to them all results = result[plural] matches = [] for item in results.get('items', []): # It must have a uri if 'uri' not in item and item['uri']: LOG.error("No URI in %s", item) # Look at all the candidate entries if 'name' in item: # See if this is better than any existing match name_score = fuzz.ratio(name, item['name'].lower()) LOG.debug("'%s' matches '%s' with score %d", item['name'], name, name_score) # Check to make sure that we have an artist match as well if artist is None: # Treat as a wildcard artist_score = 100 else: artist_score = 0 for entry in item.get('artists', []): score = fuzz.ratio(artist, entry.get('name', '').lower()) LOG.debug("Artist match score for '%s' was %d", entry.get('name', ''), score) if score > artist_score: artist_score = score LOG.debug("Artist match score was %d", artist_score) # Only consider cases where the scores look "good enough" if name_score > 75 and artist_score > 75: LOG.debug("Adding match") matches.append((item, name_score, artist_score)) # Anything? if len(matches) > 0: LOG.debug("Got %d matches", len(matches)) # Order them accordingly matches.sort(key=lambda e: (e[1], e[2])) # Now, pick the top one best = matches[0] item = best[0] LOG.debug("Best match was: %s", item) # Extract the info item_name = item.get('name', None) or name artists = item.get('artists', []) artist_name = (artists[0].get('name', None) if len(artists) > 0 else None) or artist # Description of what we are playing what = item_name if item_name else name if artist_name: what += " by " + artist_name what += " on Spotify" # The score is the geometric value of the two score = sqrt(best[1] * best[1] + best[2] * best[2]) / 100.0 # The should be here assert 'uri' in item, "Missing URI in %s" % (item, ) uri = item['uri'] # If we are an album then grab the track URIs if which == 'album': tracks = self._spotify.album_tracks(uri) if tracks and 'items' in tracks: uris = [track['uri'] for track in tracks['items']] else: # Just the track uris = [uri] # And we're done break # Otherwise assume that it's an artist if len(uris) == 0 and artist is None: LOG.info("Looking for '%s' as an artist", name) result = self._spotify.search(name, type='artist') LOG.debug("Got: %s", result) if result and 'artists' in result and 'items' in result['artists']: items = sorted(result['artists']['items'], key=lambda entry: fuzz.ratio( name, entry.get('name', '').lower()), reverse=True) # Look at the best one, if any LOG.debug("Got %d matches", len(items)) if len(items) > 0: match = items[0] who = match['name'] what = "%s on Spotify" % (who, ) score = fuzz.ratio(who.lower(), name) # Find all their albums if 'uri' in match: LOG.debug("Got match: %s", match['uri']) artist_albums = self._spotify.artist_albums( match['uri']) for album in artist_albums.get('items', []): # Append all the tracks LOG.debug("Looking at album: %s", album) if 'uri' in album: tracks = self._spotify.album_tracks( album['uri']) if tracks and 'items' in tracks: LOG.debug( "Adding tracks: %s", ' '.join(track['name'] for track in tracks['items'])) uris.extend([ track['uri'] for track in tracks['items'] ]) # And now we can give it back, if we had something if len(uris) > 0: return _SpotifyServicePlayHandler(self, tokens, what, uris, score) else: # We got nothing return None
class SpotifyMediaPlayer(MediaPlayerDevice): """Representation of a Spotify controller.""" def __init__(self, session, spotify: Spotify, me: dict, user_id: str, name: str): """Initialize.""" self._id = user_id self._me = me self._name = f"Spotify {name}" self._session = session self._spotify = spotify self._currently_playing: Optional[dict] = {} self._devices: Optional[List[dict]] = [] self._playlist: Optional[dict] = None self._spotify: Spotify = None self.player_available = False @property def name(self) -> str: """Return the name.""" return self._name @property def icon(self) -> str: """Return the icon.""" return ICON @property def available(self) -> bool: """Return True if entity is available.""" return self.player_available @property def unique_id(self) -> str: """Return the unique ID.""" return self._id @property def device_info(self) -> Dict[str, Any]: """Return device information about this entity.""" if self._me is not None: model = self._me["product"] return { "identifiers": {(DOMAIN, self._id)}, "manufacturer": "Spotify AB", "model": f"Spotify {model}".rstrip(), "name": self._name, } @property def state(self) -> Optional[str]: """Return the playback state.""" if not self._currently_playing: return STATE_IDLE if self._currently_playing["is_playing"]: return STATE_PLAYING return STATE_PAUSED @property def volume_level(self) -> Optional[float]: """Return the device volume.""" return self._currently_playing.get("device", {}).get( "volume_percent", 0) / 100 @property def media_content_id(self) -> Optional[str]: """Return the media URL.""" item = self._currently_playing.get("item") or {} return item.get("name") @property def media_content_type(self) -> Optional[str]: """Return the media type.""" return MEDIA_TYPE_MUSIC @property def media_duration(self) -> Optional[int]: """Duration of current playing media in seconds.""" if self._currently_playing.get("item") is None: return None return self._currently_playing["item"]["duration_ms"] / 1000 @property def media_position(self) -> Optional[str]: """Position of current playing media in seconds.""" if not self._currently_playing: return None return self._currently_playing["progress_ms"] / 1000 @property def media_position_updated_at(self) -> Optional[dt.datetime]: """When was the position of the current playing media valid.""" if not self._currently_playing: return None return utc_from_timestamp(self._currently_playing["timestamp"] / 1000) @property def media_image_url(self) -> Optional[str]: """Return the media image URL.""" if (self._currently_playing.get("item") is None or not self._currently_playing["item"]["album"]["images"]): return None return self._currently_playing["item"]["album"]["images"][0]["url"] @property def media_image_remotely_accessible(self) -> bool: """If the image url is remotely accessible.""" return False @property def media_title(self) -> Optional[str]: """Return the media title.""" item = self._currently_playing.get("item") or {} return item.get("name") @property def media_artist(self) -> Optional[str]: """Return the media artist.""" if self._currently_playing.get("item") is None: return None return ", ".join([ artist["name"] for artist in self._currently_playing["item"]["artists"] ]) @property def media_album_name(self) -> Optional[str]: """Return the media album.""" if self._currently_playing.get("item") is None: return None return self._currently_playing["item"]["album"]["name"] @property def media_track(self) -> Optional[int]: """Track number of current playing media, music track only.""" item = self._currently_playing.get("item") or {} return item.get("track_number") @property def media_playlist(self): """Title of Playlist currently playing.""" if self._playlist is None: return None return self._playlist["name"] @property def source(self) -> Optional[str]: """Return the current playback device.""" return self._currently_playing.get("device", {}).get("name") @property def source_list(self) -> Optional[List[str]]: """Return a list of source devices.""" if not self._devices: return None return [device["name"] for device in self._devices] @property def shuffle(self) -> bool: """Shuffling state.""" return bool(self._currently_playing.get("shuffle_state")) @property def supported_features(self) -> int: """Return the media player features that are supported.""" if self._me["product"] != "premium": return 0 return SUPPORT_SPOTIFY @spotify_exception_handler def set_volume_level(self, volume: int) -> None: """Set the volume level.""" self._spotify.volume(int(volume * 100)) @spotify_exception_handler def media_play(self) -> None: """Start or resume playback.""" self._spotify.start_playback() @spotify_exception_handler def media_pause(self) -> None: """Pause playback.""" self._spotify.pause_playback() @spotify_exception_handler def media_previous_track(self) -> None: """Skip to previous track.""" self._spotify.previous_track() @spotify_exception_handler def media_next_track(self) -> None: """Skip to next track.""" self._spotify.next_track() @spotify_exception_handler def media_seek(self, position): """Send seek command.""" self._spotify.seek_track(int(position * 1000)) @spotify_exception_handler def play_media(self, media_type: str, media_id: str, **kwargs) -> None: """Play media.""" kwargs = {} if media_type == MEDIA_TYPE_MUSIC: kwargs["uris"] = [media_id] elif media_type == MEDIA_TYPE_PLAYLIST: kwargs["context_uri"] = media_id else: _LOGGER.error("Media type %s is not supported", media_type) return self._spotify.start_playback(**kwargs) @spotify_exception_handler def select_source(self, source: str) -> None: """Select playback device.""" for device in self._devices: if device["name"] == source: self._spotify.transfer_playback(device["id"], self.state == STATE_PLAYING) return @spotify_exception_handler def set_shuffle(self, shuffle: bool) -> None: """Enable/Disable shuffle mode.""" self._spotify.shuffle(shuffle) @spotify_exception_handler def update(self) -> None: """Update state and attributes.""" if not self.enabled: return if not self._session.valid_token or self._spotify is None: run_coroutine_threadsafe(self._session.async_ensure_token_valid(), self.hass.loop).result() self._spotify = Spotify(auth=self._session.token["access_token"]) current = self._spotify.current_playback() self._currently_playing = current or {} self._playlist = None context = self._currently_playing.get("context") if context is not None and context["type"] == MEDIA_TYPE_PLAYLIST: self._playlist = self._spotify.playlist(current["context"]["uri"]) devices = self._spotify.devices() or {} self._devices = devices.get("devices", [])
class SpotifyMediaPlayer(MediaPlayerEntity): """Representation of a Spotify controller.""" _attr_icon = "mdi:spotify" _attr_media_content_type = MEDIA_TYPE_MUSIC _attr_media_image_remotely_accessible = False def __init__( self, session: OAuth2Session, spotify: Spotify, me: dict, # pylint: disable=invalid-name user_id: str, name: str, ) -> None: """Initialize.""" self._id = user_id self._me = me self._name = f"Spotify {name}" self._session = session self._spotify = spotify self._scope_ok = set( session.token["scope"].split(" ")).issuperset(SPOTIFY_SCOPES) self._currently_playing: dict | None = {} self._devices: list[dict] | None = [] self._playlist: dict | None = None self._attr_name = self._name self._attr_unique_id = user_id self._attr_last_selected = None self._attributes = {} @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" model = "Spotify Free" if self._me is not None: product = self._me["product"] model = f"Spotify {product}" return DeviceInfo( identifiers={(DOMAIN, self._id)}, manufacturer="Spotify AB", model=model, name=self._name, entry_type=DeviceEntryType.SERVICE, configuration_url="https://open.spotify.com", ) @property def state(self) -> str | None: """Return the playback state.""" if not self._currently_playing: return STATE_IDLE if self._currently_playing["is_playing"]: return STATE_PLAYING return STATE_PAUSED @property def volume_level(self) -> float | None: """Return the device volume.""" return self._currently_playing.get("device", {}).get( "volume_percent", 0) / 100 @property def media_content_id(self) -> str | None: """Return the media URL.""" item = self._currently_playing.get("item") or {} return item.get("uri") @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" if self._currently_playing.get("item") is None: return None return self._currently_playing["item"]["duration_ms"] / 1000 @property def media_position(self) -> str | None: """Position of current playing media in seconds.""" if not self._currently_playing: return None return self._currently_playing["progress_ms"] / 1000 @property def media_position_updated_at(self) -> dt.datetime | None: """When was the position of the current playing media valid.""" if not self._currently_playing: return None return utc_from_timestamp(self._currently_playing["timestamp"] / 1000) @property def media_image_url(self) -> str | None: """Return the media image URL.""" if (self._currently_playing.get("item") is None or not self._currently_playing["item"]["album"]["images"]): return None return fetch_image_url(self._currently_playing["item"]["album"]) @property def media_title(self) -> str | None: """Return the media title.""" item = self._currently_playing.get("item") or {} return item.get("name") @property def media_artist(self) -> str | None: """Return the media artist.""" if self._currently_playing.get("item") is None: return None return ", ".join( artist["name"] for artist in self._currently_playing["item"]["artists"]) @property def media_album_name(self) -> str | None: """Return the media album.""" if self._currently_playing.get("item") is None: return None return self._currently_playing["item"]["album"]["name"] @property def media_track(self) -> int | None: """Track number of current playing media, music track only.""" item = self._currently_playing.get("item") or {} return item.get("track_number") @property def media_playlist(self): """Title of Playlist currently playing.""" if self._playlist is None: return None return self._playlist["name"] @property def source(self) -> str | None: """Return the current playback device.""" return self._currently_playing.get("device", {}).get("name") @property def source_list(self) -> list[str] | None: """Return a list of source devices.""" if not self._devices: return None return [device["name"] for device in self._devices] @property def shuffle(self) -> bool: """Shuffling state.""" return bool(self._currently_playing.get("shuffle_state")) @property def repeat(self) -> str | None: """Return current repeat mode.""" repeat_state = self._currently_playing.get("repeat_state") return REPEAT_MODE_MAPPING_TO_HA.get(repeat_state) @property def supported_features(self) -> int: """Return the media player features that are supported.""" if self._me["product"] != "premium": return 0 return SUPPORT_SPOTIFY @property def extra_state_attributes(self): """Return entity specific state attributes.""" return self._attributes @spotify_exception_handler def set_volume_level(self, volume: int) -> None: """Set the volume level.""" self._spotify.volume(int(volume * 100)) @spotify_exception_handler def media_play(self) -> None: """Start or resume playback.""" self._spotify.start_playback() @spotify_exception_handler def media_pause(self) -> None: """Pause playback.""" self._spotify.pause_playback() @spotify_exception_handler def media_previous_track(self) -> None: """Skip to previous track.""" self._spotify.previous_track() @spotify_exception_handler def media_next_track(self) -> None: """Skip to next track.""" self._spotify.next_track() @spotify_exception_handler def media_seek(self, position): """Send seek command.""" self._spotify.seek_track(int(position * 1000)) @spotify_exception_handler def play_media(self, media_type: str, media_id: str, **kwargs) -> None: """Play media.""" kwargs = {} # Spotify can't handle URI's with query strings or anchors # Yet, they do generate those types of URI in their official clients. media_id = str(URL(media_id).with_query(None).with_fragment(None)) self._attr_last_selected = media_id self._attributes[ATTR_LAST_SELECTED] = self._attr_last_selected self.update() if media_type in (MEDIA_TYPE_TRACK, MEDIA_TYPE_EPISODE, MEDIA_TYPE_MUSIC): kwargs["uris"] = [media_id] elif media_type in PLAYABLE_MEDIA_TYPES: kwargs["context_uri"] = media_id else: _LOGGER.error("Media type %s is not supported", media_type) return if not self._currently_playing.get("device") and self._devices: kwargs["device_id"] = self._devices[0].get("id") self._spotify.start_playback(**kwargs) self.update() @spotify_exception_handler def select_source(self, source: str) -> None: """Select playback device.""" for device in self._devices: if device["name"] == source: self._spotify.transfer_playback(device["id"], self.state == STATE_PLAYING) return @spotify_exception_handler def set_shuffle(self, shuffle: bool) -> None: """Enable/Disable shuffle mode.""" self._spotify.shuffle(shuffle) @spotify_exception_handler def set_repeat(self, repeat: str) -> None: """Set repeat mode.""" if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY: raise ValueError(f"Unsupported repeat mode: {repeat}") self._spotify.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat]) @spotify_exception_handler def update(self) -> None: """Update state and attributes.""" self._attributes[ATTR_LAST_SELECTED] = self._attr_last_selected if not self.enabled: return if not self._session.valid_token or self._spotify is None: run_coroutine_threadsafe(self._session.async_ensure_token_valid(), self.hass.loop).result() self._spotify = Spotify(auth=self._session.token["access_token"]) current = self._spotify.current_playback() self._currently_playing = current or {} self._playlist = None context = self._currently_playing.get("context") if context is not None and context["type"] == MEDIA_TYPE_PLAYLIST: self._playlist = self._spotify.playlist(current["context"]["uri"]) devices = self._spotify.devices() or {} self._devices = devices.get("devices", []) async def async_browse_media(self, media_content_type=None, media_content_id=None): """Implement the websocket media browsing helper.""" if not self._scope_ok: _LOGGER.debug( "Spotify scopes are not set correctly, this can impact features such as media browsing" ) raise NotImplementedError if media_content_type in (None, "library"): return await self.hass.async_add_executor_job(library_payload) payload = { "media_content_type": media_content_type, "media_content_id": media_content_id, } response = await self.hass.async_add_executor_job( build_item_response, self._spotify, self._me, payload) if response is None: raise BrowseError( f"Media not found: {media_content_type} / {media_content_id}") return response
class AuthTestSpotipy(unittest.TestCase): """ These tests require user authentication - provide client credentials using the following environment variables :: 'SPOTIPY_CLIENT_USERNAME' 'SPOTIPY_CLIENT_ID' 'SPOTIPY_CLIENT_SECRET' 'SPOTIPY_REDIRECT_URI' """ playlist = "spotify:user:plamere:playlist:2oCEWyyAPbZp9xhVSxZavx" playlist_new_id = "spotify:playlist:7GlxpQjjxRjmbb3RP2rDqI" four_tracks = [ "spotify:track:6RtPijgfPKROxEzTHNRiDp", "spotify:track:7IHOIqZUUInxjVkko181PB", "4VrWlk8IQxevMvERoX08iC", "http://open.spotify.com/track/3cySlItpiPiIAzU3NyHCJf" ] two_tracks = [ "spotify:track:6RtPijgfPKROxEzTHNRiDp", "spotify:track:7IHOIqZUUInxjVkko181PB" ] other_tracks = [ "spotify:track:2wySlB6vMzCbQrRnNGOYKa", "spotify:track:29xKs5BAHlmlX1u4gzQAbJ", "spotify:track:1PB7gRWcvefzu7t3LJLUlf" ] album_ids = [ "spotify:album:6kL09DaURb7rAoqqaA51KU", "spotify:album:6RTzC0rDbvagTSJLlY7AKl" ] bad_id = 'BAD_ID' @classmethod def setUpClass(self): if sys.version_info >= (3, 2): # >= Python3.2 only warnings.filterwarnings( "ignore", category=ResourceWarning, # noqa message="unclosed.*<ssl.SSLSocket.*>") missing = list(filter(lambda var: not os.getenv(CCEV[var]), CCEV)) if missing: raise Exception( ('Please set the client credentials for the test application' ' using the following environment variables: {}').format( CCEV.values())) self.username = os.getenv(CCEV['client_username']) self.scope = ('playlist-modify-public ' 'user-library-read ' 'user-follow-read ' 'user-library-modify ' 'user-read-private ' 'user-top-read ' 'user-follow-modify ' 'user-read-recently-played ' 'ugc-image-upload') self.token = prompt_for_user_token(self.username, scope=self.scope) self.spotify = Spotify(auth=self.token) # Helper def get_or_create_spotify_playlist(self, playlist_name): playlists = self.spotify.user_playlists(self.username) while playlists: for item in playlists['items']: if item['name'] == playlist_name: return item playlists = self.spotify.next(playlists) return self.spotify.user_playlist_create(self.username, playlist_name) # Helper def get_as_base64(self, url): import base64 return base64.b64encode(requests.get(url).content).decode("utf-8") def test_track_bad_id(self): try: self.spotify.track(self.bad_id) self.assertTrue(False) except SpotifyException: self.assertTrue(True) def test_basic_user_profile(self): user = self.spotify.user(self.username) self.assertTrue(user['id'] == self.username.lower()) def test_current_user(self): user = self.spotify.current_user() self.assertTrue(user['id'] == self.username.lower()) def test_me(self): user = self.spotify.me() self.assertTrue(user['id'] == self.username.lower()) def test_user_playlists(self): playlists = self.spotify.user_playlists(self.username, limit=5) self.assertTrue('items' in playlists) self.assertTrue(len(playlists['items']) == 5) def test_user_playlist_tracks(self): playlists = self.spotify.user_playlists(self.username, limit=5) self.assertTrue('items' in playlists) for playlist in playlists['items']: user = playlist['owner']['id'] pid = playlist['id'] results = self.spotify.user_playlist_tracks(user, pid) self.assertTrue(len(results['items']) >= 0) def test_current_user_saved_albums(self): # List albums = self.spotify.current_user_saved_albums() self.assertTrue(len(albums['items']) > 1) # Add self.spotify.current_user_saved_albums_add(self.album_ids) # Contains self.assertTrue( self.spotify.current_user_saved_albums_contains(self.album_ids) == [True, True]) # Remove self.spotify.current_user_saved_albums_delete(self.album_ids) albums = self.spotify.current_user_saved_albums() self.assertTrue(len(albums['items']) > 1) def test_current_user_playlists(self): playlists = self.spotify.current_user_playlists(limit=10) self.assertTrue('items' in playlists) self.assertTrue(len(playlists['items']) == 10) def test_user_playlist_follow(self): self.spotify.user_playlist_follow_playlist('plamere', '4erXB04MxwRAVqcUEpu30O') follows = self.spotify.user_playlist_is_following( 'plamere', '4erXB04MxwRAVqcUEpu30O', [self.spotify.current_user()['id']]) self.assertTrue(len(follows) == 1, 'proper follows length') self.assertTrue(follows[0], 'is following') self.spotify.user_playlist_unfollow('plamere', '4erXB04MxwRAVqcUEpu30O') follows = self.spotify.user_playlist_is_following( 'plamere', '4erXB04MxwRAVqcUEpu30O', [self.spotify.current_user()['id']]) self.assertTrue(len(follows) == 1, 'proper follows length') self.assertFalse(follows[0], 'is no longer following') def test_current_user_saved_tracks(self): tracks = self.spotify.current_user_saved_tracks() self.assertTrue(len(tracks['items']) > 0) def test_current_user_save_and_unsave_tracks(self): tracks = self.spotify.current_user_saved_tracks() total = tracks['total'] self.spotify.current_user_saved_tracks_add(self.four_tracks) tracks = self.spotify.current_user_saved_tracks() new_total = tracks['total'] self.assertTrue(new_total - total == len(self.four_tracks)) tracks = self.spotify.current_user_saved_tracks_delete( self.four_tracks) tracks = self.spotify.current_user_saved_tracks() new_total = tracks['total'] self.assertTrue(new_total == total) def test_categories(self): response = self.spotify.categories() self.assertTrue(len(response['categories']) > 0) def test_category_playlists(self): response = self.spotify.categories() for cat in response['categories']['items']: cat_id = cat['id'] response = self.spotify.category_playlists(category_id=cat_id) if len(response['playlists']["items"]) > 0: break self.assertTrue(True) def test_new_releases(self): response = self.spotify.new_releases() self.assertTrue(len(response['albums']) > 0) def test_featured_releases(self): response = self.spotify.featured_playlists() self.assertTrue(len(response['playlists']) > 0) def test_current_user_follows(self): response = self.spotify.current_user_followed_artists() artists = response['artists'] self.assertTrue(len(artists['items']) > 0) def test_current_user_top_tracks(self): response = self.spotify.current_user_top_tracks() items = response['items'] self.assertTrue(len(items) > 0) def test_current_user_top_artists(self): response = self.spotify.current_user_top_artists() items = response['items'] self.assertTrue(len(items) > 0) def test_current_user_recently_played(self): # No cursor res = self.spotify.current_user_recently_played() self.assertTrue(len(res['items']) <= 50) played_at = res['items'][0]['played_at'] # Using `before` gives tracks played before res = self.spotify.current_user_recently_played( before=res['cursors']['after']) self.assertTrue(len(res['items']) <= 50) self.assertTrue(res['items'][0]['played_at'] < played_at) played_at = res['items'][0]['played_at'] # Using `after` gives tracks played after res = self.spotify.current_user_recently_played( after=res['cursors']['before']) self.assertTrue(len(res['items']) <= 50) self.assertTrue(res['items'][0]['played_at'] > played_at) def test_user_playlist_ops(self): sp = self.spotify # create empty playlist playlist = self.get_or_create_spotify_playlist( 'spotipy-testing-playlist-1') playlist_id = playlist['id'] # remove all tracks from it sp.user_playlist_replace_tracks(self.username, playlist_id, []) playlist = sp.user_playlist(self.username, playlist_id) self.assertTrue(playlist['tracks']['total'] == 0) self.assertTrue(len(playlist['tracks']['items']) == 0) # add tracks to it sp.user_playlist_add_tracks(self.username, playlist_id, self.four_tracks) playlist = sp.user_playlist(self.username, playlist_id) self.assertTrue(playlist['tracks']['total'] == 4) self.assertTrue(len(playlist['tracks']['items']) == 4) # remove two tracks from it sp.user_playlist_remove_all_occurrences_of_tracks( self.username, playlist_id, self.two_tracks) playlist = sp.user_playlist(self.username, playlist_id) self.assertTrue(playlist['tracks']['total'] == 2) self.assertTrue(len(playlist['tracks']['items']) == 2) # replace with 3 other tracks sp.user_playlist_replace_tracks(self.username, playlist_id, self.other_tracks) playlist = sp.user_playlist(self.username, playlist_id) self.assertTrue(playlist['tracks']['total'] == 3) self.assertTrue(len(playlist['tracks']['items']) == 3) def test_playlist(self): # New playlist ID pl = self.spotify.playlist(self.playlist_new_id) self.assertTrue(pl["tracks"]["total"] > 0) # Old playlist ID pl = self.spotify.playlist(self.playlist) self.assertTrue(pl["tracks"]["total"] > 0) def test_playlist_tracks(self): # New playlist ID pl = self.spotify.playlist_tracks(self.playlist_new_id, limit=2) self.assertTrue(len(pl["items"]) == 2) self.assertTrue(pl["total"] > 0) # Old playlist ID pl = self.spotify.playlist_tracks(self.playlist, limit=2) self.assertTrue(len(pl["items"]) == 2) self.assertTrue(pl["total"] > 0) def test_playlist_upload_cover_image(self): pl1 = self.get_or_create_spotify_playlist('spotipy-testing-playlist-1') plid = pl1['uri'] old_b64 = pl1['images'][0]['url'] # Upload random dog image r = requests.get('https://dog.ceo/api/breeds/image/random') dog_base64 = self.get_as_base64(r.json()['message']) self.spotify.playlist_upload_cover_image(plid, dog_base64) # Image must be different pl1 = self.spotify.playlist(plid) new_b64 = self.get_as_base64(pl1['images'][0]['url']) self.assertTrue(old_b64 != new_b64) def test_playlist_cover_image(self): pl = self.get_or_create_spotify_playlist('spotipy-testing-playlist-1') plid = pl['uri'] res = self.spotify.playlist_cover_image(plid) self.assertTrue(len(res) > 0) first_image = res[0] self.assertTrue('width' in first_image) self.assertTrue('height' in first_image) self.assertTrue('url' in first_image) def test_user_follows_and_unfollows_artist(self): # Initially follows 1 artist res = self.spotify.current_user_followed_artists() self.assertTrue(res['artists']['total'] == 1) # Follow 2 more artists artists = ["6DPYiyq5kWVQS4RGwxzPC7", "0NbfKEOTQCcwd6o7wSDOHI"] self.spotify.user_follow_artists(artists) res = self.spotify.current_user_followed_artists() self.assertTrue(res['artists']['total'] == 3) # Unfollow these 2 artists self.spotify.user_unfollow_artists(artists) res = self.spotify.current_user_followed_artists() self.assertTrue(res['artists']['total'] == 1) def test_user_follows_and_unfollows_user(self): # TODO improve after implementing `me/following/contains` users = ["11111204", "xlqeojt6n7on0j7coh9go8ifd"] # Follow 2 more users self.spotify.user_follow_users(users) # Unfollow these 2 users self.spotify.user_unfollow_users(users) def test_deprecated_starred(self): pl = self.spotify.user_playlist(self.username) self.assertTrue(pl["tracks"] is None) self.assertTrue(pl["owner"] is None) def test_deprecated_user_playlist(self): # Test without user due to change from # https://developer.spotify.com/community/news/2018/06/12/changes-to-playlist-uris/ pl = self.spotify.user_playlist(None, self.playlist) self.assertTrue(pl["tracks"]["total"] > 0) def test_deprecated_user_playlis(self): # Test without user due to change from # https://developer.spotify.com/community/news/2018/06/12/changes-to-playlist-uris/ pl = self.spotify.user_playlist_tracks(None, self.playlist, limit=2) self.assertTrue(len(pl["items"]) == 2) self.assertTrue(pl["total"] > 0) def test_devices(self): # No devices playing by default res = self.spotify.devices() self.assertEqual(len(res["devices"]), 0)
class _ThingSpotifyImpl(_ThingSpotifyDummy): def __init__(self, api_base_url, tok): super().__init__(api_base_url) self._sp = Spotify(auth=tok) self.unmuted_vol_pct = 0 self.volume_up_pct_delta = 10 self.status_cache_seconds = 10 self.last_status = None self.last_status_t = 0 def playpause(self): if self._is_active(): self._sp.pause_playback() else: self._sp.start_playback() def stop(self): self._sp.pause_playback() def play_next_in_queue(self): self._sp.next_track() def play_prev_in_queue(self): # First 'prev' just moves playtime back to 0 self._sp.previous_track() self._sp.previous_track() def set_playtime(self, t): if not self._is_active(): return self._sp.seek_track(int(t) * 1000) def volume_up(self): if not self._is_active(): return vol = self._get_volume_pct() + self.volume_up_pct_delta if vol > 100: vol = 100 self.set_volume_pct(vol) def volume_down(self): if not self._is_active(): return vol = self._get_volume_pct() - self.volume_up_pct_delta if vol < 0: vol = 0 self.set_volume_pct(vol) def set_volume_pct(self, pct): if not self._is_active(): return self._sp.volume(int(pct)) def toggle_mute(self): if not self._is_active(): return vol = self._get_volume_pct() if vol == 0: self.set_volume_pct(self.unmuted_vol_pct) else: self.unmuted_vol_pct = vol self.set_volume_pct(0) def play_in_device(self, dev_name): devs = self._sp.devices()['devices'] for dev in devs: if dev['name'] == dev_name: self._sp.transfer_playback(dev['id']) if self.json_status(nocache=True)['player_state'] != 'Playing': self.playpause() return raise KeyError("Spotify knows no device called {}".format(dev_name)) def _get_volume_pct(self): l = [ x for x in self._sp.devices()['devices'] if x['is_active'] == True ] if len(l) == 0: return 0 return l[0]['volume_percent'] def _is_active(self): track = self._sp.current_user_playing_track() return (track is not None) and track['is_playing'] def json_status(self, nocache=False): global LAST_ACTIVE_DEVICE if nocache == False and self.last_status is not None: if time.time() - self.last_status_t < self.status_cache_seconds: logger.debug("Return Spotify status from cache") return self.last_status self.last_status_t = time.time() devices = self._sp.devices()['devices'] active_dev = None if len(devices) > 0: act_devs = [x for x in devices if x['is_active'] == True] if len(act_devs) > 0: active_dev = act_devs[0] if active_dev is not None: LAST_ACTIVE_DEVICE = active_dev['name'] vol = active_dev['volume_percent'] if active_dev is not None else 0 track = self._sp.current_user_playing_track() is_active = (track is not None) and track['is_playing'] self.last_status = { 'name': self.get_id(), 'uri': None, 'active_device': active_dev['name'] if active_dev is not None else None, 'last_active_device': LAST_ACTIVE_DEVICE, 'available_devices': [x['name'] for x in devices], 'app': None, 'volume_pct': vol, 'volume_muted': (vol == 0), 'player_state': 'Playing' if is_active else 'Idle', 'media': None, } if track is None or track['item'] is None: return self.last_status # Get all cover images sorted by image size imgs = [] try: imgs = [(img['height'] * img['width'], img['url']) for img in track['item']['album']['images']] imgs.sort() except: pass # Pick an image that's at least 300*300 (or the biggest, if all are smaller) selected_img = None for img in imgs: area, selected_img = img if area >= 90000: break self.last_status['media'] = { 'icon': selected_img, 'title': track['item']['name'], 'duration': track['item']['duration_ms'] / 1000, 'current_time': track['progress_ms'] / 1000, 'spotify_metadata': { 'artist': ', '.join( [x['name'] for x in track['item']['album']['artists']]), 'album_link': track['item']['album']['external_urls']['spotify'], 'album_name': track['item']['album']['name'], 'track_count': track['item']['album']['total_tracks'], 'current_track': track['item']['track_number'], } } return self.last_status
def main(): parser = argparse.ArgumentParser( description="Control Spotify Connect devices using MPRIS2") parser.add_argument('-d', '--devices', nargs='+', metavar="DEVICE", help="Only create interfaces for the listed devices") parser.add_argument('-i', '--ignore', nargs='+', metavar="DEVICE", help="Ignore the listed devices") parser.add_argument('-a', '--auto', action="store_true", help="Automatically control the active device") parser.add_argument('-l', '--list', nargs='?', choices=["name", "id"], const="name", help="List available devices and exit") parser.add_argument('-n', '--name', default="spotpris", help="Change name of application") args = parser.parse_args() MediaPlayer2.dbus = [ pkg_resources.resource_string(__name__, f"mpris/{iface}.xml").decode('utf-8') for iface in ifaces ] loop = GLib.MainLoop() oauth = authenticate() sp = Spotify(oauth_manager=oauth) if args.list: devices = sp.devices() for devices in devices["devices"]: print(devices[args.list]) return exclusive_count = 0 for arg in [args.devices, args.ignore, args.auto]: if arg: exclusive_count += 1 if exclusive_count >= 2: parser.error( "Only one of --devices, --ignore and --auto can be used at the same time" ) return if not args.auto: manager = MultiBusManager(sp, args.devices, args.ignore, args.name) else: manager = SingleBusManager(sp, args.name) def timeout_handler(): manager.main_loop() return True GLib.timeout_add_seconds(1, timeout_handler) try: loop.run() except KeyboardInterrupt: pass
class TopSongsApp: """ A class to store methods for the Top Songs Application. Attributes: sp_api : Spotify spotify api connection object used to play songs songs : list[dict] list of songs read from www.billboard.com root : Tk Tkinter base window on which to build ui images : dict[str: ImageTk] loaded images used for youtube buttons and scroll top button widgets : dict[str: Widget] dictionary of widgets (widget name mapped to widget object) Methods: get_spotify_creds(): Read spotify credentials from environment variables or CREDENTIALS_FILE get_top_songs(n): Request and return a number of top songs from www.billboard.com create_ui(self): Create static buttons and sections and add them to the GUI. create_scrollable_frame(self): Create Scrollable region to contain song widgets. create_song_widgets(self): Create buttons and labels for each song. open_project_github(event): Open the TopSongs directory in GitHub. open_developer_github(event): Open developer's Github page. open_desktop_player(): Open the Spotify Desktop Player using Windows command prompt. open_desktop_player(): Open the Spotify Desktop Player using Windows command prompt. open_web_player(): Open the Spotify Web Player in the default browser. scroll_to_top(self): Scroll to top of scrollable songs frame. scroll(self, event): Scroll through songs. number_btn_release(self, event): Open Billboard song chart upon releasing song number button. song_btn_release(self, event): Start song playback upon releasing song name button artist_btn_release(self, event): Open artist in currently open player upon releasing artist button. yt_btn_release(self, event): Open song's music video upon releasing youtube button. button_hover(self, event): Upon hovering over a widget, update status label with widget's message. button_leave(self, event): Upon leaving a widget, clear status label. open_song_chart(self, button): Open Billboard chart with button's song selected. get_song_data(self, song): Get a song's track and artist URIs from Spotify's API via spotipy. spotify_launchers_are_running(self): Check whether Spotify app or web players (or both) are running. open_artist(self, button): Open an artist in the currently open Spotify player. play_song(self, button): Play a song in the currently open Spotify player. open_music_video(self, button): Open a song's music video on YouTube in the default browser. get_real_artist(artist): Return an artist string with featured artists removed. run(self): Start TopSongs app. """ def __init__(self, client_id=None, client_secret=None, redirect_uri=None): """ Create attributes for TopSongs object, connect spotipy, get songs, initialize Tkinter. :param client_id: Client ID of your Spotify app (default=None) :param client_secret: Client Secret of your Spotify app (default=None) :param redirect_uri: Redirect URI of your Spotify app (default=None) The above parameters are optional, if they are left blank the program will set them from environment variables or by reading CREDENTIALS_FILE (see get_spotify_creds()). Whichever of these three ways you choose, these three variables must be set and correct to run TopSongs. NOTE: providing TopSongs with your credentials allows the program to access your spotify account. the variable scope dictates which parts of your information TopSongs can access. The scope is set to "user-read-currently-playing user-modify-playback-state user-read-playback-state", which means that TopSongs can only start/stop playback, see what you're playing, and what device you're playing on. """ scope = 'user-read-currently-playing user-modify-playback-state user-read-playback-state' if client_id and client_secret and redirect_uri: cid, secret, uri = client_id, client_secret, redirect_uri else: cid, secret, uri = self.get_spotify_creds() auth_manager = SpotifyOAuth(scope=scope, client_id=cid, client_secret=secret, redirect_uri=uri) self.sp_api = Spotify(auth_manager=auth_manager) self.songs = self.get_top_songs(NUM_SONGS) self.root = Tk() self.root.title('Top Songs') self.root.resizable(False, False) self.root.iconbitmap("top_songs/icon.ico") self.images = { 'youtube': ImageTk.PhotoImage(Img.open('top_songs/youtube.png')), 'up_arrow': ImageTk.PhotoImage(Img.open('top_songs/up_arrow.png')), } self.widgets = {} self.create_ui() @staticmethod def get_spotify_creds(): """ Read spotify credentials from environment variables or CREDENTIALS_FILE. :except KeyError: if one or more env variables are missing, read from file Environment variable names: SPOTIPY_CLIENT_ID SPOTIPY_CLIENT_secret SPOTIPY_REDIRECT_URI Credentials file format: Line 1: Client Id Line 2: Client Secret Line 3: Redirect Uri """ try: client_id = os.environ['SPOTIPY_CLIENT_ID'] client_secret = os.environ['SPOTIPY_CLIENT_SECRET'] redirect_uri = os.environ['SPOTIPY_REDIRECT_URI'] except KeyError: with open(CREDENTIALS_FILE, 'r') as file_in: client_id = file_in.readline().strip() client_secret = file_in.readline().strip() redirect_uri = file_in.readline().strip() return client_id, client_secret, redirect_uri @staticmethod def get_top_songs(n): """ Request and return a number of top songs from www.billboard.com. :param int n: number of songs to return :return: top songs :rtype: list[dict] songs are extracted from HTML parsed by beautifulsoup4 and are stored as dictionaries with the format: { "number": number, "name": name, "artist": artist } NOTE: making n smaller does not reduce network load or processing time. You just get less songs. """ top_100 = requests.get('https://www.billboard.com/charts/hot-100') try: top_100.raise_for_status() except requests.HTTPError: return [] html = top_100.text top_100.close() # Parse HTML. soup = BeautifulSoup(html, 'html.parser') songs = [] for number, song in enumerate( soup.find_all('li', class_='chart-list__element')[:n], 1): name = song.find('span', class_='chart-element__information__song').text artist = song.find( 'span', class_='chart-element__information__artist').text.replace( 'Featuring', 'ft.').replace(' x ', ', ').replace(' & ', ', ').replace(' X ', ', ') songs.append({'number': number, 'name': name, 'artist': artist}) return songs def create_ui(self): """ Create static buttons and sections and add them to the GUI. Process for creating widgets: Step 1. Create widget objects (LabelFrame, Label, Button) Step 2. Pack widgets to the screen (widget.grid(), widget.pack()) Step 3. Add widgets to self.widgets dict Step 4. Add message attributes to widgets to show in status bar Step 5. Add necessary bindings to widgets """ # Create widgets. top_frame = LabelFrame(self.root, bd=3, relief=SUNKEN) title_btn = Label(top_frame, bd=0, text='Top Songs', font=LARGE_FONT) subtitle_btn = Label(top_frame, bd=0, text='by Caleb Webster', font=MEDIUM_FONT) app_btn = Button(top_frame, width=4, bd=3, text='App', font=SMALL_FONT, command=self.open_desktop_player) web_btn = Button(top_frame, width=4, bd=3, text='Web', font=SMALL_FONT, command=self.open_web_player) scroll_top_btn = Button(top_frame, bd=3, image=self.images['up_arrow'], command=self.scroll_to_top) bottom_frame = LabelFrame(self.root, relief=SUNKEN) hover_label = Label(bottom_frame, text='', font=SMALL_FONT, anchor=W, width=59) # Pack em' in. top_frame.grid(row=0, column=0, padx=5, pady=5, sticky=W + E) title_btn.grid(row=0, column=0, padx=15) subtitle_btn.grid(row=0, column=1, pady=(15, 0)) app_btn.grid(row=0, column=2, padx=(35, 0)) web_btn.grid(row=0, column=3) scroll_top_btn.grid(row=0, column=4, padx=(31, 0)) bottom_frame.grid(row=2, column=0, padx=5, pady=5, sticky=W + E) hover_label.grid(row=0, column=0) # Add widgets to dict. self.widgets['top_frame'] = top_frame self.widgets['title_btn'] = title_btn self.widgets['subtitle_btn'] = subtitle_btn self.widgets['app_btn'] = app_btn self.widgets['web_btn'] = web_btn self.widgets['bottom_frame'] = bottom_frame self.widgets['hover_label'] = hover_label self.widgets['song_frames'] = [] # Bind status messages to buttons. title_btn.message = 'https://www.github.com/CalebWebsterJCU/TopSongs' subtitle_btn.message = 'https://www.github.com/CalebWebsterJCU' app_btn.message = 'Open Spotify Desktop Player' web_btn.message = 'Open Spotify Web Player' scroll_top_btn.message = 'Scroll to Top' # Bindings for labels. title_btn.bind('<ButtonRelease-1>', self.open_project_github) subtitle_btn.bind('<ButtonRelease-1>', self.open_developer_github) # Bind hover event to buttons to display info. title_btn.bind('<Enter>', self.button_hover) subtitle_btn.bind('<Enter>', self.button_hover) app_btn.bind('<Enter>', self.button_hover) web_btn.bind('<Enter>', self.button_hover) scroll_top_btn.bind('<Enter>', self.button_hover) # Bind leave event to buttons to clear info panel. title_btn.bind('<Leave>', self.button_leave) subtitle_btn.bind('<Leave>', self.button_leave) app_btn.bind('<Leave>', self.button_leave) web_btn.bind('<Leave>', self.button_leave) scroll_top_btn.bind('<Leave>', self.button_leave) self.create_scrollable_frame() self.create_song_widgets() def create_scrollable_frame(self): """Create Scrollable region to contain song widgets.""" middle_frame_outer = LabelFrame(self.root, bd=3, relief=SUNKEN) # Behold, the process for creating a simple scrolling widget in Tkinter. canvas = Canvas(middle_frame_outer, height=480) scrollbar = ttk.Scrollbar(middle_frame_outer, orient=VERTICAL, command=canvas.yview) canvas.configure(yscrollcommand=scrollbar.set) canvas.bind( '<Configure>', lambda event: canvas.configure(scrollregion=canvas.bbox('all'))) middle_frame_inner = Frame(canvas) middle_frame_inner.bind('<MouseWheel>', self.scroll) scrollbar.bind('<MouseWheel>', self.scroll) canvas.create_window((0, 0), window=middle_frame_inner, anchor=N + W) canvas.yview("moveto", 0) middle_frame_outer.grid(row=1, column=0, padx=5, pady=5, sticky=W + E) canvas.pack(side=LEFT, fill=BOTH, expand=True) scrollbar.pack(side=RIGHT, fill=Y) self.widgets['middle_frame_outer'] = middle_frame_outer self.widgets['canvas'] = canvas self.widgets['scrollbar'] = scrollbar self.widgets['middle_frame_inner'] = middle_frame_inner def create_song_widgets(self): """Create buttons and labels for each song.""" max_name_length = 20 max_artist_length = 15 # Add song widgets to bottom frame. middle_frame_inner = self.widgets['middle_frame_inner'] for x in range(len(self.songs)): song = self.songs[x] # Shorten song and artist name if they exceed the maximum values. shortened_name = song['name'][:max_name_length + 1] + '...' if len( song['name']) > max_name_length else song['name'] shortened_artist = song[ 'artist'][:max_name_length + 1] + '...' if len( song['artist']) > max_name_length else song['artist'] # Create inner frame and buttons for song name, artist, album cover, and music video. song_frame = LabelFrame(middle_frame_inner, bd=3, relief=RAISED) number_btn = Label(song_frame, width=3, text=str(song['number']) + '.', font=SMALL_FONT) name_btn = Label(song_frame, width=max_name_length, text=shortened_name, padx=10, anchor=W, font=SMALL_FONT) artist_btn = Label(song_frame, width=max_artist_length + 5, text=shortened_artist, padx=10, anchor=W, font=SMALL_FONT) youtube_btn = Label(song_frame, image=self.images['youtube']) # Add data (dict key) and index (song i) to buttons. name_btn.data = 'song_uri' artist_btn.data = 'artist_uri' youtube_btn.data = 'yt_url' number_btn.index = x name_btn.index = x artist_btn.index = x youtube_btn.index = x # Bind open functions to buttons. number_btn.bind('<ButtonRelease-1>', self.number_btn_release) name_btn.bind('<ButtonRelease-1>', self.song_btn_release) artist_btn.bind('<ButtonRelease-1>', self.artist_btn_release) youtube_btn.bind('<ButtonRelease-1>', self.yt_btn_release) # Add buttons and frame to bottom frame. song_frame.grid(row=x, column=0, padx=6, pady=3, sticky=W + E) number_btn.grid(row=0, column=0) name_btn.grid(row=0, column=1) artist_btn.grid(row=0, column=2) youtube_btn.grid(row=0, column=3, padx=10) # Attach hover message to buttons. number_btn.message = song['number'] name_btn.message = song['name'] artist_btn.message = song['artist'] youtube_btn.message = f'{song["name"]} Music Video' # Bind hover event to buttons to display info. number_btn.bind('<Enter>', self.button_hover) name_btn.bind('<Enter>', self.button_hover) artist_btn.bind('<Enter>', self.button_hover) youtube_btn.bind('<Enter>', self.button_hover) # Bind leave event to buttons to clear info panel. number_btn.bind('<Leave>', self.button_leave) name_btn.bind('<Leave>', self.button_leave) artist_btn.bind('<Leave>', self.button_leave) youtube_btn.bind('<Leave>', self.button_leave) # Bind scroll event to buttons and container. song_frame.bind('<MouseWheel>', self.scroll) number_btn.bind('<MouseWheel>', self.scroll) name_btn.bind('<MouseWheel>', self.scroll) artist_btn.bind('<MouseWheel>', self.scroll) youtube_btn.bind('<MouseWheel>', self.scroll) self.widgets['song_frames'].append(song_frame) @staticmethod def open_project_github(event): """Open the TopSongs directory in GitHub.""" button = event.widget x, y = event.x, event.y # Check if mouse is on button if 0 < x < button.winfo_width() and 0 < y < button.winfo_height(): webbrowser.open('https://www.github.com/CalebWebsterJCU/TopSongs') @staticmethod def open_developer_github(event): """Open developer's Github page.""" button = event.widget x, y = event.x, event.y # Check if mouse is on button if 0 < x < button.winfo_width() and 0 < y < button.winfo_height(): webbrowser.open('https://www.github.com/CalebWebsterJCU') @staticmethod def open_desktop_player(): """Open the Spotify Desktop Player using Windows command prompt.""" os.system('spotify') @staticmethod def open_web_player(): """Open the Spotify Web Player in the default browser.""" webbrowser.open('https://open.spotify.com') def scroll_to_top(self): """Scroll to top of scrollable songs frame.""" canvas = self.widgets['canvas'] canvas.yview_scroll(-len(self.songs), 'units') def scroll(self, event): """Scroll through songs.""" canvas = self.widgets['canvas'] # Event.delta will be either 120 or -120. # By finding the sign of event.delta, the # program can scroll the opposite direction. sign = event.delta // abs(event.delta) canvas.yview_scroll(-sign * SCROLL_SPEED, 'units') def number_btn_release(self, event): """Open Billboard song chart upon releasing song number button.""" button = event.widget x, y = event.x, event.y # Check if mouse is on button if 0 < x < button.winfo_width() and 0 < y < button.winfo_height(): self.open_song_chart(button) def song_btn_release(self, event): """ Start song playback upon releasing song name button. Playback will start in currently running Spotify player. If both web and app players are running, app is preferred. """ button = event.widget x, y = event.x, event.y # Check if mouse is on button if 0 < x < button.winfo_width() and 0 < y < button.winfo_height(): self.play_song(button) def artist_btn_release(self, event): """Open artist in currently open player upon releasing artist button.""" button = event.widget x, y = event.x, event.y # Check if mouse is on button if 0 < x < button.winfo_width() and 0 < y < button.winfo_height(): self.open_artist(button) def yt_btn_release(self, event): """Open song's music video upon releasing youtube button.""" button = event.widget x, y = event.x, event.y # Check if mouse is on button if 0 < x < button.winfo_width() and 0 < y < button.winfo_height(): self.open_music_video(button) def button_hover(self, event): """Upon hovering over a widget, update status label with widget's message.""" button = event.widget hover_label = self.widgets['hover_label'] hover_label.config(text=button.message) def button_leave(self, event): """Upon leaving a widget, clear status label.""" assert event hover_label = self.widgets['hover_label'] hover_label.config(text='') def open_song_chart(self, button): """ Open Billboard chart with button's song selected. :param button: button that was pressed. Song buttons store their song's index, which is used to find the correct song. """ song = self.songs[button.index] song_number = song['number'] webbrowser.open( f'https://www.billboard.com/charts/hot-100?rank={song_number}') def get_song_data(self, song): """ Get a song's track and artist URIs from Spotify's API via spotipy. :param dict song: song data dictionary :returns: song URI, artist URI :rtype: str, str NOTE: Artist URI returned is the first artist listed (TopSongs can ony open one artist at this time). """ song_name = song['name'] artist = song['artist'] real_artist = self.get_real_artist(artist) # Send request to Spotify's API and sift through dict to find desired data. result = self.sp_api.search(q=f'{song_name} {real_artist}', type='track', limit=1) uri = result['tracks']['items'][0]['uri'] artist_uri = result['tracks']['items'][0]['artists'][0][ 'uri'] # First listed artist return uri, artist_uri def spotify_launchers_are_running(self): """ Check whether Spotify app or web players (or both) are running. :return: tuple (bool, bool) for app, web player running This function uses the socket module to get your PC's name. """ app = False web = False pc_name = socket.gethostname() devices = self.sp_api.devices()['devices'] print(devices) for device in devices: if device['name'] == pc_name: app = True if 'Web Player' in device['name']: web = True return app, web def open_artist(self, button): """ Open an artist in the currently open Spotify player. :param button: button that was pressed If a artist's URI is already stored, open it in the browser, desktop app, or both. If not, get and store song's data, then open artist's URI. """ # Button stores data key and song index. i = button.index song = self.songs[i] if 'artist_uri' in song: uri = song['artist_uri'] else: song['uri'], song['artist_uri'] = self.get_song_data(song) uri = self.songs[i]['artist_uri'] app, web = self.spotify_launchers_are_running() if app: os.system(f'spotify --uri={uri}') if web: uri_type, uri_id = uri.split(':')[1], uri.split(':')[2] webbrowser.open(f'https://open.spotify.com/{uri_type}/{uri_id}') def play_song(self, button): """ Play a song in the currently open Spotify player. :param button: button that was pressed If a song's URI is already stored, play it. get and store song's data, then play song's URI. """ # Button stores data key and song index. i = button.index song = self.songs[i] if 'uri' in song: uri = song['uri'] else: song['uri'], song['artist_uri'] = self.get_song_data(song) uri = self.songs[i]['uri'] devices = self.sp_api.devices()['devices'] if len(devices) > 0: device_id = devices[0]['id'] # Play on first device self.sp_api.start_playback(uris=[uri], device_id=device_id) else: messagebox.showinfo( 'No Player', 'No Spotify player running! Use the App/Web buttons to launch Spotify. You may have to wait a second before hitting play.' ) def open_music_video(self, button): """ Open a song's music video on YouTube in the default browser. :param button: button that was pressed If a song's music video url is already stored, open it. If not, search for the song on www.youtube.com, get and store the first video result, then open the music video. """ # Button stores data key and song index. key = button.data i = button.index song = self.songs[i] if key in song: url = song[key] else: song_name = song['name'] artist = song['artist'] real_artist = self.get_real_artist(artist) # Search for song using youtube_search and sift through dict to find desired data. results = YoutubeSearch(f'{song_name} {real_artist}', max_results=1).to_dict() video_id = results[0]['id'] url = f'https://www.youtube.com/watch?v={video_id}' song['yt_url'] = url webbrowser.open(url) @staticmethod def get_real_artist(artist): """Return an artist string with featured artists removed.""" if ' ft.' in artist: return artist[:artist.find(' ft.')] return artist def run(self): """Start TopSongs app.""" self.root.mainloop()