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")
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'])