Example #1
0
def process_one_user(user: dict, service: SpotifyService) -> int:
    """ Get recently played songs for this user and submit them to ListenBrainz.

    Args:
        user (spotify.Spotify): the user whose plays are to be imported.
        service (listenbrainz.domain.spotify.SpotifyService): service to process users

    Raises:
        spotify.SpotifyAPIError: if we encounter errors from the Spotify API.
        spotify.SpotifyListenBrainzError: if we encounter a rate limit, even after retrying.
                                          or if we get errors while submitting the data to ListenBrainz
    Returns:
        the number of recently played listens imported for the user
    """
    try:
        if service.user_oauth_token_has_expired(user):
            user = service.refresh_access_token(user['user_id'],
                                                user['refresh_token'])

        listens = []
        latest_listened_at = None

        # If there is no playback, currently_playing will be None.
        # There are two playing types, track and episode. We use only the
        # track type. Therefore, when the user's playback type is not a track,
        # Spotify will set the item field to null which becomes None after
        # parsing the JSON. Due to these reasons, we cannot simplify the
        # checks below.
        currently_playing = get_user_currently_playing(user)
        if currently_playing is not None:
            currently_playing_item = currently_playing.get('item', None)
            if currently_playing_item is not None:
                current_app.logger.debug(
                    'Received a currently playing track for %s', str(user))
                listens, latest_listened_at = parse_and_validate_spotify_plays(
                    [currently_playing_item], LISTEN_TYPE_PLAYING_NOW)
                if listens:
                    submit_listens_to_listenbrainz(
                        user, listens, listen_type=LISTEN_TYPE_PLAYING_NOW)

        recently_played = get_user_recently_played(user)
        if recently_played is not None and 'items' in recently_played:
            listens, latest_listened_at = parse_and_validate_spotify_plays(
                recently_played['items'], LISTEN_TYPE_IMPORT)
            current_app.logger.debug('Received %d tracks for %s', len(listens),
                                     str(user))

        # if we don't have any new listens, return. we don't check whether the listens list is empty here
        # because it will empty in both cases where we don't receive any listens and when we receive only
        # bad listens. so instead we check latest_listened_at which is None only in case when we received
        # nothing from spotify.
        if latest_listened_at is None:
            service.update_user_import_status(user['user_id'])
            return 0

        submit_listens_to_listenbrainz(user,
                                       listens,
                                       listen_type=LISTEN_TYPE_IMPORT)

        # we've succeeded so update the last_updated and latest_listened_at field for this user
        service.update_latest_listen_ts(user['user_id'], latest_listened_at)

        current_app.logger.info('imported %d listens for %s' %
                                (len(listens), str(user['musicbrainz_id'])))
        return len(listens)

    except ExternalServiceInvalidGrantError:
        error_message = "It seems like you've revoked permission for us to read your spotify account"
        service.update_user_import_status(user_id=user['user_id'],
                                          error=error_message)
        if not current_app.config['TESTING']:
            notify_error(user['musicbrainz_id'], error_message)
        # user has revoked authorization through spotify ui or deleted their spotify account etc.
        # in any of these cases, we should delete the user's token from.
        service.revoke_user(user['user_id'])
        raise ExternalServiceError("User has revoked spotify authorization")

    except ExternalServiceAPIError as e:
        # if it is an error from the Spotify API, show the error message to the user
        service.update_user_import_status(user_id=user['user_id'],
                                          error=str(e))
        if not current_app.config['TESTING']:
            notify_error(user['musicbrainz_id'], str(e))
        raise ExternalServiceError("Could not refresh user token from spotify")
