Пример #1
0
class SpotifyWebAPI(APIBase):
    artist: str = None
    title: str = None
    is_playing: bool = None

    def __init__(self, token: RefreshingToken) -> None:
        super().__init__()
        self.artist = ""
        self.title = ""
        self.is_playing = False
        self._position = 0
        self._token = token
        self._spotify = Spotify(self._token)
        self._event_timestamp = time.time()

    def connect_api(self) -> None:
        self._refresh_metadata()

    @property
    def position(self) -> int:
        """
        _refresh_metadata() has to be called because the song position is
        constantly changing.
        """

        self._refresh_metadata()
        return self._position

    def _refresh_metadata(self) -> None:
        """
        Refreshes the metadata of the player: artist, title, whether
        it's playing or not, and the current position.
        """

        metadata = self._spotify.playback_currently_playing()
        if metadata is None:
            raise ConnectionNotReady("No song currently playing")
        self.artist = metadata.item.artists[0].name
        self.title = metadata.item.name

        # Some local songs don't have an artist name so `split_title`
        # is called in an attempt to manually get it from the title.
        if self.artist == '':
            self.artist, self.title = split_title(self.title)

        self._position = metadata.progress_ms
        self.is_playing = metadata.is_playing

    def event_loop(self) -> None:
        """
        A callable event loop that checks if changes happen. This is called
        every 0.5 seconds from the Qt window.

        It checks for changes in:
            * The playback status (playing/paused) to change the player's too
            * The currently playing song: if a new song started, it's played
            * The position. Changes will be ignored unless the difference is
              larger than the real elapsed time or if it was backwards.
              This is done because some systems may lag more than others and
              a fixed time difference would cause errors
        """

        # Previous properties are saved to compare them with the new ones
        # after the metadata refresh
        artist = self.artist
        title = self.title
        position = self._position
        is_playing = self.is_playing
        self._refresh_metadata()

        # The first check should be if the song has ended to not touch
        # anything else that may not actually be true.
        if self.artist != artist or self.title != title:
            logging.info("New video detected")
            self.new_song_signal.emit(self.artist, self.title, 0)

        if self.is_playing != is_playing:
            logging.info("Status change detected")
            self.status_signal.emit(self.is_playing)

        playback_diff = self._position - position
        calls_diff = int((time.time() - self._event_timestamp) * 1000)
        if playback_diff >= (calls_diff + 100) or playback_diff < 0:
            logging.info("Position change detected")
            self.position_signal.emit(self._position)

        # The time passed between calls is refreshed
        self._event_timestamp = time.time()
Пример #2
0
class SpotifyWebAPI(APIBase):
    player_name: str = "Spotify"
    artist: str = None
    title: str = None
    is_playing: bool = None

    def __init__(self, token: RefreshingToken) -> None:
        super().__init__()
        self.artist = ""
        self.title = ""
        self.is_playing = False
        self._position = 0
        self._token = token
        self._spotify = Spotify(self._token)
        self._event_timestamp = time.time()

    def connect_api(self) -> None:
        self._refresh_metadata()

    @property
    def position(self) -> int:
        """
        _refresh_metadata() has to be called because the song position is
        constantly changing.
        """

        self._refresh_metadata()
        return self._position

    def _refresh_metadata(self) -> None:
        """
        Refreshes the metadata of the player: artist, title, whether
        it's playing or not, and the current position.
        """

        metadata = self._spotify.playback_currently_playing()
        if metadata is None or metadata.item is None:
            raise ConnectionNotReady("No song currently playing")
        self.artist = metadata.item.artists[0].name
        self.title = metadata.item.name

        # Some local songs don't have an artist name so `split_title`
        # is called in an attempt to manually get it from the title.
        if self.artist == '':
            self.artist, self.title = split_title(self.title)

        self._position = metadata.progress_ms
        self.is_playing = metadata.is_playing

    def event_loop(self) -> None:
        """
        The event loop callback that checks if changes happen. This is called
        periodically within the Qt window.

        It checks for changes in:
            * The playback status (playing/paused) to change the player's too
            * The currently playing song: if a new song started, it's played
            * The position
        """

        # Previous properties are saved to compare them with the new ones
        # after the metadata refresh
        artist = self.artist
        title = self.title
        position = self._position
        is_playing = self.is_playing
        self._refresh_metadata()

        # First checking if a new song started, so that position or status
        # changes are related to the new song.
        if self.artist != artist or self.title != title:
            logging.info("New video detected")
            self.new_song_signal.emit(self.artist, self.title, 0)

        if self.is_playing != is_playing:
            logging.info("Status change detected")
            self.status_signal.emit(self.is_playing)

        # The position difference between calls is compared to the elapsed
        # time to know whether the position has been modified.
        # Changes will be ignored unless the position difference is
        # greater than the elapsed time (plus a margin) or if it's negative
        # (backwards).
        playback_diff = self._position - position
        calls_diff = int((time.time() - self._event_timestamp) * 1000)
        if playback_diff >= (calls_diff + 100) or playback_diff < 0:
            logging.info("Position change detected")
            self.position_signal.emit(self._position)

        # The time passed between calls is refreshed
        self._event_timestamp = time.time()
