def __init__(self, logger, api, config): self.logger = logger.getChild('IntegrityManager') self.api = api self.config = config self.spotify_helper = SpotifyHelper(self.logger, api=self.api) # to avoid uncertainty of whether a forward slash needs to be appended to the backup path while self.config['BACKUP_PATH'][-1] == '/': self.config['BACKUP_PATH'] = self.config['BACKUP_PATH'][:-1]
def setUp(self): self.test_logger = logging.getLogger('TestSpotifyHelper') log_handler = logging.StreamHandler() log_handler.setLevel('CRITICAL') self.test_logger.addHandler(log_handler) self.helper = SpotifyHelper(self.test_logger) self.generate_spotify_id = ( lambda: ''.join(random.choice(string.ascii_letters + string.digits) for i in range(0, 22))) self.generate_playlist_uri = ( lambda gen_id=self.generate_spotify_id: 'spotify:playlist:' + gen_id()) self.generate_track_uri = ( lambda gen_id=self.generate_spotify_id: 'spotify:track:' + gen_id()) os.environ = {} # reset environment
def moderate_playlists(logger, api_client, username, playlist_config): playlist_cleaner = PlaylistCleaner(logger, api_client, username, playlist_config) integrity_manager = IntegrityManager(logger, api_client, playlist_config) sp_helper = SpotifyHelper(logger) def protect_playlists(): # runs one iteration of playlist moderation if playlist_config['PROTECT_ALL']: protected_playlists = sp_helper.get_all_collab_playlists( username, api=api_client) else: protected_playlists = [] for playlist in playlist_config['PROTECTED_PLAYLISTS']: if len(playlist.keys()) == 1: for key, val in playlist.items(): protected_playlists.append(val) for playlist in protected_playlists: print( '') # newlines between playlists improves readibility of logs playlist_cleaner.run(playlist) integrity_manager.run(playlist) if '--loop' in sys.argv or '-l' in sys.argv: # For termination of loop mode, the idea is: delays between loop iterations are implemented # by a timeboxed attempt to get user input (from stdin) in order to allow the user to # terminate the program loop without needing to send a kill signal while True: protect_playlists() logger.info('Completed iteration') if user_wants_to_exit(playlist_config['DELAY_BETWEEN_SCANS']): break else: protect_playlists()
def all_protected_playlists_exist(self, api_client): if ('PROTECTED_PLAYLISTS' not in self.playlist.keys() or not isinstance(self.playlist['PROTECTED_PLAYLISTS'], list)): self.logger.error( '`PLAYLIST_CONFIG.PROTECTED_PLAYLISTS is invalid - it must be a list`' ) return False helper = SpotifyHelper(self.logger, api=api_client) collab_playlists = helper.get_all_collab_playlists( self.account['USERNAME']) collab_pl_uris = [pl['uri'] for pl in collab_playlists] for playlist in self.playlist['PROTECTED_PLAYLISTS']: if len(playlist.keys()) != 1: return False for key, val in playlist.items(): if val['uri'] not in collab_pl_uris: return False return True
def main(): if '-h' in sys.argv or '--help' in sys.argv: print_help_information() exit_with_code(0) elif '--rdc' in sys.argv: exit_with_code(restore_default_config_file()) try: logger = default_logger( ) # used for error logging before custom logger can be configured (playlist_config, log_config, account_config) = load_configurations(path=get_config_filepath()) config_validator = ConfigValidator(playlist_config, log_config, account_config) if not config_validator.is_valid(): raise Exception( 'Invalid configuration file - check \'data/config.yaml\'') logger = setup_logger( log_config) # custom logger based on user's config logger.info('Starting new session of SpotifyAutoModerator') api_client = SpotifyHelper(logger).configure_api( account_config['CLIENT_ID'], account_config['CLIENT_SECRET'], account_config['REDIRECT_URI']) if not isinstance(api_client, spotipy.client.Spotify): raise Exception('Failed to authenticate with Spotify') elif not config_validator.all_protected_playlists_exist(api_client): raise Exception( 'Could not find all protected playlists in Spotify') moderate_playlists(logger, api_client, account_config['USERNAME'], playlist_config) except OSError as err: logger.error('Error: \'%s\'', err) if 'Address already in use' in str(err): logger.error('Redirect URI \'%s\' is already in use', account_config['REDIRECT_URI']) logger.error( 'Try to use a different (free) port number and add this address to the' + 'application in the Spotify Developer Dashboard') exit_with_code(1) except Exception as err: logger.error('Error: \'%s\'', err) if '401' in str(err): logger.error('Confirm your account/client details are correct') exit_with_code(1) exit_with_code(0)
class IntegrityManager: def __init__(self, logger, api, config): self.logger = logger.getChild('IntegrityManager') self.api = api self.config = config self.spotify_helper = SpotifyHelper(self.logger, api=self.api) # to avoid uncertainty of whether a forward slash needs to be appended to the backup path while self.config['BACKUP_PATH'][-1] == '/': self.config['BACKUP_PATH'] = self.config['BACKUP_PATH'][:-1] def run(self, playlist): pl_id = self.spotify_helper.get_playlist_id(playlist) latest_backup = self.find_latest_backup(pl_id) if latest_backup is None: self.logger.info('Playlist has no backup for comparison (PID: %s)', pl_id) self.backup_playlist(pl_id) return self.logger.info('Checking if any tracks were removed (PID: %s)', pl_id) removals = self.get_removals(pl_id, latest_backup) try: unapproved_removals = self.get_unapproved_removals( removals, latest_backup['name']) except TimeoutOccurred as err: self.logger.warning( 'No response given for an approval request (PID: %s)', pl_id) self.logger.warning( 'Skipping track restoration until next run (PID: %s)', pl_id) return if isinstance(unapproved_removals, list) and len(unapproved_removals) > 0: try: self._restore_removals(pl_id, unapproved_removals) except Exception as err: self.logger.error( 'Failed to restore unapproved removals (PID: %s). Error: \'%s\'', pl_id, err) return else: self.logger.info( 'Successfully restored unapproved removals (PID: )', pl_id) self.backup_playlist(pl_id) self.manage_redundant_backups(pl_id) self.logger.debug( 'Completed verification of playlist integrity (PID: %s)', pl_id) def find_latest_backup(self, playlist_id): backup_files = os.listdir(self.config['BACKUP_PATH']) relevant_backups = [] for backup in backup_files: match = re.search( '^%s_[0-9]{10}\.[0-9]+\.backup\.json$' % playlist_id, backup) if match is not None: ts_span = re.search('[0-9]{10}.[0-9]+', backup).span() relevant_backups.append({ 'filename': '%s/%s' % (self.config['BACKUP_PATH'], backup), 'timestamp': float(backup[ts_span[0]:ts_span[1]]) }) if len(relevant_backups) == 0: return None relevant_backups.sort(reverse=True, key=lambda x: x['timestamp']) return self._load_backup_from_file(relevant_backups[0]['filename']) def get_removals(self, playlist_id, backup_info): current_items = self.spotify_helper.get_all_items_in_playlist( playlist_id, fields='items.track(uri)', api=self.api) removals = [] for backup_item in backup_info['items']: still_in_playlist = False for current_item in current_items: if backup_item['uri'] == current_item['track']['uri']: still_in_playlist = True break if not still_in_playlist: removals.append(backup_item) return removals def get_unapproved_removals(self, removals, playlist_name): unapproved = [] for removal in removals: try: if not self._user_approves_removal(removal, playlist_name, 20): unapproved.append(removal) except TimeoutOccurred as err: raise err return unapproved def backup_playlist(self, playlist_id): playlist_info = self.api.playlist(playlist_id, fields='name') playlist_items = self.spotify_helper.get_all_items_in_playlist( playlist_id, fields='items(track(name,uri, artists.name)),total', api=self.api) formatted_items = [] self.logger.info('Backing up playlist contents (PID: %s)', playlist_id) for item in playlist_items: formatted_items.append({ 'name': item['track']['name'], 'artists': self._get_artists_string( [artist['name'] for artist in item['track']['artists']]), 'uri': item['track']['uri'], 'position': item['position'] }) backup = {'name': playlist_info['name'], 'items': formatted_items} backup_file_path = '%s/%s_%s.backup.json' % ( self.config['BACKUP_PATH'], playlist_id, str(time())) self.logger.debug('Saving playlist backup as \'%s\' (PID: %s)', backup_file_path, playlist_id) backup_file = open(backup_file_path, 'w') backup_file.write(json.dumps(backup)) backup_file.close() sleep(0.2) # for stability self.logger.debug('Playlist backup was saved successfully (PID: %s)', playlist_id) def manage_redundant_backups(self, playlist_id): desired_num_backups = self.config['MAX_BACKUPS_PER_PLAYLIST'] backup_files = os.listdir(self.config['BACKUP_PATH']) timestamp_re = '[0-9]{10}\.[0-9]+' filename_re = '^%s_%s\.backup\.json$' % (playlist_id, timestamp_re) relevant_backups = [] for filename in backup_files: if re.search(filename_re, filename) != None: ts_match_span = re.search(timestamp_re, filename).span() relevant_backups.append({ 'filename': '%s/%s' % (self.config['BACKUP_PATH'], filename), 'timestamp': float(filename[ts_match_span[0] + 1:ts_match_span[1]]) }) num_backups = len(relevant_backups) if num_backups > desired_num_backups: self.logger.debug('Deleting redundant playlist backups (PID: %s)', playlist_id) # as expected, the oldest, most out-of-date backups are deleted relevant_backups.sort(key=lambda x: x['timestamp'], reverse=False) for index in range(0, num_backups - desired_num_backups): self.logger.debug('Deleting backup \'%s\'', relevant_backups[index]['filename']) os.remove(relevant_backups[index]['filename']) self.logger.debug( 'Completed deletion of redundant backups (PID: %s)', playlist_id) def _user_approves_removal(self, removal, playlist_name, timeout_after_secs): try: input = inputimeout( prompt= 'Do you approve the removal of \'%s - %s\' from playlist \'%s\'? (Y/n): ' % (removal['artists'], removal['name'], playlist_name), timeout=timeout_after_secs) if input in ['Y', 'y', 'YES', 'Yes', 'yes']: return True except TimeoutOccurred as err: raise err return False def _get_artists_string(self, artist_names): combined = '' for name in artist_names: combined += '%s, ' % name if combined != '': combined = combined[:-2] return combined def _restore_removals(self, playlist_id, removals): for removal in removals: self.logger.info('Restoring track \'%s\' (PID: %s)', removal['name'], playlist_id) self.spotify_helper.add_items_to_playlist(playlist_id, removals) def _load_backup_from_file(self, filename): if not os.path.isfile(filename): self.logger.error('Backup file \'%s\' does not exist', filename) return None backup_file = open(filename, 'r') backup_info = None try: backup_info = json.loads(backup_file.read()) except json.JSONDecodeError as err: self.logger.error( 'Backup file \'%s\' is invalid JSON. Error: \'%s\'', filename, err) return None except Exception as err: self.logger.error( 'Could not read backup file \'%s\'. Error: \'%s\'', filename, err) return None finally: backup_file.close() return backup_info if self._backup_info_is_valid( backup_info, filename) else None def _backup_info_is_valid(self, backup_info, filename): # name is needed for the user to be able to identify the playlist if ('name' not in backup_info.keys() or not isinstance(backup_info['name'], str) or backup_info['name'] == ''): self.logger.error('Backup file \'%s\' is invalid', filename) self.logger.error( 'The \'name\' attribute/property is either missing or not a valid playlist name' ) return False # cannot restore a playlist to a state of its constituent items are not known # it is okay for a playlist to have no items but this needs to be explicitly known elif ('items' not in backup_info.keys() or not isinstance(backup_info['items'], list)): self.logger.error('Backup file \'%s\' is invalid', filename) self.logger.error( 'The \'items\' attribute/property is either missing or not a valid list' ) return False # cannot restore constituent items if their URIs are not known # item names are needed for the user to identify them and choose which backup to restore from item_num = 1 for item in backup_info['items']: if not isinstance(item, dict): self.logger.error('Backup file \'%s\' is invalid', filename) self.logger.error( 'Item number %d is not a valid object/dict/map', item_num) return False elif ('uri' not in item.keys() or not isinstance(item['uri'], str) or re.search( '^spotify:track:[A-Za-z0-9]{22}$', item['uri']) is None): self.logger.error('Backup file \'%s\' is invalid', filename) self.logger.error( 'Item number %d does not have a valid track URI', item_num) return False elif 'name' not in item.keys() or not isinstance( item['name'], str) or item['name'] == '': self.logger.error('Backup file \'%s\' is invalid', filename) self.logger.error( 'Item number %d (URI: %s) does not have a valid name', item_num, item['uri']) return False item_num += 1 return True
class TestSpotifyHelper(unittest.TestCase): def setUp(self): self.test_logger = logging.getLogger('TestSpotifyHelper') log_handler = logging.StreamHandler() log_handler.setLevel('CRITICAL') self.test_logger.addHandler(log_handler) self.helper = SpotifyHelper(self.test_logger) self.generate_spotify_id = ( lambda: ''.join(random.choice(string.ascii_letters + string.digits) for i in range(0, 22))) self.generate_playlist_uri = ( lambda gen_id=self.generate_spotify_id: 'spotify:playlist:' + gen_id()) self.generate_track_uri = ( lambda gen_id=self.generate_spotify_id: 'spotify:track:' + gen_id()) os.environ = {} # reset environment # ----- Tests for SpotifyHelper.configure_api ----- # @patch('src.spotify_helper.spotipy.Spotify') @patch('src.spotify_helper.SpotifyOAuth') def test_configure_api_authenticates_with_all_required_scopes(self, oauth_mock, spotify_mock): oauth_mock.return_value = Mock() spotify_mock.return_value = None required_scopes = [ 'playlist-read-private', 'playlist-read-collaborative', 'playlist-modify-public', 'playlist-modify-private' ] self.helper.configure_api('test_client_id', 'test_client_secret', 'test_redirect') # Ensure Spotify (client) has been configured with the correct auth manager oauth_mock.assert_called_once() spotify_mock.asset_called_once_with(auth_manager=oauth_mock.return_value) # Ensure the used auth manager received all of the required scopes self.assertTrue(isinstance(oauth_mock.call_args[1], dict) and 'scope' in oauth_mock.call_args[1].keys(), "SpotifyOAuth did not receive any list/string of scopes.") requested_scopes = oauth_mock.call_args[1]['scope'] for scope in required_scopes: # Scopes must be delimiteed by a space which can be either prefixed or suffixed self.assertTrue(scope + ' ' in requested_scopes or ' ' + scope in requested_scopes) @patch.dict(os.environ, {}) def test_configure_api_passes_client_auth_details_via_environment_variables(self): auth_keys = ('SPOTIPY_CLIENT_ID', 'SPOTIPY_CLIENT_SECRET', 'SPOTIPY_REDIRECT_URI') for key in auth_keys: self.assertTrue(key not in os.environ) auth_values = ('test_client_id', 'test_client_secret', 'test_redirect_uri') self.helper.configure_api(auth_values[0], auth_values[1], auth_values[2]) for index in range(0, len(auth_keys)): self.assertEqual(os.environ[auth_keys[index]], auth_values[index]) @patch('src.spotify_helper.spotipy.Spotify') @patch('src.spotify_helper.SpotifyOAuth') def test_configure_api_returns_none_if_spotipy_raises_exception(self, oauth_mock, spotify_mock): spotify_mock.side_effect = Exception('TestException') api = self.helper.configure_api('test_client_id', 'test_client_secret', 'test_redirect') self.assertIsNone(api) @patch('src.spotify_helper.spotipy.Spotify') @patch('src.spotify_helper.SpotifyOAuth') def test_configure_api_returns_client_object_if_spotipy_returns_the_correct_type(self, oauth_mock, spotify_mock): spotify_mock.return_value = spotipy.client.Spotify() api = self.helper.configure_api('test_client_id', 'test_client_secret', 'test_redirect') self.assertEqual(api, spotify_mock.return_value) # Tests for SpotifyHelper.get_all_collab_playlists ----- # def test_get_all_collab_playlists_returns_none_if_no_api_clients_are_given_or_configured(self): self.helper.api = None self.assertEqual(self.helper.get_all_collab_playlists('creator_id', api=None), None) def test_get_all_collab_playlists_uses_api_client_received_as_argument_instead_of_preconfigured_client(self): stubbed_response = { 'items': [], 'owner': { 'id': 'creator_id' }, 'total': 0 } preconfigured_api = spotipy.client.Spotify() preconfigured_api.current_user_playlists = Mock(return_value=stubbed_response) given_api = spotipy.client.Spotify() given_api.current_user_playlists = Mock(return_value=stubbed_response) self.helper.api = preconfigured_api self.helper.get_all_collab_playlists('creator_id', api=given_api) preconfigured_api.current_user_playlists.assert_not_called() given_api.current_user_playlists.assert_called() def test_get_all_collab_playlists_uses_preconfigured_api_client_if_no_client_is_given_as_an_argument(self): preconfigured_api = spotipy.client.Spotify() preconfigured_api.current_user_playlists = Mock(return_value={ 'items': [], 'owner': { 'id': 'creator_id' }, 'total': 0 }) self.helper.api = preconfigured_api self.helper.get_all_collab_playlists('creator_id', api=None) preconfigured_api.current_user_playlists.assert_called() def test_get_all_collab_playlists_returns_only_collaborative_playlists_owned_by_the_user_and_in_correct_format(self): response = { 'items': [ { 'uri': self.generate_track_uri(), 'collaborative': False, 'owner': { 'id': 'creator_id' } } for i in range(0, 130) ], 'total': 132 } response['items'][0]['collaborative'] = True # collaborative but owned by someone else response['items'][0]['owner']['id'] = 'someotheruser' for item in range(130, 132): response['items'].append({ 'uri': self.generate_playlist_uri(), 'collaborative': True, 'owner': { 'id': 'creator_id' } }) mock_api = spotipy.client.Spotify() mock_api.current_user_playlists = Mock() mock_api.current_user_playlists.side_effect = [ { 'items': response['items'][0:50], 'total': 130 }, { 'items': response['items'][50:100], 'total': 130 }, { 'items': response['items'][100:], 'total': 130 } ] expected_result = [ { 'uri': item['uri'] } for item in response['items'][130:] ] result = self.helper.get_all_collab_playlists('creator_id', api=mock_api) self.assertEqual(result, expected_result) def test_get_all_collab_playlists_fetches_playlists_in_blocks_of_50_playlists(self): response = { 'items': [ { 'uri': 'playlist_uri%d' % item, 'collaborative': False, 'owner': { 'id': 'creator_id' } } for item in range(0, 130) ], 'total': 130 } mock_api = spotipy.client.Spotify() mock_api.current_user_playlists = Mock() mock_api.current_user_playlists.side_effect = [ { 'items': response['items'][0:50], 'total': 130 }, { 'items': response['items'][50:100], 'total': 130 }, { 'items': response['items'][100:], 'total': 130 } ] self.helper.get_all_collab_playlists('creator_id', api=mock_api) self.assertEqual(mock_api.current_user_playlists.call_count, 3) self.assertEqual(mock_api.current_user_playlists.call_args_list[0][1]['limit'], 50) self.assertEqual(mock_api.current_user_playlists.call_args_list[0][1]['offset'], 0) self.assertEqual(mock_api.current_user_playlists.call_args_list[1][1]['limit'], 50) self.assertEqual(mock_api.current_user_playlists.call_args_list[1][1]['offset'], 50) self.assertEqual(mock_api.current_user_playlists.call_args_list[2][1]['limit'], 50) self.assertEqual(mock_api.current_user_playlists.call_args_list[2][1]['offset'], 100) # ----- Tests for SpotifyHelper.add_items_to_playlist ----- \ def test_add_items_to_playlist_uses_api_client_received_as_argument_instead_of_preconfigured_client(self): self.helper.api = spotipy.client.Spotify() self.helper.api.playlist_add_items = Mock() received_api = spotipy.client.Spotify() received_api.playlist_add_items = Mock() self.helper.add_items_to_playlist(self.generate_spotify_id(), [ { 'uri': self.generate_track_uri() } for i in range (0, 2) ], api=received_api) received_api.playlist_add_items.assert_called_once() self.helper.api.playlist_add_items.assert_not_called() def test_add_items_to_playlist_uses_preconfigure_api_client_one_is_available_and_no_api_is_given(self): self.helper.api = spotipy.client.Spotify() self.helper.api.playlist_add_items = Mock() self.helper.add_items_to_playlist(self.generate_spotify_id(), [ { 'uri': self.generate_track_uri() } for i in range (0, 2) ]) self.helper.api.playlist_add_items.assert_called_once() def test_add_items_to_playlist_does_not_attempt_to_add_items_if_no_preconfigured_or_provided_api_is_available(self): # the API mock should be ignored because it is not of the correct type but if execution # unexpectedly continued then its playlist_add_items method would be called. # Therefore this mock and its method can be used to test that execution stops before # an attempt is made to add the items to the playlist self.helper.api = Mock() self.helper.add_items_to_playlist(self.generate_spotify_id(), [ { 'uri': self.generate_track_uri() } for i in range (0, 2) ]) self.helper.api.playlist_add_items.assert_not_called() def test_add_items_to_playlist_add_items_in_blocks_of_100(self): mock_api = spotipy.client.Spotify() mock_api.playlist_add_items = Mock() pl_id = self.generate_spotify_id() items = [ { 'uri': self.generate_track_uri() } for i in range(0, 230) ] item_uris = [ item['uri'] for item in items ] self.helper.add_items_to_playlist(pl_id, items, api=mock_api) self.assertEqual(mock_api.playlist_add_items.call_count, 3) self.assertEqual(len(mock_api.playlist_add_items.call_args_list[0][0]), 2) self.assertEqual(mock_api.playlist_add_items.call_args_list[0][0][0], pl_id) self.assertEqual(mock_api.playlist_add_items.call_args_list[0][0][1], item_uris[0:100]) self.assertEqual(len(mock_api.playlist_add_items.call_args_list[1][0]), 2) self.assertEqual(mock_api.playlist_add_items.call_args_list[1][0][0], pl_id) self.assertEqual(mock_api.playlist_add_items.call_args_list[1][0][1], item_uris[100:200]) self.assertEqual(len(mock_api.playlist_add_items.call_args_list[2][0]), 2) self.assertEqual(mock_api.playlist_add_items.call_args_list[2][0][0], pl_id) self.assertEqual(mock_api.playlist_add_items.call_args_list[2][0][1], item_uris[200:]) # ----- Tests for SpotifyHelper.get_track_id ----- # def test_get_track_id_returns_correct_id_when_input_is_uri(self): track_id = self.generate_spotify_id() track_uri = 'spotify:track:' + track_id self.assertEqual(self.helper.get_track_id(track_uri), track_id) def test_get_track_id_returns_correct_id_when_input_is_dict_with_uri(self): track_id = self.generate_spotify_id() track_uri = 'spotify:track:' + track_id self.assertEqual(self.helper.get_track_id({ 'uri': track_uri }), track_id) def test_get_track_id_returns_none_if_input_is_string_with_no_uri(self): self.assertIsNone(self.helper.get_track_id('somestringwithnouri')) def test_get_track_id_returns_none_if_input_is_dict_with_no_uri_field(self): self.assertIsNone(self.helper.get_track_id({ 'otherfield': self.generate_spotify_id() })) # ----- Tests for SpotifyHelper.get_playlist_id ----- # def test_get_playlist_id_returns_correct_id_when_input_is_uri(self): pl_id = self.generate_spotify_id() pl_uri = 'spotify:playlist:' + pl_id self.assertEqual(self.helper.get_playlist_id(pl_uri), pl_id) def test_get_playlist_id_returns_correct_id_when_input_is_dict_with_uri(self): pl_id = self.generate_spotify_id() pl_uri = 'spotify:playlist:' + pl_id self.assertEqual(self.helper.get_playlist_id({ 'uri': pl_uri }), pl_id) def test_get_playlist_id_returns_none_if_input_is_string_with_no_uri(self): self.assertIsNone(self.helper.get_playlist_id('somestringwithnouri')) def test_get_playlist_id_returns_none_if_input_is_dict_with_no_uri_field(self): self.assertIsNone(self.helper.get_track_id({ 'otherfield': self.generate_spotify_id() })) # ----- Tests for SpotifyHelper.get_all_items_in_playlist ----- # def test_get_all_items_in_playlist_uses_api_client_received_as_argument_instead_of_preconfigured_client(self): self.helper.api = spotipy.client.Spotify() self.helper.api.playlist_items = Mock(return_value={ 'items': [] }) received_api = spotipy.client.Spotify() received_api.playlist_items = Mock(return_value={ 'items': [] }) self.helper.get_all_items_in_playlist(self.generate_spotify_id(), fields=None, api=received_api) received_api.playlist_items.assert_called_once() self.helper.api.playlist_items.assert_not_called() def test_get_all_items_in_playlist_uses_preconfigure_api_client_one_is_available_and_no_api_is_given(self): self.helper.api = spotipy.client.Spotify() self.helper.api.playlist_items = Mock(return_value={ 'items': [] }) self.helper.get_all_items_in_playlist(self.generate_spotify_id(), fields=None) self.helper.api.playlist_items.assert_called_once() def test_get_all_items_playlist_returns_none_if_there_is_no_preconfigured_or_received_api_available(self): self.helper.api = None self.assertIsNone(self.helper.get_all_items_in_playlist(self.generate_spotify_id(), fields=None)) def test_get_all_items_in_playlist_returns_all_constituent_items(self): items = [ { 'track': { 'uri': self.generate_track_uri(), 'name': 'track_name' }, 'added_at': 'added_at_timestamp', 'added_by': { 'id': self.generate_spotify_id() }, 'position': index } for index in range(0, 230) ] mock_api = spotipy.client.Spotify() mock_api.playlist_items = Mock(side_effect=[ { 'items': items[0:100] }, { 'items': items[100:200] }, { 'items': items[200:] } ]) result = self.helper.get_all_items_in_playlist( self.generate_spotify_id(), api=mock_api, fields='items(added_at,added_by,track(uri.name))') self.assertEqual(result, items)
def __init__(self, logger, api, playlist_creator_id, config): self.logger = logger.getChild('PlaylistCleaner') self.api = api self.playlist_creator_id = playlist_creator_id self.config = config self.spotify_helper = SpotifyHelper(self.logger)
class PlaylistCleaner: def __init__(self, logger, api, playlist_creator_id, config): self.logger = logger.getChild('PlaylistCleaner') self.api = api self.playlist_creator_id = playlist_creator_id self.config = config self.spotify_helper = SpotifyHelper(self.logger) def run(self, playlist): playlist_id = self.spotify_helper.get_playlist_id(playlist) pl_details = self.api.playlist(playlist_id, fields='name') self.logger.info('Scanning playlist \'%s\' for unauthorized additions (PID: %s)', pl_details['name'], playlist_id) unauth_additions = self.find_unauthorized_additions(playlist_id) if len(unauth_additions) > 0: self.remove_playlist_items(playlist_id, unauth_additions) def find_unauthorized_additions(self, playlist_id): pl_uri = 'spotify:playlist:' + playlist_id all_items = self.spotify_helper.get_all_items_in_playlist( playlist_id, fields='items(added_at,added_by.id,track(name,uri)),total', api=self.api) unauth_additions = [] for item in all_items: if not self.playlist_addition_is_authorized(item['added_by']['id'], playlist_id): unauth_additions.append({ 'name': item['track']['name'], 'uri': item['track']['uri'], 'added_at': item['added_at'], 'added_by': item['added_by']['id'], 'position': item['position'] }) self.logger.debug('Identified %d unauthorized track additions (PID: %s)' % (len(unauth_additions), playlist_id)) return unauth_additions def remove_playlist_items(self, playlist_id, items): items_with_pos = [ { 'uri': item['uri'], 'positions': [ item['position'] ] } for item in items ] item_limit = 100 lower_bound = 0 upper_bound = 100 more_to_process = True self._log_playlist_item_removal(playlist_id, items) while more_to_process: more_to_process = upper_bound < len(items_with_pos) self.api.playlist_remove_specific_occurrences_of_items(playlist_id, items_with_pos[lower_bound:upper_bound]) lower_bound = upper_bound upper_bound = (lower_bound + item_limit if lower_bound + item_limit < len(items_with_pos) else len(items_with_pos)) def playlist_addition_is_authorized(self, adder_id, playlist_id): if adder_id == self.playlist_creator_id: return True local_auth = self._local_authorization(adder_id, playlist_id) return (local_auth == 'authorized' or (local_auth == 'neutral' and self._global_authorization(adder_id) == 'authorized')) def _global_authorization(self, adder_id): # Return values: # 'neutral' - neither explicitly authorized nor explicitly unauthorized # 'authorized' - explicitly authorized # 'unauthorized' - explicitly authorized if 'GLOBAL_MODE' in self.config.keys(): if self.config['GLOBAL_MODE'] == 'blacklist': return 'unauthorized' if adder_id in self.config['GLOBAL_BLACKLIST'] else 'authorized' elif self.config['GLOBAL_MODE'] == 'whitelist': return 'authorized' if adder_id in self.config['GLOBAL_WHITELIST'] else 'unauthorized' return 'neutral' def _local_authorization(self, adder_id, playlist_id): # Return values: # 'neutral' - neither explicitly authorized nor explicitly unauthorized # 'authorized' - explicitly authorized # 'unauthorized' - explicitly authorized playlist_config = self._get_playlist_config(playlist_id) if playlist_config is not None: if 'blacklist' in playlist_config.keys(): return 'unauthorized' if adder_id in playlist_config['blacklist'] else 'authorized' elif 'whitelist' in playlist_config.keys(): return 'authorized' if adder_id in playlist_config['whitelist'] else 'unauthorized' return 'neutral' def _log_playlist_item_removal(self, playlist_id, items): for item in items: self.logger.info('Removing \'%s\' added by user \'%s\' at %s (Track URI: %s) (PID: %s)' % (item['name'], item['added_by'], item['added_at'], item['uri'], playlist_id)) def _get_playlist_config(self, playlist_id): pl_uri = 'spotify:playlist:' + playlist_id if 'PROTECTED_PLAYLISTS' not in self.config.keys(): return None for playlist in self.config['PROTECTED_PLAYLISTS']: if len(playlist.keys()) != 1: return None for key, val in playlist.items(): if pl_uri == val['uri']: return val return None