Example #2
0
class SpotifyServiceTestCase(IntegrationTestCase):
    def setUp(self):
        super(SpotifyServiceTestCase, self).setUp()
        self.user_id = db_user.create(312, 'spotify_user')
        self.service = SpotifyService()
        self.service.add_new_user(
            self.user_id, {
                'access_token': 'old-token',
                'refresh_token': 'old-refresh-token',
                'expires_in': 3600,
                'scope':
                'user-read-currently-playing user-read-recently-played'
            })
        self.spotify_user = self.service.get_user(self.user_id)

    def test_get_active_users(self):
        user_id_1 = db_user.create(333, 'user-1')
        user_id_2 = db_user.create(666, 'user-2')
        user_id_3 = db_user.create(999, 'user-3')

        self.service.add_new_user(
            user_id_2, {
                'access_token': 'access-token',
                'refresh_token': 'refresh-token',
                'expires_in': 3600,
                'scope': 'streaming',
            })

        self.service.add_new_user(
            user_id_3, {
                'access_token': 'access-token999',
                'refresh_token': 'refresh-token999',
                'expires_in': 3600,
                'scope':
                'user-read-currently-playing user-read-recently-played',
            })
        self.service.update_user_import_status(
            user_id_3,
            error="add an error and check this user doesn't get selected")

        self.service.add_new_user(
            user_id_1, {
                'access_token': 'access-token333',
                'refresh_token': 'refresh-token333',
                'expires_in': 3600,
                'scope':
                'user-read-currently-playing user-read-recently-played',
            })
        self.service.update_latest_listen_ts(user_id_1, int(time.time()))

        active_users = self.service.get_active_users_to_process()
        self.assertEqual(len(active_users), 2)
        self.assertEqual(active_users[0]['user_id'], user_id_1)
        self.assertEqual(active_users[1]['user_id'], self.user_id)

    def test_update_latest_listened_at(self):
        t = int(time.time())
        self.service.update_latest_listen_ts(self.user_id, t)
        user = self.service.get_user_connection_details(self.user_id)
        self.assertEqual(datetime.fromtimestamp(t, tz=timezone.utc),
                         user['latest_listened_at'])

    # apparently, requests_mocker does not follow the usual order in which decorators are applied. :-(
    @requests_mock.Mocker()
    @mock.patch('time.time')
    def test_refresh_user_token(self, mock_requests, mock_time):
        mock_time.return_value = 0
        mock_requests.post(OAUTH_TOKEN_URL,
                           status_code=200,
                           json={
                               'access_token': 'tokentoken',
                               'refresh_token': 'refreshtokentoken',
                               'expires_in': 3600,
                               'scope': '',
                           })
        user = self.service.refresh_access_token(
            self.user_id, self.spotify_user['refresh_token'])
        self.assertEqual(self.user_id, user['user_id'])
        self.assertEqual('tokentoken', user['access_token'])
        self.assertEqual('refreshtokentoken', user['refresh_token'])
        self.assertEqual(datetime.fromtimestamp(3600, tz=timezone.utc),
                         user['token_expires'])

    @requests_mock.Mocker()
    @mock.patch('time.time')
    def test_refresh_user_token_only_access(self, mock_requests, mock_time):
        mock_time.return_value = 0
        mock_requests.post(OAUTH_TOKEN_URL,
                           status_code=200,
                           json={
                               'access_token': 'tokentoken',
                               'expires_in': 3600,
                               'scope': '',
                           })
        user = self.service.refresh_access_token(
            self.user_id, self.spotify_user['refresh_token'])
        self.assertEqual(self.user_id, user['user_id'])
        self.assertEqual('tokentoken', user['access_token'])
        self.assertEqual('old-refresh-token', user['refresh_token'])
        self.assertEqual(datetime.fromtimestamp(3600, tz=timezone.utc),
                         user['token_expires'])

    @requests_mock.Mocker()
    def test_refresh_user_token_bad(self, mock_requests):
        mock_requests.post(OAUTH_TOKEN_URL,
                           status_code=400,
                           json={
                               'error': 'invalid request',
                               'error_description': 'invalid refresh token',
                           })
        with self.assertRaises(ExternalServiceAPIError):
            self.service.refresh_access_token(
                self.user_id, self.spotify_user['refresh_token'])

    # apparently, requests_mocker does not follow the usual order in which decorators are applied. :-(
    @requests_mock.Mocker()
    def test_refresh_user_token_revoked(self, mock_requests):
        mock_requests.post(OAUTH_TOKEN_URL,
                           status_code=400,
                           json={
                               'error': 'invalid_grant',
                               'error_description': 'Refresh token revoked',
                           })
        with self.assertRaises(ExternalServiceInvalidGrantError):
            self.service.refresh_access_token(
                self.user_id, self.spotify_user['refresh_token'])

    def test_remove_user(self):
        self.service.remove_user(self.user_id)
        self.assertIsNone(self.service.get_user(self.user_id))

    def test_get_user(self):
        user = self.service.get_user(self.user_id)
        self.assertIsInstance(user, dict)
        self.assertEqual(user['user_id'], self.user_id)
        self.assertEqual(user['musicbrainz_id'], 'spotify_user')
        self.assertEqual(user['access_token'], 'old-token')
        self.assertEqual(user['refresh_token'], 'old-refresh-token')
        self.assertIsNotNone(user['last_updated'])

    @mock.patch('time.time')
    def test_add_new_user(self, mock_time):
        mock_time.return_value = 0
        self.service.remove_user(self.user_id)
        self.service.add_new_user(
            self.user_id, {
                'access_token': 'access-token',
                'refresh_token': 'refresh-token',
                'expires_in': 3600,
                'scope': '',
            })
        user = self.service.get_user(self.user_id)
        self.assertEqual(self.user_id, user['user_id'])
        self.assertEqual('access-token', user['access_token'])
        self.assertEqual('refresh-token', user['refresh_token'])