Ejemplo n.º 1
0
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
Ejemplo n.º 2
0
 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']
Ejemplo n.º 3
0
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')
Ejemplo n.º 4
0
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
Ejemplo n.º 5
0
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
Ejemplo n.º 6
0
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
Ejemplo n.º 7
0
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
Ejemplo n.º 8
0
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", [])
Ejemplo n.º 9
0
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
Ejemplo n.º 10
0
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)
Ejemplo n.º 11
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
Ejemplo n.º 12
0
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
Ejemplo n.º 13
0
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()