Пример #3
0
class SpotiCLI(Cmd):
    def __init__(self, token):
        super().__init__()

        self.sp_user = Spotify(token)

        app_name = 'SpotiCLI'
        version = '1.20.0917.dev'

        ###define app parameters
        self.app_info = f'{Fore.CYAN}{Style.BRIGHT}\n{app_name} {version}{Style.RESET_ALL}'
        self.intro = self.app_info + '\n'
        self.prompt = f'{Fore.GREEN}{Style.BRIGHT}spoticli ~$ {Style.RESET_ALL}'

        self.current_endpoint = ''
        self.api_delay = 0.3

        #hide built-in cmd2 functions. this will leave them available for use but will be hidden from tab completion (and docs)
        self.hidden_commands.append('alias')
        self.hidden_commands.append('unalias')
        self.hidden_commands.append('set')
        self.hidden_commands.append('edit')
        self.hidden_commands.append('history')
        self.hidden_commands.append('load')
        self.hidden_commands.append('macro')
        self.hidden_commands.append('py')
        self.hidden_commands.append('pyscript')
        self.hidden_commands.append('quit')
        self.hidden_commands.append('shell')
        self.hidden_commands.append('shortcuts')
        self.hidden_commands.append('_relative_load')
        self.hidden_commands.append('run_pyscript')
        self.hidden_commands.append('run_script')
        self.debug = True

        ##define permissions scope...

    #### Misc / Util methods
    ##########################################
    '''
    convert milliseconds into human readable time stamp in format MM:SS 
    '''

    def ms_to_time(self, time_to_convert):

        #modulus to get seconds from ms timestamp
        seconds = (time_to_convert / 1000) % 60
        seconds = str(int(seconds))

        #modulus to get minutes from ms timestamp
        minutes = (time_to_convert / (1000 * 60)) % 60
        minutes = str(int(minutes))

        #if seconds is single digit, prefix with 0
        if (len(seconds) < 2):
            seconds = '0' + seconds

        #return formatted value
        return f'{minutes}:{seconds}'

    '''
    generate timestamp in format "length / duration" from song playback data  
    '''

    def generate_timestamp(self, song_data):
        pos_ms = self.ms_to_time(self.get_position(song_data))
        dur_ms = self.ms_to_time(self.get_duration(song_data.item))

        return f'{pos_ms} / {dur_ms}'

    ### I don't remember what this did and it's probably no longer needed....
    ### I'll leave it here in case I remember
    def print_list(self, list_to_print):
        for index, item in enumerate(list_to_print):
            self.poutput(f'{index + 1}:\t{item}')

    '''
    formats a track string in format:

    song by artist on album

    params: current playback object
    returns: formatted string
    '''

    def display_song(self, song_data):
        song_name = self.get_song(song_data)
        song_artist = self.get_artist(song_data)
        song_album = self.get_album(song_data)

        return f'{song_name} by {song_artist} on {song_album}'

    '''
    just using this function to standardize the 'no endpoint' error
    '''

    def print_endpoint_error(self):
        self.pwarning('no available playback devices detected')
        self.pwarning('assign one with the endpoint command')

    #### accessor / mutators
    #### getter / setter, whatever
    #### these make the spotify api calls
    ##########################################

    def get_playback(self):
        return self.sp_user.playback()

    def get_current_playback(self):
        return self.sp_user.playback_currently_playing()

    ## accessors
    ############################

    # track specific
    ################

    def get_album(self, song_data):
        try:
            return f'{Fore.MAGENTA}{Style.BRIGHT}{song_data.album.name}{Style.RESET_ALL}'
        except:
            return f'{Fore.MAGENTA}{Style.BRIGHT}{song_data.name}{Style.RESET_ALL}'

    def get_artist(self, song_data):
        ### artists is an array as a song can have multiple artists
        ### if there is multiple artists, return name of _first_ artist in array (usually main artist)
        try:
            return f'{Fore.CYAN}{Style.BRIGHT}{song_data.artists[0].name}{Style.RESET_ALL}'
        except:
            return f'{Fore.CYAN}{Style.BRIGHT}{song_data.name}{Style.RESET_ALL}'

    def get_song(self, song_data):
        return f'{Fore.GREEN}{Style.BRIGHT}{song_data.name}{Style.RESET_ALL}'

    def get_song_id(self, song_data):
        return song_data.id

    def get_duration(self, song_data):
        return song_data.duration_ms

    def get_is_playing(self, song_data):
        return song_data.is_playing

    def get_position(self, song_data):
        return song_data.progress_ms

    # generic functions
    ################

    ### sometimes a device is no longer marked 'active' if it's been idle too long
    ### this 'forces' playback/activity on last active device
    ### need to make this more seemless for the user
    ### def force_device(self):
    ###     current_dev = self.get_device()
    ###     self.sp_user.playback_transfer(current_dev[0].asdict()['id'])
    ###
    ### def do_force(self, line):
    ###     self.force_device()

    # generic accessors
    ################

    def get_device(self):
        return self.sp_user.playback_devices()

    def get_history(self, last_songs):
        return self.sp_user.playback_recently_played(last_songs)

    def get_repeat_state(self):
        try:
            return self.get_playback().repeat_state
        except:
            self.print_endpoint_error()
            return None

    def get_shuffle_state(self):
        try:
            return self.get_playback().shuffle_state
        except:
            self.print_endpoint_error()
            return None

    def get_volume(self):
        try:
            return self.get_playback().device.volume_percent
        except:
            self.print_endpoint_error()
            return None

    ## mutator
    ############################

    ## these methods add artificial delay after calling API
    ## this is needed to allow the API some time to 'catch-up' with our request
    ## needed as we'll usually send a 'get' request not long after and if we send too soon
    ## API might return wrong info

    def set_device(self, new_device):
        self.sp_user.playback_transfer(new_device)
        time.sleep(self.api_delay)

    def set_playback_context(self, playback_uri):
        self.sp_user.playback_start_context(context_uri=playback_uri)
        time.sleep(self.api_delay)

    def set_playback_track(self, new_track):
        if (not isinstance(new_track, list)):
            track_list = []
            track_list.append(new_track)
            self.sp_user.playback_start_tracks(track_ids=track_list)
        else:
            self.sp_user.playback_start_tracks(track_ids=new_track)
        time.sleep(self.api_delay)

    def set_play_next(self):
        try:
            self.sp_user.playback_next()
            time.sleep(self.api_delay)
            self.do_current('')
        except:
            self.print_endpoint_error()
            return None

    def set_play_resume(self):
        try:
            self.sp_user.playback_resume()
            time.sleep(self.api_delay)
            self.do_current('')
        except:
            self.print_endpoint_error()
            return None

    def set_play_pause(self):
        try:
            self.sp_user.playback_pause()
            time.sleep(self.api_delay)
            self.do_current('')
        except:
            self.print_endpoint_error()
            return None

    def set_play_previous(self):
        try:
            self.sp_user.playback_previous()
            time.sleep(self.api_delay)
            self.do_current('')
        except:
            self.print_endpoint_error()
            return None

    def set_position(self, new_time):
        try:
            self.sp_user.playback_seek(new_time)
            time.sleep(self.api_delay)
            self.do_current('')
        except:
            self.print_endpoint_error()
            return None

    def set_repeat_state(self, new_repeat_state):
        try:
            self.sp_user.playback_repeat(new_repeat_state)
            time.sleep(self.api_delay)
        except:
            self.print_endpoint_error()
            return None

    def set_save(self, song_id):
        self.sp_user.saved_tracks_add(song_id)
        time.sleep(self.api_delay)

    def set_unsave(self, song_id):
        self.sp_user.saved_tracks_delete(song_id)
        time.sleep(self.api_delay)

    def set_shuffle_state(self, new_shuffle_state):
        try:
            self.sp_user.playback_shuffle(new_shuffle_state)
            time.sleep(self.api_delay)
        except:
            self.print_endpoint_error()
            return None

    def set_volume(self, new_volume):
        self.sp_user.playback_volume(new_volume)
        time.sleep(self.api_delay)

    #### cmd2 native functions
    ##########################################

    #prints blank line
    #necessary to overload cmd2's default behavior (retry previous command)
    def emptyline(self):
        return

    #overloads default error message
    def default(self, line):
        self.perror('unrecognized command')

    #used to write an extra blank line between commands...just a formatting thing.
    def postcmd(self, line, stop):
        self.poutput('')
        return line

    #### Begin CMD2 commands below
    ##########################################

    def do_about(self, line):
        '''
        show build information

        usage: 
            about
        '''
        self.poutput(self.app_info)

    def do_diagnostics(self, line):
        '''
        display diagnostic info relating to current session
        '''
        self.poutput(
            f'current user: \t{self.sp_user.current_user().display_name}')
        #self.poutput(f'device name: \t{self.current_endpoint.name}')
        #self.poutput(f'device id: \t{self.current_endpoint.id}')
        self.poutput(f'api delay: \t{self.api_delay}')

    def do_exit(self, line):
        '''
        exit application
        
        usage:
            exit
        '''
        return True

    def do_logout(self, line):
        '''
        logout current session and force login next program start

        usage:
            logout
        '''
        is_user_sure = getpass.getpass(
            'are you sure? type \'yes\' to proceed: ')
        self.poutput(is_user_sure)
        if (is_user_sure == 'yes'):
            if (fsop.fsop.delete_conf(self)):
                self.pwarning('user creds deleted')
                return
            self.perror('failed to logout!')
            self.pwarning(
                'unable to delete config files, please try manual removal')
            self.pwarning(
                'can be found in your home config directory, .config/spoticli/'
            )
        else:
            self.pwarning('not logged out')

    #### playback commands
    ##########################################

    ###
    ### [Playing - 0:05 / 4:24] Make Me Wanna Die by The Pretty Reckless on Make Me Wanna Die
    ### [Stopped - 0:05 / 4:24] Make Me Wanna Die by The Pretty Reckless on Make Me Wanna Die
    def do_current(self, line):
        '''
        show currently playing track

        usage:
            current
        '''
        #now_playing = f'[{playing_state} - {timestamp}] {song_name} by {artist_name} on {album_name}'
        song_data = self.get_current_playback()

        if (song_data == None):
            self.print_endpoint_error()
            return

        song_playing = self.get_is_playing(song_data)

        if (song_playing == True):
            song_playing = f'{Fore.BLUE}{Style.BRIGHT}Playing'
        else:
            song_playing = f'{Fore.RED}{Style.BRIGHT}Stopped'

        time_stamp = self.generate_timestamp(song_data)

        now_playing = f'[{song_playing} - {time_stamp}{Style.RESET_ALL}] {self.display_song(song_data.item)}'
        self.poutput(now_playing)

    def play_next(self, args):
        if (self.set_play_next() == None):
            return
        self.poutput('playing next')
        self.do_current(self)

    def play_previous(self, args):
        if (self.set_play_previous() == None):
            return
        self.poutput('playing previous')
        self.do_current(self)

    play_parser = argparse.ArgumentParser(prog='play', add_help=False)
    play_subparsers = play_parser.add_subparsers(title='playback options')

    parser_play_next = play_subparsers.add_parser('next',
                                                  help='next track',
                                                  add_help=False)
    parser_play_next.set_defaults(func=play_next)

    parser_play_previous = play_subparsers.add_parser('previous',
                                                      help='previous track',
                                                      add_help=False)
    parser_play_previous.set_defaults(func=play_previous)

    play_subcommands = ['next', 'previous']

    @with_argparser(play_parser)
    def do_play(self, line):
        '''
        start or resume playback, or play next/previous song

        usage:
            play [next|previous]
        '''

        # Call whatever sub-command function was selected
        try:
            line.func(self, line)
        #if none specified do default action (start playback)
        except AttributeError:
            try:
                if (self.set_play_resume() == None):
                    return
            except:
                pass
            self.do_current(self)

    def do_pause(self, line):
        '''
        pause playback

        usage:
            pause
        '''
        try:
            if (self.set_play_pause() == None):
                return
        except:
            pass
        self.do_current(self)

    def do_replay(self, line):
        '''
        instantly restart current track

        usage:
            replay
        '''

        ###just re-use existing function
        self.do_seek('0')

    def do_seek(self, line):
        ### time should be in seconds or as a timestamp value, ie. 1:41
        ### not implemented yet...
        '''
        seek to specific time in a track. specify a step increase by prefixing time with +/-

        usage:
            seek [+/-] time
        '''

        ## no value specified; exit
        if (not line):
            self.do_help('seek')
            return

        try:
            new_pos = 1000 * int(line)
        #non-numerical value specified; exit
        except ValueError:
            self.perror('invalid time')
            return

        song_data = self.get_current_playback()
        if (song_data == None):
            self.print_endpoint_error()
            return

        song_pos = self.get_position(song_data)
        song_dur = self.get_duration(song_data.item)

        #if new time is larger than song duration, quit
        if ((new_pos > song_dur) or (new_pos < (song_dur * -1))):
            self.perror('invalid time')
            return

        if (line[0] == '+' or line[0] == '-'):
            song_pos = song_pos + new_pos
            self.set_position(song_pos)
        else:
            self.set_position(new_pos)

    #### playback properties
    ##########################################

    def do_volume(self, line):
        '''
        set volume to specified level, range 0-100
        specify a step increase by prefixing value with +/-, otherwise it defaults to 10% step
        
        usage: 
            volume [+/-][value]
        '''

        current_vol = self.get_volume()

        if (current_vol == None):
            return

        if (line):
            try:
                new_vol = int(line)
                if (line[0] == '+' or line[0] == '-'):
                    new_vol = new_vol + current_vol
            except ValueError:
                if (line[0] == '+'):
                    new_vol = current_vol + 10
                elif (line[0] == '-'):
                    new_vol = current_vol - 10
                else:
                    self.perror('invalid volume')
                    return

            if new_vol > 100:
                new_vol = 100
            elif new_vol < 0:
                new_vol = 0
            self.set_volume(new_vol)
        self.poutput(f'current volume: {self.get_volume()}')

    def do_endpoint(self, line):
        '''
        transfer playback between valid spotify connect endpoints

        usage:
            endpoint
        '''
        endpoint_list = self.get_device()

        if (len(endpoint_list) == 0):
            self.pwarning('no available endpoints detected')
            self.pwarning(
                'make sure there is an available endpoint before assigning playback again'
            )
            return

        max_index = 0

        print_string = ''
        current_active = ''

        for index, item in enumerate(endpoint_list):
            max_index = index

            print_string += f'{index + 1}:\t{item.name}'

            if (item.is_active):
                current_active = item.name
                print_string += ' (active)'
            print_string += '\n'

        if (current_active == ''):
            current_active = 'No active endpoint'

        self.poutput(f'current endpoint: {current_active}')
        self.poutput('available endpoints:')
        self.poutput(print_string)

        user_input = getpass.getpass('select endpoint: ')
        if (user_input == ''):
            return
        self.poutput(f'selected: {user_input}')

        try:
            user_input = int(user_input) - 1
            if (user_input > max_index or user_input < 0):
                raise ValueError
        except:
            self.pwarning('invalid selection')
            return

        self.set_device(endpoint_list[user_input].id)
        #self.current_endpoint = endpoint_list[user_input]
        #self.set_device(self.current_endpoint.id)

    def repeat_enable(self, args):
        if (self.set_repeat_state('context') == None):
            self.do_repeat('')

    def repeat_track(self, args):
        if (self.set_repeat_state('track') == None):
            self.do_repeat('')

    def repeat_disable(self, args):
        if (self.set_repeat_state('off') == None):
            self.do_repeat('')

    repeat_parser = argparse.ArgumentParser(prog='repeat', add_help=False)
    repeat_subparsers = repeat_parser.add_subparsers(title='repeat states')

    parser_repeat_track = repeat_subparsers.add_parser('track',
                                                       help='repeat track',
                                                       add_help=False)
    parser_repeat_track.set_defaults(func=repeat_track)

    parser_repeat_enable = repeat_subparsers.add_parser('enable',
                                                        help='enable repeat',
                                                        add_help=False)
    parser_repeat_enable.set_defaults(func=repeat_enable)

    parser_repeat_disable = repeat_subparsers.add_parser('disable',
                                                         help='disable repeat',
                                                         add_help=False)
    parser_repeat_disable.set_defaults(func=repeat_disable)

    repeat_subcommands = ['track', 'enable', 'disable']

    @with_argparser(repeat_parser)
    def do_repeat(self, line):
        '''
        show or modify repeat state

        usage: 
            repeat [enable|disable|track]
        '''

        # Call whatever sub-command function was selected
        try:
            line.func(self, line)
        except AttributeError:
            current_repeat = self.get_repeat_state()
            if (current_repeat == None):
                return
            current_repeat = current_repeat.value

            ### valid states:
            ### track - repeat enabled for track
            ### enabled - repeat enabled for playlist/album
            ### disabled - repeat disabled

            if (current_repeat == 'context'):
                self.poutput('repeat is enabled')
            elif (current_repeat == 'off'):
                self.poutput('repeat is disabled')
            elif (current_repeat == 'track'):
                self.poutput('repeating track')

    def shuffle_enable(self, args):
        if (self.set_shuffle_state(True) == None):
            self.do_shuffle('')

    def shuffle_disable(self, args):
        if (self.set_shuffle_state(False) == None):
            self.do_shuffle('')

    shuffle_parser = argparse.ArgumentParser(prog='shuffle', add_help=False)
    shuffle_subparsers = shuffle_parser.add_subparsers(title='shuffle states')

    # create the parser for the "foo" sub-command
    parser_shuffle_enable = shuffle_subparsers.add_parser(
        'enable', help='enable shuffle', add_help=False)
    parser_shuffle_enable.set_defaults(func=shuffle_enable)

    # create the parser for the "foo" sub-command
    parser_shuffle_disable = shuffle_subparsers.add_parser(
        'disable', help='disable shuffle', add_help=False)
    parser_shuffle_disable.set_defaults(func=shuffle_disable)

    shuffle_subcommands = ['enable', 'disable']

    @with_argparser(shuffle_parser)
    def do_shuffle(self, line):
        '''
        show or modify shuffle state

        usage:
            shuffle [enable|disable]
        '''
        ### valid states:
        ### enabled - shuffle enabled
        ### disabled - shuffle disabled

        #if line is empty, print shuffle state
        try:
            line.func(self, line)
        except AttributeError:
            current_shuffle = self.get_shuffle_state()
            if (current_shuffle == None):
                return

            if (current_shuffle):
                self.poutput('shuffle is enabled')
            else:
                self.poutput('shuffle is disabled')

    #### playlist modification
    ##########################################

    def do_list(self, line):
        '''
        display user playlists

        usage:
            lists
        '''
        max_index = 0
        user_playlists = self.sp_user.followed_playlists().items
        for index, item in enumerate(user_playlists):
            max_index += 1
            self.poutput(f'{index + 1}: \t{item.name}')

        user_input = getpass.getpass('select playlist: ')
        if (user_input == ''):
            return
        self.poutput(f'selected: {user_input}')

        try:
            user_input = int(user_input) - 1
            if (user_input > max_index or user_input < 0):
                raise ValueError
        except:
            self.pwarning('invalid selection')

        self.set_playback_context(user_playlists[user_input].uri)

    def do_previous(self, line):
        '''
        show last 10 songs (or more)

        usage:
            previous [integer]
        '''
        items_to_fetch = 10

        if (line):
            try:
                items_to_fetch = int(line)

                if (items_to_fetch <= 0):
                    items_to_fetch = 3
                    self.pwarning('value too low, retrieving last 3 items')
                    pass

                if (items_to_fetch > 50):
                    items_to_fetch = 50
                    self.pwarning('value too high, retrieving last 50 items')
                    pass
            except:
                self.pwarning('invalid value, retrieving last 10 items')

        for index, prev_song in enumerate(
                self.get_history(items_to_fetch).items):
            self.poutput(
                f'{index + 1}: {self.get_song(prev_song.track)} by {self.get_artist(prev_song.track)} on {self.get_album(prev_song.track)}'
            )

    def do_queue(self, line):
        '''
        show and modify queue

        usage:
            queue
        '''
        self.poutput('not implemented. pending expansion of spotify api')

    def do_save(self, line):
        '''
        add currently playing track to liked songs
        
        usage:
            save
        '''
        song_data = self.get_playback()

        if (song_data == None):
            self.print_endpoint_error()
            return

        song_id = self.get_song_id(song_data.item)

        self.set_save([song_id])
        self.poutput(
            f'{Fore.RED}{Style.BRIGHT}<3{Style.RESET_ALL} - saved song - {self.display_song(song_data.item)}'
        )

    def do_unsave(self, line):
        '''
        remove currently playing track from liked songs
        
        usage:
            unsave
        '''

        song_data = self.get_playback()

        if (song_data == None):
            self.print_endpoint_error()
            return

        song_id = self.get_song_id(song_data.item)

        self.set_unsave([song_id])
        self.poutput(
            f'{Fore.RED}{Style.BRIGHT}</3{Style.RESET_ALL} - removed song - {self.display_song(song_data.item)}'
        )

    def do_search(self, line):
        '''
        search by track, artist or album
        
        usage:
            search [filter] [-c amount] query

        filters:
            -a, --artist
            -b, --album
            -p, --playlist
            -t, --track

        examples: 
            search -t seven nation army
            search -a eminem
            search --playlist -c 3 cool songs
        '''

        if (not line):
            self.pwarning('no query detected')
            self.do_help('search')
            return

        result_limit = 10
        result_type = ()

        ### turn into a list so we can check first flag (if any)
        search_string = line.split(' ')

        ### check for flags in beginning of search string.
        ### if found, remove (so we don't do a search for the flag)
        if ('-a' in search_string[0]):
            result_type = result_type + ('artist', )
            search_string.remove('-a')
        elif ('-b' in search_string[0]):
            result_type = result_type + ('album', )
            search_string.remove('-b')
        elif ('-p' in search_string[0]):
            result_type = result_type + ('playlist', )
            search_string.remove('-p')
        elif ('-t' in search_string[0]):
            result_type = result_type + ('track', )
            search_string.remove('-t')

        if (result_type == ()):
            result_type = ('track', )

        #if no flags detected, default search for track

        ##once we finish checking flags turn back into a string and pass along to search call
        search_string = ' '.join(search_string)

        if (search_string == ''):
            self.pwarning('no query detected')
            self.do_help('search')
            return

        search_results = self.sp_user.search(types=result_type,
                                             limit=result_limit,
                                             query=search_string)
        #print(search_results)

        item_id = []
        for index, item in enumerate(search_results[0].items):
            media_type = item.type

            if (media_type == 'track'):
                self.poutput(
                    f'{str(index + 1)}. \t{media_type} - {self.display_song(item)}'
                )

                ### tekore playback track uses ID or uri depending on what you want to do
                ### save entire item for future ref so we can decide action later
                item_id.append(item)
            if (media_type == 'artist'):
                self.poutput(
                    f'{str(index + 1)}. \t{media_type} - {self.get_artist(item)}'
                )
                item_id.append(item.uri)
            if (media_type == 'album'):
                self.poutput(
                    f'{str(index + 1)}. \t{media_type} - {self.get_album(item)} by {self.get_artist(item)}'
                )
                item_id.append(item.uri)
            if (media_type == 'playlist'):
                self.poutput(f'{str(index + 1)}. \t{media_type} - {item.name}')
                item_id.append(item.uri)

        #for index, item in enumerate(search_results):
        #print(f'{index} : {self.get_song(item[index].items[0].name)}')

        ### check user input for sanity
        user_input = getpass.getpass('select item: ')
        if (user_input == ''):
            return
        self.poutput(f'selected: {user_input}')

        try:
            user_input = int(user_input) - 1
            if (user_input > 9 or user_input < 0):
                raise ValueError
        except:
            self.pwarning('invalid selection')
            return

        ### if input is sane, insta-play. unless it's a track. then prompt for action
        if (result_type == ('track', )):
            self.poutput('1. play')
            self.poutput('2. queue')
            user_action = getpass.getpass('select action: ')
            if (user_action == ''):
                return
            self.poutput(f'selected: {user_action}')

            try:
                user_action = int(user_action) - 1
                if (user_action > 2 or user_action < 0):
                    raise ValueError
            except:
                self.pwarning('invalid action')
                return

            #play
            if (user_action == 0):
                self.set_playback_track(item_id[user_input].id)
                time.sleep(self.api_delay)
                self.do_current('')
                return
            #queue
            if (user_action == 1):
                self.sp_user.playback_queue_add(uri=item_id[user_input].uri)

                self.poutput(
                    f'song queued - {self.display_song(item_id[user_input])}')
                return

        else:
            self.set_playback_context(item_id[user_input])
            time.sleep(self.api_delay)
            self.do_current('')