コード例 #1
0
    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]
コード例 #2
0
    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
コード例 #3
0
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()
コード例 #4
0
    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
コード例 #5
0
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)
コード例 #6
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
コード例 #7
0
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)
コード例 #8
0
 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)
コード例 #9
0
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