Example #1
0
class Core(pykka.ThreadingActor, audio.AudioListener, backend.BackendListener,
           mixer.MixerListener):

    library = None
    """An instance of :class:`~mopidy.core.LibraryController`"""

    history = None
    """An instance of :class:`~mopidy.core.HistoryController`"""

    mixer = None
    """An instance of :class:`~mopidy.core.MixerController`"""

    playback = None
    """An instance of :class:`~mopidy.core.PlaybackController`"""

    playlists = None
    """An instance of :class:`~mopidy.core.PlaylistsController`"""

    tracklist = None
    """An instance of :class:`~mopidy.core.TracklistController`"""
    def __init__(self, config=None, mixer=None, backends=None, audio=None):
        super(Core, self).__init__()

        self._config = config

        self.backends = Backends(backends)

        self.library = LibraryController(backends=self.backends, core=self)
        self.history = HistoryController()
        self.mixer = MixerController(mixer=mixer)
        self.playback = PlaybackController(audio=audio,
                                           backends=self.backends,
                                           core=self)
        self.playlists = PlaylistsController(backends=self.backends, core=self)
        self.tracklist = TracklistController(core=self)

        self.audio = audio

    def get_uri_schemes(self):
        """Get list of URI schemes we can handle"""
        futures = [b.uri_schemes for b in self.backends]
        results = pykka.get_all(futures)
        uri_schemes = itertools.chain(*results)
        return sorted(uri_schemes)

    uri_schemes = deprecated_property(get_uri_schemes)
    """
    .. deprecated:: 1.0
        Use :meth:`get_uri_schemes` instead.
    """

    def get_version(self):
        """Get version of the Mopidy core API"""
        return versioning.get_version()

    version = deprecated_property(get_version)
    """
    .. deprecated:: 1.0
        Use :meth:`get_version` instead.
    """

    def reached_end_of_stream(self):
        self.playback._on_end_of_stream()

    def stream_changed(self, uri):
        self.playback._on_stream_changed(uri)

    def position_changed(self, position):
        self.playback._on_position_changed(position)

    def state_changed(self, old_state, new_state, target_state):
        # XXX: This is a temporary fix for issue #232 while we wait for a more
        # permanent solution with the implementation of issue #234. When the
        # Spotify play token is lost, the Spotify backend pauses audio
        # playback, but mopidy.core doesn't know this, so we need to update
        # mopidy.core's state to match the actual state in mopidy.audio. If we
        # don't do this, clients will think that we're still playing.

        # We ignore cases when target state is set as this is buffering
        # updates (at least for now) and we need to get #234 fixed...
        if (new_state == PlaybackState.PAUSED and not target_state
                and self.playback.state != PlaybackState.PAUSED):
            self.playback.state = new_state
            self.playback._trigger_track_playback_paused()

    def playlists_loaded(self):
        # Forward event from backend to frontends
        CoreListener.send('playlists_loaded')

    def volume_changed(self, volume):
        # Forward event from mixer to frontends
        CoreListener.send('volume_changed', volume=volume)

    def mute_changed(self, mute):
        # Forward event from mixer to frontends
        CoreListener.send('mute_changed', mute=mute)

    def tags_changed(self, tags):
        if not self.audio or 'title' not in tags:
            return

        tags = self.audio.get_current_tags().get()
        if not tags:
            return

        # TODO: this limits us to only streams that set organization, this is
        # a hack to make sure we don't emit stream title changes for plain
        # tracks. We need a better way to decide if something is a stream.
        if 'title' in tags and tags['title'] and 'organization' in tags:
            title = tags['title'][0]
            self.playback._stream_title = title
            CoreListener.send('stream_title_changed', title=title)

    def setup(self):
        """Do not call this function. It is for internal use at startup."""
        try:
            coverage = []
            if self._config and 'restore_state' in self._config['core']:
                if self._config['core']['restore_state']:
                    coverage = [
                        'tracklist', 'mode', 'play-last', 'mixer', 'history'
                    ]
            if len(coverage):
                self._load_state(coverage)
        except Exception as e:
            logger.warn('Restore state: Unexpected error: %s', str(e))

    def teardown(self):
        """Do not call this function. It is for internal use at shutdown."""
        try:
            if self._config and 'restore_state' in self._config['core']:
                if self._config['core']['restore_state']:
                    self._save_state()
        except Exception as e:
            logger.warn('Unexpected error while saving state: %s', str(e))

    def _get_data_dir(self):
        # get or create data director for core
        data_dir_path = os.path.join(self._config['core']['data_dir'], b'core')
        path.get_or_create_dir(data_dir_path)
        return data_dir_path

    def _save_state(self):
        """
        Save current state to disk.
        """

        file_name = os.path.join(self._get_data_dir(), b'state.json.gz')
        logger.info('Saving state to %s', file_name)

        data = {}
        data['version'] = mopidy.__version__
        data['state'] = CoreState(tracklist=self.tracklist._save_state(),
                                  history=self.history._save_state(),
                                  playback=self.playback._save_state(),
                                  mixer=self.mixer._save_state())
        storage.dump(file_name, data)
        logger.debug('Saving state done')

    def _load_state(self, coverage):
        """
        Restore state from disk.

        Load state from disk and restore it. Parameter ``coverage``
        limits the amount of data to restore. Possible
        values for ``coverage`` (list of one or more of):

            - 'tracklist' fill the tracklist
            - 'mode' set tracklist properties (consume, random, repeat, single)
            - 'play-last' restore play state ('tracklist' also required)
            - 'mixer' set mixer volume and mute state
            - 'history' restore history

        :param coverage: amount of data to restore
        :type coverage: list of strings
        """

        file_name = os.path.join(self._get_data_dir(), b'state.json.gz')
        logger.info('Loading state from %s', file_name)

        data = storage.load(file_name)

        try:
            # Try only once. If something goes wrong, the next start is clean.
            os.remove(file_name)
        except OSError:
            logger.info('Failed to delete %s', file_name)

        if 'state' in data:
            core_state = data['state']
            validation.check_instance(core_state, CoreState)
            self.history._load_state(core_state.history, coverage)
            self.tracklist._load_state(core_state.tracklist, coverage)
            self.mixer._load_state(core_state.mixer, coverage)
            # playback after tracklist
            self.playback._load_state(core_state.playback, coverage)
        logger.debug('Loading state done')
Example #2
0
class TracklistController(object):
    pykka_traversable = True

    def __init__(self, core):
        self.core = core
        self._next_tlid = 0
        self._tl_tracks = []
        self._version = 0

        self._shuffled = []

    # Properties

    def get_tl_tracks(self):
        """Get tracklist as list of :class:`mopidy.models.TlTrack`."""
        return self._tl_tracks[:]

    tl_tracks = deprecation.deprecated_property(get_tl_tracks)
    """
    .. deprecated:: 1.0
        Use :meth:`get_tl_tracks` instead.
    """

    def get_tracks(self):
        """Get tracklist as list of :class:`mopidy.models.Track`."""
        return [tl_track.track for tl_track in self._tl_tracks]

    tracks = deprecation.deprecated_property(get_tracks)
    """
    .. deprecated:: 1.0
        Use :meth:`get_tracks` instead.
    """

    def get_length(self):
        """Get length of the tracklist."""
        return len(self._tl_tracks)

    length = deprecation.deprecated_property(get_length)
    """
    .. deprecated:: 1.0
        Use :meth:`get_length` instead.
    """

    def get_version(self):
        """
        Get the tracklist version.

        Integer which is increased every time the tracklist is changed. Is not
        reset before Mopidy is restarted.
        """
        return self._version

    def _increase_version(self):
        self._version += 1
        self.core.playback._on_tracklist_change()
        self._trigger_tracklist_changed()

    version = deprecation.deprecated_property(get_version)
    """
    .. deprecated:: 1.0
        Use :meth:`get_version` instead.
    """

    def get_consume(self):
        """Get consume mode.

        :class:`True`
            Tracks are removed from the tracklist when they have been played.
        :class:`False`
            Tracks are not removed from the tracklist.
        """
        return getattr(self, '_consume', False)

    def set_consume(self, value):
        """Set consume mode.

        :class:`True`
            Tracks are removed from the tracklist when they have been played.
        :class:`False`
            Tracks are not removed from the tracklist.
        """
        validation.check_boolean(value)
        if self.get_consume() != value:
            self._trigger_options_changed()
        return setattr(self, '_consume', value)

    consume = deprecation.deprecated_property(get_consume, set_consume)
    """
    .. deprecated:: 1.0
        Use :meth:`get_consume` and :meth:`set_consume` instead.
    """

    def get_random(self):
        """Get random mode.

        :class:`True`
            Tracks are selected at random from the tracklist.
        :class:`False`
            Tracks are played in the order of the tracklist.
        """
        return getattr(self, '_random', False)

    def set_random(self, value):
        """Set random mode.

        :class:`True`
            Tracks are selected at random from the tracklist.
        :class:`False`
            Tracks are played in the order of the tracklist.
        """
        validation.check_boolean(value)
        if self.get_random() != value:
            self._trigger_options_changed()
        if value:
            self._shuffled = self.get_tl_tracks()
            random.shuffle(self._shuffled)
        return setattr(self, '_random', value)

    random = deprecation.deprecated_property(get_random, set_random)
    """
    .. deprecated:: 1.0
        Use :meth:`get_random` and :meth:`set_random` instead.
    """

    def get_repeat(self):
        """
        Get repeat mode.

        :class:`True`
            The tracklist is played repeatedly.
        :class:`False`
            The tracklist is played once.
        """
        return getattr(self, '_repeat', False)

    def set_repeat(self, value):
        """
        Set repeat mode.

        To repeat a single track, set both ``repeat`` and ``single``.

        :class:`True`
            The tracklist is played repeatedly.
        :class:`False`
            The tracklist is played once.
        """
        validation.check_boolean(value)
        if self.get_repeat() != value:
            self._trigger_options_changed()
        return setattr(self, '_repeat', value)

    repeat = deprecation.deprecated_property(get_repeat, set_repeat)
    """
    .. deprecated:: 1.0
        Use :meth:`get_repeat` and :meth:`set_repeat` instead.
    """

    def get_single(self):
        """
        Get single mode.

        :class:`True`
            Playback is stopped after current song, unless in ``repeat`` mode.
        :class:`False`
            Playback continues after current song.
        """
        return getattr(self, '_single', False)

    def set_single(self, value):
        """
        Set single mode.

        :class:`True`
            Playback is stopped after current song, unless in ``repeat`` mode.
        :class:`False`
            Playback continues after current song.
        """
        validation.check_boolean(value)
        if self.get_single() != value:
            self._trigger_options_changed()
        return setattr(self, '_single', value)

    single = deprecation.deprecated_property(get_single, set_single)
    """
    .. deprecated:: 1.0
        Use :meth:`get_single` and :meth:`set_single` instead.
    """

    # Methods

    def index(self, tl_track=None, tlid=None):
        """
        The position of the given track in the tracklist.

        If neither *tl_track* or *tlid* is given we return the index of
        the currently playing track.

        :param tl_track: the track to find the index of
        :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
        :param tlid: TLID of the track to find the index of
        :type tlid: :class:`int` or :class:`None`
        :rtype: :class:`int` or :class:`None`

        .. versionadded:: 1.1
            The *tlid* parameter
        """
        tl_track is None or validation.check_instance(tl_track, TlTrack)
        tlid is None or validation.check_integer(tlid, min=0)

        if tl_track is None and tlid is None:
            tl_track = self.core.playback.get_current_tl_track()

        if tl_track is not None:
            try:
                return self._tl_tracks.index(tl_track)
            except ValueError:
                pass
        elif tlid is not None:
            for i, tl_track in enumerate(self._tl_tracks):
                if tl_track.tlid == tlid:
                    return i
        return None

    def get_eot_tlid(self):
        """
        The TLID of the track that will be played after the current track.

        Not necessarily the same TLID as returned by :meth:`get_next_tlid`.

        :rtype: :class:`int` or :class:`None`

        .. versionadded:: 1.1
        """

        current_tl_track = self.core.playback.get_current_tl_track()
        return getattr(self.eot_track(current_tl_track), 'tlid', None)

    def eot_track(self, tl_track):
        """
        The track that will be played after the given track.

        Not necessarily the same track as :meth:`next_track`.

        :param tl_track: the reference track
        :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
        :rtype: :class:`mopidy.models.TlTrack` or :class:`None`
        """
        deprecation.warn('core.tracklist.eot_track', pending=True)
        tl_track is None or validation.check_instance(tl_track, TlTrack)
        if self.get_single() and self.get_repeat():
            return tl_track
        elif self.get_single():
            return None

        # Current difference between next and EOT handling is that EOT needs to
        # handle "single", with that out of the way the rest of the logic is
        # shared.
        return self.next_track(tl_track)

    def get_next_tlid(self):
        """
        The tlid of the track that will be played if calling
        :meth:`mopidy.core.PlaybackController.next()`.

        For normal playback this is the next track in the tracklist. If repeat
        is enabled the next track can loop around the tracklist. When random is
        enabled this should be a random track, all tracks should be played once
        before the tracklist repeats.

        :rtype: :class:`int` or :class:`None`

        .. versionadded:: 1.1
        """
        current_tl_track = self.core.playback.get_current_tl_track()
        return getattr(self.next_track(current_tl_track), 'tlid', None)

    def next_track(self, tl_track):
        """
        The track that will be played if calling
        :meth:`mopidy.core.PlaybackController.next()`.

        For normal playback this is the next track in the tracklist. If repeat
        is enabled the next track can loop around the tracklist. When random is
        enabled this should be a random track, all tracks should be played once
        before the tracklist repeats.

        :param tl_track: the reference track
        :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
        :rtype: :class:`mopidy.models.TlTrack` or :class:`None`
        """
        deprecation.warn('core.tracklist.next_track', pending=True)
        tl_track is None or validation.check_instance(tl_track, TlTrack)

        if not self._tl_tracks:
            return None

        if self.get_random() and not self._shuffled:
            if self.get_repeat() or not tl_track:
                logger.debug('Shuffling tracks')
                self._shuffled = self._tl_tracks[:]
                random.shuffle(self._shuffled)

        if self.get_random():
            if self._shuffled:
                return self._shuffled[0]
            return None

        if tl_track is None:
            next_index = 0
        else:
            next_index = self.index(tl_track) + 1

        if self.get_repeat():
            next_index %= len(self._tl_tracks)
        elif next_index >= len(self._tl_tracks):
            return None

        return self._tl_tracks[next_index]

    def get_previous_tlid(self):
        """
        Returns the TLID of the track that will be played if calling
        :meth:`mopidy.core.PlaybackController.previous()`.

        For normal playback this is the previous track in the tracklist. If
        random and/or consume is enabled it should return the current track
        instead.

        :rtype: :class:`int` or :class:`None`

        .. versionadded:: 1.1
        """
        current_tl_track = self.core.playback.get_current_tl_track()
        return getattr(self.previous_track(current_tl_track), 'tlid', None)

    def previous_track(self, tl_track):
        """
        Returns the track that will be played if calling
        :meth:`mopidy.core.PlaybackController.previous()`.

        For normal playback this is the previous track in the tracklist. If
        random and/or consume is enabled it should return the current track
        instead.

        :param tl_track: the reference track
        :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
        :rtype: :class:`mopidy.models.TlTrack` or :class:`None`
        """
        deprecation.warn('core.tracklist.previous_track', pending=True)
        tl_track is None or validation.check_instance(tl_track, TlTrack)

        if self.get_repeat() or self.get_consume() or self.get_random():
            return tl_track

        position = self.index(tl_track)

        if position in (None, 0):
            return None

        # Since we know we are not at zero we have to be somewhere in the range
        # 1 - len(tracks) Thus 'position - 1' will always be within the list.
        return self._tl_tracks[position - 1]

    def add(self, tracks=None, at_position=None, uri=None, uris=None):
        """
        Add tracks to the tracklist.

        If ``uri`` is given instead of ``tracks``, the URI is looked up in the
        library and the resulting tracks are added to the tracklist.

        If ``uris`` is given instead of ``uri`` or ``tracks``, the URIs are
        looked up in the library and the resulting tracks are added to the
        tracklist.

        If ``at_position`` is given, the tracks are inserted at the given
        position in the tracklist. If ``at_position`` is not given, the tracks
        are appended to the end of the tracklist.

        Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.

        :param tracks: tracks to add
        :type tracks: list of :class:`mopidy.models.Track` or :class:`None`
        :param at_position: position in tracklist to add tracks
        :type at_position: int or :class:`None`
        :param uri: URI for tracks to add
        :type uri: string or :class:`None`
        :param uris: list of URIs for tracks to add
        :type uris: list of string or :class:`None`
        :rtype: list of :class:`mopidy.models.TlTrack`

        .. versionadded:: 1.0
            The ``uris`` argument.

        .. deprecated:: 1.0
            The ``tracks`` and ``uri`` arguments. Use ``uris``.
        """
        if sum(o is not None for o in [tracks, uri, uris]) != 1:
            raise ValueError(
                'Exactly one of "tracks", "uri" or "uris" must be set')

        tracks is None or validation.check_instances(tracks, Track)
        uri is None or validation.check_uri(uri)
        uris is None or validation.check_uris(uris)
        validation.check_integer(at_position or 0)

        if tracks:
            deprecation.warn('core.tracklist.add:tracks_arg')

        if uri:
            deprecation.warn('core.tracklist.add:uri_arg')

        if tracks is None:
            if uri is not None:
                uris = [uri]

            tracks = []
            track_map = self.core.library.lookup(uris=uris)
            for uri in uris:
                tracks.extend(track_map[uri])

        tl_tracks = []
        max_length = self.core._config['core']['max_tracklist_length']

        for track in tracks:
            if self.get_length() >= max_length:
                raise exceptions.TracklistFull(
                    'Tracklist may contain at most %d tracks.' % max_length)

            tl_track = TlTrack(self._next_tlid, track)
            self._next_tlid += 1
            if at_position is not None:
                self._tl_tracks.insert(at_position, tl_track)
                at_position += 1
            else:
                self._tl_tracks.append(tl_track)
            tl_tracks.append(tl_track)

        if tl_tracks:
            self._increase_version()

        return tl_tracks

    def clear(self):
        """
        Clear the tracklist.

        Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
        """
        self._tl_tracks = []
        self._increase_version()

    def filter(self, criteria=None, **kwargs):
        """
        Filter the tracklist by the given criterias.

        A criteria consists of a model field to check and a list of values to
        compare it against. If the model field matches one of the values, it
        may be returned.

        Only tracks that matches all the given criterias are returned.

        Examples::

            # Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID)
            filter({'tlid': [1, 2, 3, 4]})

            # Returns track with URIs 'xyz' or 'abc'
            filter({'uri': ['xyz', 'abc']})

            # Returns track with a matching TLIDs (1, 3 or 6) and a
            # matching URI ('xyz' or 'abc')
            filter({'tlid': [1, 3, 6], 'uri': ['xyz', 'abc']})

        :param criteria: on or more criteria to match by
        :type criteria: dict, of (string, list) pairs
        :rtype: list of :class:`mopidy.models.TlTrack`

        .. deprecated:: 1.1
            Providing the criteria via ``kwargs``.
        """
        if kwargs:
            deprecation.warn('core.tracklist.filter:kwargs_criteria')

        criteria = criteria or kwargs
        tlids = criteria.pop('tlid', [])
        validation.check_query(criteria, validation.TRACKLIST_FIELDS)
        validation.check_instances(tlids, int)

        matches = self._tl_tracks
        for (key, values) in criteria.items():
            matches = [
                ct for ct in matches if getattr(ct.track, key) in values]
        if tlids:
            matches = [ct for ct in matches if ct.tlid in tlids]
        return matches

    def move(self, start, end, to_position):
        """
        Move the tracks in the slice ``[start:end]`` to ``to_position``.

        Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.

        :param start: position of first track to move
        :type start: int
        :param end: position after last track to move
        :type end: int
        :param to_position: new position for the tracks
        :type to_position: int
        """
        if start == end:
            end += 1

        tl_tracks = self._tl_tracks

        # TODO: use validation helpers?
        assert start < end, 'start must be smaller than end'
        assert start >= 0, 'start must be at least zero'
        assert end <= len(tl_tracks), \
            'end can not be larger than tracklist length'
        assert to_position >= 0, 'to_position must be at least zero'
        assert to_position <= len(tl_tracks), \
            'to_position can not be larger than tracklist length'

        new_tl_tracks = tl_tracks[:start] + tl_tracks[end:]
        for tl_track in tl_tracks[start:end]:
            new_tl_tracks.insert(to_position, tl_track)
            to_position += 1
        self._tl_tracks = new_tl_tracks
        self._increase_version()

    def remove(self, criteria=None, **kwargs):
        """
        Remove the matching tracks from the tracklist.

        Uses :meth:`filter()` to lookup the tracks to remove.

        Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.

        :param criteria: on or more criteria to match by
        :type criteria: dict
        :rtype: list of :class:`mopidy.models.TlTrack` that was removed

        .. deprecated:: 1.1
            Providing the criteria  via ``kwargs``.
        """
        if kwargs:
            deprecation.warn('core.tracklist.remove:kwargs_criteria')

        tl_tracks = self.filter(criteria or kwargs)
        for tl_track in tl_tracks:
            position = self._tl_tracks.index(tl_track)
            del self._tl_tracks[position]
        self._increase_version()
        return tl_tracks

    def shuffle(self, start=None, end=None):
        """
        Shuffles the entire tracklist. If ``start`` and ``end`` is given only
        shuffles the slice ``[start:end]``.

        Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.

        :param start: position of first track to shuffle
        :type start: int or :class:`None`
        :param end: position after last track to shuffle
        :type end: int or :class:`None`
        """
        tl_tracks = self._tl_tracks

        # TOOD: use validation helpers?
        if start is not None and end is not None:
            assert start < end, 'start must be smaller than end'

        if start is not None:
            assert start >= 0, 'start must be at least zero'

        if end is not None:
            assert end <= len(tl_tracks), 'end can not be larger than ' + \
                'tracklist length'

        before = tl_tracks[:start or 0]
        shuffled = tl_tracks[start:end]
        after = tl_tracks[end or len(tl_tracks):]
        random.shuffle(shuffled)
        self._tl_tracks = before + shuffled + after
        self._increase_version()

    def slice(self, start, end):
        """
        Returns a slice of the tracklist, limited by the given start and end
        positions.

        :param start: position of first track to include in slice
        :type start: int
        :param end: position after last track to include in slice
        :type end: int
        :rtype: :class:`mopidy.models.TlTrack`
        """
        # TODO: validate slice?
        return self._tl_tracks[start:end]

    def _mark_playing(self, tl_track):
        """Internal method for :class:`mopidy.core.PlaybackController`."""
        if self.get_random() and tl_track in self._shuffled:
            self._shuffled.remove(tl_track)

    def _mark_unplayable(self, tl_track):
        """Internal method for :class:`mopidy.core.PlaybackController`."""
        logger.warning('Track is not playable: %s', tl_track.track.uri)
        if self.get_random() and tl_track in self._shuffled:
            self._shuffled.remove(tl_track)

    def _mark_played(self, tl_track):
        """Internal method for :class:`mopidy.core.PlaybackController`."""
        if self.consume and tl_track is not None:
            self.remove({'tlid': [tl_track.tlid]})
            return True
        return False

    def _trigger_tracklist_changed(self):
        if self.get_random():
            self._shuffled = self._tl_tracks[:]
            random.shuffle(self._shuffled)
        else:
            self._shuffled = []

        logger.debug('Triggering event: tracklist_changed()')
        listener.CoreListener.send('tracklist_changed')

    def _trigger_options_changed(self):
        logger.debug('Triggering options changed event')
        listener.CoreListener.send('options_changed')
Example #3
0
class PlaybackController(object):
    pykka_traversable = True

    def __init__(self, audio, backends, core):
        # TODO: these should be internal
        self.backends = backends
        self.core = core
        self._audio = audio

        self._stream_title = None
        self._state = PlaybackState.STOPPED

        self._current_tl_track = None
        self._pending_tl_track = None

        self._pending_position = None
        self._last_position = None
        self._previous = False

        if self._audio:
            self._audio.set_about_to_finish_callback(
                self._on_about_to_finish_callback)

    def _get_backend(self, tl_track):
        if tl_track is None:
            return None
        uri_scheme = urllib.parse.urlparse(tl_track.track.uri).scheme
        return self.backends.with_playback.get(uri_scheme, None)

    # Properties

    def get_current_tl_track(self):
        """Get the currently playing or selected track.

        Returns a :class:`mopidy.models.TlTrack` or :class:`None`.
        """
        return self._current_tl_track

    def _set_current_tl_track(self, value):
        """Set the currently playing or selected track.

        *Internal:* This is only for use by Mopidy's test suite.
        """
        self._current_tl_track = value

    current_tl_track = deprecation.deprecated_property(get_current_tl_track)
    """
    .. deprecated:: 1.0
        Use :meth:`get_current_tl_track` instead.
    """

    def get_current_track(self):
        """
        Get the currently playing or selected track.

        Extracted from :meth:`get_current_tl_track` for convenience.

        Returns a :class:`mopidy.models.Track` or :class:`None`.
        """
        return getattr(self.get_current_tl_track(), 'track', None)

    current_track = deprecation.deprecated_property(get_current_track)
    """
    .. deprecated:: 1.0
        Use :meth:`get_current_track` instead.
    """

    def get_current_tlid(self):
        """
        Get the currently playing or selected TLID.

        Extracted from :meth:`get_current_tl_track` for convenience.

        Returns a :class:`int` or :class:`None`.

        .. versionadded:: 1.1
        """
        return getattr(self.get_current_tl_track(), 'tlid', None)

    def get_stream_title(self):
        """Get the current stream title or :class:`None`."""
        return self._stream_title

    def get_state(self):
        """Get The playback state."""

        return self._state

    def set_state(self, new_state):
        """Set the playback state.

        Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`.

        Possible states and transitions:

        .. digraph:: state_transitions

            "STOPPED" -> "PLAYING" [ label="play" ]
            "STOPPED" -> "PAUSED" [ label="pause" ]
            "PLAYING" -> "STOPPED" [ label="stop" ]
            "PLAYING" -> "PAUSED" [ label="pause" ]
            "PLAYING" -> "PLAYING" [ label="play" ]
            "PAUSED" -> "PLAYING" [ label="resume" ]
            "PAUSED" -> "STOPPED" [ label="stop" ]
        """
        validation.check_choice(new_state, validation.PLAYBACK_STATES)

        (old_state, self._state) = (self.get_state(), new_state)
        logger.debug('Changing state: %s -> %s', old_state, new_state)

        self._trigger_playback_state_changed(old_state, new_state)

    state = deprecation.deprecated_property(get_state, set_state)
    """
    .. deprecated:: 1.0
        Use :meth:`get_state` and :meth:`set_state` instead.
    """

    def get_time_position(self):
        """Get time position in milliseconds."""
        if self._pending_position is not None:
            return self._pending_position
        backend = self._get_backend(self.get_current_tl_track())
        if backend:
            # TODO: Wrap backend call in error handling.
            return backend.playback.get_time_position().get()
        else:
            return 0

    time_position = deprecation.deprecated_property(get_time_position)
    """
    .. deprecated:: 1.0
        Use :meth:`get_time_position` instead.
    """

    def get_volume(self):
        """
        .. deprecated:: 1.0
            Use :meth:`core.mixer.get_volume()
            <mopidy.core.MixerController.get_volume>` instead.
        """
        deprecation.warn('core.playback.get_volume')
        return self.core.mixer.get_volume()

    def set_volume(self, volume):
        """
        .. deprecated:: 1.0
            Use :meth:`core.mixer.set_volume()
            <mopidy.core.MixerController.set_volume>` instead.
        """
        deprecation.warn('core.playback.set_volume')
        return self.core.mixer.set_volume(volume)

    volume = deprecation.deprecated_property(get_volume, set_volume)
    """
    .. deprecated:: 1.0
        Use :meth:`core.mixer.get_volume()
        <mopidy.core.MixerController.get_volume>` and
        :meth:`core.mixer.set_volume()
        <mopidy.core.MixerController.set_volume>` instead.
    """

    def get_mute(self):
        """
        .. deprecated:: 1.0
            Use :meth:`core.mixer.get_mute()
            <mopidy.core.MixerController.get_mute>` instead.
        """
        deprecation.warn('core.playback.get_mute')
        return self.core.mixer.get_mute()

    def set_mute(self, mute):
        """
        .. deprecated:: 1.0
            Use :meth:`core.mixer.set_mute()
            <mopidy.core.MixerController.set_mute>` instead.
        """
        deprecation.warn('core.playback.set_mute')
        return self.core.mixer.set_mute(mute)

    mute = deprecation.deprecated_property(get_mute, set_mute)
    """
    .. deprecated:: 1.0
        Use :meth:`core.mixer.get_mute()
        <mopidy.core.MixerController.get_mute>` and
        :meth:`core.mixer.set_mute()
        <mopidy.core.MixerController.set_mute>` instead.
    """

    # Methods

    def _on_end_of_stream(self):
        self.set_state(PlaybackState.STOPPED)
        if self._current_tl_track:
            self._trigger_track_playback_ended(self.get_time_position())
        self._set_current_tl_track(None)

    def _on_stream_changed(self, uri):
        if self._last_position is None:
            position = self.get_time_position()
        else:
            # This code path handles the stop() case, uri should be none.
            position, self._last_position = self._last_position, None

        if self._pending_position is None:
            self._trigger_track_playback_ended(position)

        self._stream_title = None
        if self._pending_tl_track:
            self._set_current_tl_track(self._pending_tl_track)
            self._pending_tl_track = None

            if self._pending_position is None:
                self.set_state(PlaybackState.PLAYING)
                self._trigger_track_playback_started()
            else:
                self._seek(self._pending_position)

    def _on_position_changed(self, position):
        if self._pending_position is not None:
            self._trigger_seeked(self._pending_position)
            self._pending_position = None

    def _on_about_to_finish_callback(self):
        """Callback that performs a blocking actor call to the real callback.

        This is passed to audio, which is allowed to call this code from the
        audio thread. We pass execution into the core actor to ensure that
        there is no unsafe access of state in core. This must block until
        we get a response.
        """
        self.core.actor_ref.ask({
            'command':
            'pykka_call',
            'args':
            tuple(),
            'kwargs': {},
            'attr_path': ('playback', '_on_about_to_finish'),
        })

    def _on_about_to_finish(self):
        if self._state == PlaybackState.STOPPED:
            return

        # Unless overridden by other calls (e.g. next / previous / stop) this
        # will be the last position recorded until the track gets reassigned.
        # TODO: Check if case when track.length isn't populated needs to be
        # handled.
        self._last_position = self._current_tl_track.track.length

        pending = self.core.tracklist.eot_track(self._current_tl_track)
        # avoid endless loop if 'repeat' is 'true' and no track is playable
        # * 2 -> second run to get all playable track in a shuffled playlist
        count = self.core.tracklist.get_length() * 2

        while pending:
            backend = self._get_backend(pending)
            if backend:
                try:
                    if backend.playback.change_track(pending.track).get():
                        self._pending_tl_track = pending
                        break
                except Exception:
                    logger.exception('%s backend caused an exception.',
                                     backend.actor_ref.actor_class.__name__)

            self.core.tracklist._mark_unplayable(pending)
            pending = self.core.tracklist.eot_track(pending)
            count -= 1
            if not count:
                logger.info('No playable track in the list.')
                break

    def _on_tracklist_change(self):
        """
        Tell the playback controller that the current playlist has changed.

        Used by :class:`mopidy.core.TracklistController`.
        """
        if not self.core.tracklist.tl_tracks:
            self.stop()
            self._set_current_tl_track(None)
        elif self.get_current_tl_track() not in self.core.tracklist.tl_tracks:
            self._set_current_tl_track(None)

    def next(self):
        """
        Change to the next track.

        The current playback state will be kept. If it was playing, playing
        will continue. If it was paused, it will still be paused, etc.
        """
        state = self.get_state()
        current = self._pending_tl_track or self._current_tl_track
        # avoid endless loop if 'repeat' is 'true' and no track is playable
        # * 2 -> second run to get all playable track in a shuffled playlist
        count = self.core.tracklist.get_length() * 2

        while current:
            pending = self.core.tracklist.next_track(current)
            if self._change(pending, state):
                break
            else:
                self.core.tracklist._mark_unplayable(pending)
            # TODO: this could be needed to prevent a loop in rare cases
            # if current == pending:
            #     break
            current = pending
            count -= 1
            if not count:
                logger.info('No playable track in the list.')
                break

        # TODO return result?

    def pause(self):
        """Pause playback."""
        backend = self._get_backend(self.get_current_tl_track())
        # TODO: Wrap backend call in error handling.
        if not backend or backend.playback.pause().get():
            # TODO: switch to:
            # backend.track(pause)
            # wait for state change?
            self.set_state(PlaybackState.PAUSED)
            self._trigger_track_playback_paused()

    def play(self, tl_track=None, tlid=None):
        """
        Play the given track, or if the given tl_track and tlid is
        :class:`None`, play the currently active track.

        Note that the track **must** already be in the tracklist.

        :param tl_track: track to play
        :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
        :param tlid: TLID of the track to play
        :type tlid: :class:`int` or :class:`None`
        """
        if sum(o is not None for o in [tl_track, tlid]) > 1:
            raise ValueError('At most one of "tl_track" and "tlid" may be set')

        tl_track is None or validation.check_instance(tl_track, models.TlTrack)
        tlid is None or validation.check_integer(tlid, min=1)

        if tl_track:
            deprecation.warn('core.playback.play:tl_track_kwarg', pending=True)

        if tl_track is None and tlid is not None:
            for tl_track in self.core.tracklist.get_tl_tracks():
                if tl_track.tlid == tlid:
                    break
            else:
                tl_track = None

        if tl_track is not None:
            # TODO: allow from outside tracklist, would make sense given refs?
            assert tl_track in self.core.tracklist.get_tl_tracks()
        elif tl_track is None and self.get_state() == PlaybackState.PAUSED:
            self.resume()
            return

        current = self._pending_tl_track or self._current_tl_track
        pending = tl_track or current or self.core.tracklist.next_track(None)
        # avoid endless loop if 'repeat' is 'true' and no track is playable
        # * 2 -> second run to get all playable track in a shuffled playlist
        count = self.core.tracklist.get_length() * 2

        while pending:
            if self._change(pending, PlaybackState.PLAYING):
                break
            else:
                self.core.tracklist._mark_unplayable(pending)
            current = pending
            pending = self.core.tracklist.next_track(current)
            count -= 1
            if not count:
                logger.info('No playable track in the list.')
                break

        # TODO return result?

    def _change(self, pending_tl_track, state):
        self._pending_tl_track = pending_tl_track

        if not pending_tl_track:
            self.stop()
            self._on_end_of_stream()  # pretend an EOS happened for cleanup
            return True

        backend = self._get_backend(pending_tl_track)
        if not backend:
            return False

        # This must happen before prepare_change gets called, otherwise the
        # backend flushes the information of the track.
        self._last_position = self.get_time_position()

        # TODO: Wrap backend call in error handling.
        backend.playback.prepare_change()

        try:
            if not backend.playback.change_track(pending_tl_track.track).get():
                return False
        except Exception:
            logger.exception('%s backend caused an exception.',
                             backend.actor_ref.actor_class.__name__)
            return False

        # TODO: Wrap backend calls in error handling.
        if state == PlaybackState.PLAYING:
            try:
                return backend.playback.play().get()
            except TypeError:
                # TODO: check by binding against underlying play method using
                # inspect and otherwise re-raise?
                logger.error(
                    '%s needs to be updated to work with this '
                    'version of Mopidy.', backend)
                return False
        elif state == PlaybackState.PAUSED:
            return backend.playback.pause().get()
        elif state == PlaybackState.STOPPED:
            # TODO: emit some event now?
            self._current_tl_track = self._pending_tl_track
            self._pending_tl_track = None
            return True

        raise Exception('Unknown state: %s' % state)

    def previous(self):
        """
        Change to the previous track.

        The current playback state will be kept. If it was playing, playing
        will continue. If it was paused, it will still be paused, etc.
        """
        self._previous = True
        state = self.get_state()
        current = self._pending_tl_track or self._current_tl_track
        # avoid endless loop if 'repeat' is 'true' and no track is playable
        # * 2 -> second run to get all playable track in a shuffled playlist
        count = self.core.tracklist.get_length() * 2

        while current:
            pending = self.core.tracklist.previous_track(current)
            if self._change(pending, state):
                break
            else:
                self.core.tracklist._mark_unplayable(pending)
            # TODO: this could be needed to prevent a loop in rare cases
            # if current == pending:
            #     break
            current = pending
            count -= 1
            if not count:
                logger.info('No playable track in the list.')
                break

        # TODO: no return value?

    def resume(self):
        """If paused, resume playing the current track."""
        if self.get_state() != PlaybackState.PAUSED:
            return
        backend = self._get_backend(self.get_current_tl_track())
        # TODO: Wrap backend call in error handling.
        if backend and backend.playback.resume().get():
            self.set_state(PlaybackState.PLAYING)
            # TODO: trigger via gst messages
            self._trigger_track_playback_resumed()
        # TODO: switch to:
        # backend.resume()
        # wait for state change?

    def seek(self, time_position):
        """
        Seeks to time position given in milliseconds.

        :param time_position: time position in milliseconds
        :type time_position: int
        :rtype: :class:`True` if successful, else :class:`False`
        """
        # TODO: seek needs to take pending tracks into account :(
        validation.check_integer(time_position)

        if time_position < 0:
            logger.debug(
                'Client seeked to negative position. Seeking to zero.')
            time_position = 0

        if not self.core.tracklist.tracks:
            return False

        if self.get_state() == PlaybackState.STOPPED:
            self.play()

        # We need to prefer the still playing track, but if nothing is playing
        # we fall back to the pending one.
        tl_track = self._current_tl_track or self._pending_tl_track
        if tl_track and tl_track.track.length is None:
            return False

        if time_position < 0:
            time_position = 0
        elif time_position > tl_track.track.length:
            # TODO: GStreamer will trigger a about-to-finish for us, use that?
            self.next()
            return True

        # Store our target position.
        self._pending_position = time_position

        # Make sure we switch back to previous track if we get a seek while we
        # have a pending track.
        if self._current_tl_track and self._pending_tl_track:
            self._change(self._current_tl_track, self.get_state())
        else:
            return self._seek(time_position)

    def _seek(self, time_position):
        backend = self._get_backend(self.get_current_tl_track())
        if not backend:
            return False
        # TODO: Wrap backend call in error handling.
        return backend.playback.seek(time_position).get()

    def stop(self):
        """Stop playing."""
        if self.get_state() != PlaybackState.STOPPED:
            self._last_position = self.get_time_position()
            backend = self._get_backend(self.get_current_tl_track())
            # TODO: Wrap backend call in error handling.
            if not backend or backend.playback.stop().get():
                self.set_state(PlaybackState.STOPPED)

    def _trigger_track_playback_paused(self):
        logger.debug('Triggering track playback paused event')
        if self.current_track is None:
            return
        listener.CoreListener.send('track_playback_paused',
                                   tl_track=self.get_current_tl_track(),
                                   time_position=self.get_time_position())

    def _trigger_track_playback_resumed(self):
        logger.debug('Triggering track playback resumed event')
        if self.current_track is None:
            return
        listener.CoreListener.send('track_playback_resumed',
                                   tl_track=self.get_current_tl_track(),
                                   time_position=self.get_time_position())

    def _trigger_track_playback_started(self):
        if self.get_current_tl_track() is None:
            return

        logger.debug('Triggering track playback started event')
        tl_track = self.get_current_tl_track()
        self.core.tracklist._mark_playing(tl_track)
        self.core.history._add_track(tl_track.track)
        listener.CoreListener.send('track_playback_started', tl_track=tl_track)

    def _trigger_track_playback_ended(self, time_position_before_stop):
        tl_track = self.get_current_tl_track()
        if tl_track is None:
            return

        logger.debug('Triggering track playback ended event')

        if not self._previous:
            self.core.tracklist._mark_played(self._current_tl_track)
        self._previous = False

        # TODO: Use the lowest of track duration and position.
        listener.CoreListener.send('track_playback_ended',
                                   tl_track=tl_track,
                                   time_position=time_position_before_stop)

    def _trigger_playback_state_changed(self, old_state, new_state):
        logger.debug('Triggering playback state change event')
        listener.CoreListener.send('playback_state_changed',
                                   old_state=old_state,
                                   new_state=new_state)

    def _trigger_seeked(self, time_position):
        # TODO: Trigger this from audio events?
        logger.debug('Triggering seeked event')
        listener.CoreListener.send('seeked', time_position=time_position)
Example #4
0
class PlaylistsController(object):
    pykka_traversable = True

    def __init__(self, backends, core):
        self.backends = backends
        self.core = core

    def as_list(self):
        """
        Get a list of the currently available playlists.

        Returns a list of :class:`~mopidy.models.Ref` objects referring to the
        playlists. In other words, no information about the playlists' content
        is given.

        :rtype: list of :class:`mopidy.models.Ref`

        .. versionadded:: 1.0
        """
        futures = {
            backend: backend.playlists.as_list()
            for backend in set(self.backends.with_playlists.values())
        }

        results = []
        for b, future in futures.items():
            try:
                with _backend_error_handling(b, reraise=NotImplementedError):
                    playlists = future.get()
                    if playlists is not None:
                        validation.check_instances(playlists, Ref)
                        results.extend(playlists)
            except NotImplementedError:
                backend_name = b.actor_ref.actor_class.__name__
                logger.warning(
                    '%s does not implement playlists.as_list(). '
                    'Please upgrade it.', backend_name)

        return results

    def get_items(self, uri):
        """
        Get the items in a playlist specified by ``uri``.

        Returns a list of :class:`~mopidy.models.Ref` objects referring to the
        playlist's items.

        If a playlist with the given ``uri`` doesn't exist, it returns
        :class:`None`.

        :rtype: list of :class:`mopidy.models.Ref`, or :class:`None`

        .. versionadded:: 1.0
        """
        validation.check_uri(uri)

        uri_scheme = urlparse.urlparse(uri).scheme
        backend = self.backends.with_playlists.get(uri_scheme, None)

        if not backend:
            return None

        with _backend_error_handling(backend):
            items = backend.playlists.get_items(uri).get()
            items is None or validation.check_instances(items, Ref)
            return items

        return None

    def get_playlists(self, include_tracks=True):
        """
        Get the available playlists.

        :rtype: list of :class:`mopidy.models.Playlist`

        .. versionchanged:: 1.0
            If you call the method with ``include_tracks=False``, the
            :attr:`~mopidy.models.Playlist.last_modified` field of the returned
            playlists is no longer set.

        .. deprecated:: 1.0
            Use :meth:`as_list` and :meth:`get_items` instead.
        """
        deprecation.warn('core.playlists.get_playlists')

        playlist_refs = self.as_list()

        if include_tracks:
            playlists = {r.uri: self.lookup(r.uri) for r in playlist_refs}
            # Use the playlist name from as_list() because it knows about any
            # playlist folder hierarchy, which lookup() does not.
            return [
                playlists[r.uri].replace(name=r.name) for r in playlist_refs
                if playlists[r.uri] is not None
            ]
        else:
            return [Playlist(uri=r.uri, name=r.name) for r in playlist_refs]

    playlists = deprecation.deprecated_property(get_playlists)
    """
    .. deprecated:: 1.0
        Use :meth:`as_list` and :meth:`get_items` instead.
    """

    def create(self, name, uri_scheme=None):
        """
        Create a new playlist.

        If ``uri_scheme`` matches an URI scheme handled by a current backend,
        that backend is asked to create the playlist. If ``uri_scheme`` is
        :class:`None` or doesn't match a current backend, the first backend is
        asked to create the playlist.

        All new playlists must be created by calling this method, and **not**
        by creating new instances of :class:`mopidy.models.Playlist`.

        :param name: name of the new playlist
        :type name: string
        :param uri_scheme: use the backend matching the URI scheme
        :type uri_scheme: string
        :rtype: :class:`mopidy.models.Playlist` or :class:`None`
        """
        if uri_scheme in self.backends.with_playlists:
            backends = [self.backends.with_playlists[uri_scheme]]
        else:
            backends = self.backends.with_playlists.values()

        for backend in backends:
            with _backend_error_handling(backend):
                result = backend.playlists.create(name).get()
                if result is None:
                    continue
                validation.check_instance(result, Playlist)
                listener.CoreListener.send('playlist_changed', playlist=result)
                return result

        return None

    def delete(self, uri):
        """
        Delete playlist identified by the URI.

        If the URI doesn't match the URI schemes handled by the current
        backends, nothing happens.

        :param uri: URI of the playlist to delete
        :type uri: string
        """
        validation.check_uri(uri)

        uri_scheme = urlparse.urlparse(uri).scheme
        backend = self.backends.with_playlists.get(uri_scheme, None)
        if not backend:
            return None  # TODO: error reporting to user

        with _backend_error_handling(backend):
            backend.playlists.delete(uri).get()
            # TODO: error detection and reporting to user
            listener.CoreListener.send('playlist_deleted', uri=uri)

        # TODO: return value?

    def filter(self, criteria=None, **kwargs):
        """
        Filter playlists by the given criterias.

        Examples::

            # Returns track with name 'a'
            filter({'name': 'a'})

            # Returns track with URI 'xyz'
            filter({'uri': 'xyz'})

            # Returns track with name 'a' and URI 'xyz'
            filter({'name': 'a', 'uri': 'xyz'})

        :param criteria: one or more criteria to match by
        :type criteria: dict
        :rtype: list of :class:`mopidy.models.Playlist`

        .. deprecated:: 1.0
            Use :meth:`as_list` and filter yourself.
        """
        deprecation.warn('core.playlists.filter')

        criteria = criteria or kwargs
        validation.check_query(criteria,
                               validation.PLAYLIST_FIELDS,
                               list_values=False)

        matches = self.playlists  # TODO: stop using self playlists
        for (key, value) in criteria.iteritems():
            matches = filter(lambda p: getattr(p, key) == value, matches)
        return matches

    def lookup(self, uri):
        """
        Lookup playlist with given URI in both the set of playlists and in any
        other playlist sources. Returns :class:`None` if not found.

        :param uri: playlist URI
        :type uri: string
        :rtype: :class:`mopidy.models.Playlist` or :class:`None`
        """
        uri_scheme = urlparse.urlparse(uri).scheme
        backend = self.backends.with_playlists.get(uri_scheme, None)
        if not backend:
            return None

        with _backend_error_handling(backend):
            playlist = backend.playlists.lookup(uri).get()
            playlist is None or validation.check_instance(playlist, Playlist)
            return playlist

        return None

    # TODO: there is an inconsistency between library.refresh(uri) and this
    # call, not sure how to sort this out.
    def refresh(self, uri_scheme=None):
        """
        Refresh the playlists in :attr:`playlists`.

        If ``uri_scheme`` is :class:`None`, all backends are asked to refresh.
        If ``uri_scheme`` is an URI scheme handled by a backend, only that
        backend is asked to refresh. If ``uri_scheme`` doesn't match any
        current backend, nothing happens.

        :param uri_scheme: limit to the backend matching the URI scheme
        :type uri_scheme: string
        """
        # TODO: check: uri_scheme is None or uri_scheme?

        futures = {}
        backends = {}
        playlists_loaded = False

        for backend_scheme, backend in self.backends.with_playlists.items():
            backends.setdefault(backend, set()).add(backend_scheme)

        for backend, backend_schemes in backends.items():
            if uri_scheme is None or uri_scheme in backend_schemes:
                futures[backend] = backend.playlists.refresh()

        for backend, future in futures.items():
            with _backend_error_handling(backend):
                future.get()
                playlists_loaded = True

        if playlists_loaded:
            listener.CoreListener.send('playlists_loaded')

    def save(self, playlist):
        """
        Save the playlist.

        For a playlist to be saveable, it must have the ``uri`` attribute set.
        You must not set the ``uri`` atribute yourself, but use playlist
        objects returned by :meth:`create` or retrieved from :attr:`playlists`,
        which will always give you saveable playlists.

        The method returns the saved playlist. The return playlist may differ
        from the saved playlist. E.g. if the playlist name was changed, the
        returned playlist may have a different URI. The caller of this method
        must throw away the playlist sent to this method, and use the
        returned playlist instead.

        If the playlist's URI isn't set or doesn't match the URI scheme of a
        current backend, nothing is done and :class:`None` is returned.

        :param playlist: the playlist
        :type playlist: :class:`mopidy.models.Playlist`
        :rtype: :class:`mopidy.models.Playlist` or :class:`None`
        """
        validation.check_instance(playlist, Playlist)

        if playlist.uri is None:
            return  # TODO: log this problem?

        uri_scheme = urlparse.urlparse(playlist.uri).scheme
        backend = self.backends.with_playlists.get(uri_scheme, None)
        if not backend:
            return None

        # TODO: we let AssertionError error through due to legacy tests :/
        with _backend_error_handling(backend, reraise=AssertionError):
            playlist = backend.playlists.save(playlist).get()
            playlist is None or validation.check_instance(playlist, Playlist)
            if playlist:
                listener.CoreListener.send('playlist_changed',
                                           playlist=playlist)
            return playlist

        return None
Example #5
0
class Core(
        pykka.ThreadingActor, audio.AudioListener, backend.BackendListener,
        mixer.MixerListener):

    library = None
    """An instance of :class:`~mopidy.core.LibraryController`"""

    history = None
    """An instance of :class:`~mopidy.core.HistoryController`"""

    mixer = None
    """An instance of :class:`~mopidy.core.MixerController`"""

    playback = None
    """An instance of :class:`~mopidy.core.PlaybackController`"""

    playlists = None
    """An instance of :class:`~mopidy.core.PlaylistsController`"""

    tracklist = None
    """An instance of :class:`~mopidy.core.TracklistController`"""

    def __init__(self, config=None, mixer=None, backends=None, audio=None):
        super(Core, self).__init__()

        self._config = config

        self.backends = Backends(backends)

        self.library = LibraryController(backends=self.backends, core=self)
        self.history = HistoryController()
        self.mixer = MixerController(mixer=mixer)
        self.playback = PlaybackController(
            audio=audio, backends=self.backends, core=self)
        self.playlists = PlaylistsController(backends=self.backends, core=self)
        self.tracklist = TracklistController(core=self)

        self.audio = audio

    def get_uri_schemes(self):
        """Get list of URI schemes we can handle"""
        futures = [b.uri_schemes for b in self.backends]
        results = pykka.get_all(futures)
        uri_schemes = itertools.chain(*results)
        return sorted(uri_schemes)

    uri_schemes = deprecated_property(get_uri_schemes)
    """
    .. deprecated:: 1.0
        Use :meth:`get_uri_schemes` instead.
    """

    def get_version(self):
        """Get version of the Mopidy core API"""
        return versioning.get_version()

    version = deprecated_property(get_version)
    """
    .. deprecated:: 1.0
        Use :meth:`get_version` instead.
    """

    def reached_end_of_stream(self):
        self.playback._on_end_of_stream()

    def stream_changed(self, uri):
        self.playback._on_stream_changed(uri)

    def position_changed(self, position):
        self.playback._on_position_changed(position)

    def state_changed(self, old_state, new_state, target_state):
        # XXX: This is a temporary fix for issue #232 while we wait for a more
        # permanent solution with the implementation of issue #234. When the
        # Spotify play token is lost, the Spotify backend pauses audio
        # playback, but mopidy.core doesn't know this, so we need to update
        # mopidy.core's state to match the actual state in mopidy.audio. If we
        # don't do this, clients will think that we're still playing.

        # We ignore cases when target state is set as this is buffering
        # updates (at least for now) and we need to get #234 fixed...
        if (new_state == PlaybackState.PAUSED and not target_state and
                self.playback.state != PlaybackState.PAUSED):
            self.playback.state = new_state
            self.playback._trigger_track_playback_paused()

    def playlists_loaded(self):
        # Forward event from backend to frontends
        CoreListener.send('playlists_loaded')

    def volume_changed(self, volume):
        # Forward event from mixer to frontends
        CoreListener.send('volume_changed', volume=volume)

    def mute_changed(self, mute):
        # Forward event from mixer to frontends
        CoreListener.send('mute_changed', mute=mute)

    def tags_changed(self, tags):
        if not self.audio or 'title' not in tags:
            return

        tags = self.audio.get_current_tags().get()
        if not tags:
            return

        # TODO: this limits us to only streams that set organization, this is
        # a hack to make sure we don't emit stream title changes for plain
        # tracks. We need a better way to decide if something is a stream.
        if 'title' in tags and tags['title'] and 'organization' in tags:
            title = tags['title'][0]
            self.playback._stream_title = title
            CoreListener.send('stream_title_changed', title=title)
Example #6
0
class PlaybackController(object):
    pykka_traversable = True

    def __init__(self, backends, core):
        self.backends = backends
        self.core = core

        self._current_tl_track = None
        self._stream_title = None
        self._state = PlaybackState.STOPPED

    def _get_backend(self):
        # TODO: take in track instead
        track = self.get_current_track()
        if track is None:
            return None
        uri = track.uri
        uri_scheme = urlparse.urlparse(uri).scheme
        return self.backends.with_playback.get(uri_scheme, None)

    # Properties

    def get_current_tl_track(self):
        """Get the currently playing or selected track.

        Returns a :class:`mopidy.models.TlTrack` or :class:`None`.
        """
        return self._current_tl_track

    def _set_current_tl_track(self, value):
        """Set the currently playing or selected track.

        *Internal:* This is only for use by Mopidy's test suite.
        """
        self._current_tl_track = value

    current_tl_track = deprecation.deprecated_property(get_current_tl_track)
    """
    .. deprecated:: 1.0
        Use :meth:`get_current_tl_track` instead.
    """

    def get_current_track(self):
        """
        Get the currently playing or selected track.

        Extracted from :meth:`get_current_tl_track` for convenience.

        Returns a :class:`mopidy.models.Track` or :class:`None`.
        """
        return getattr(self.get_current_tl_track(), 'track', None)

    current_track = deprecation.deprecated_property(get_current_track)
    """
    .. deprecated:: 1.0
        Use :meth:`get_current_track` instead.
    """

    def get_current_tlid(self):
        """
        Get the currently playing or selected TLID.

        Extracted from :meth:`get_current_tl_track` for convenience.

        Returns a :class:`int` or :class:`None`.

        .. versionadded:: 1.1
        """
        return getattr(self.get_current_tl_track(), 'tlid', None)

    def get_stream_title(self):
        """Get the current stream title or :class:`None`."""
        return self._stream_title

    def get_state(self):
        """Get The playback state."""

        return self._state

    def set_state(self, new_state):
        """Set the playback state.

        Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`.

        Possible states and transitions:

        .. digraph:: state_transitions

            "STOPPED" -> "PLAYING" [ label="play" ]
            "STOPPED" -> "PAUSED" [ label="pause" ]
            "PLAYING" -> "STOPPED" [ label="stop" ]
            "PLAYING" -> "PAUSED" [ label="pause" ]
            "PLAYING" -> "PLAYING" [ label="play" ]
            "PAUSED" -> "PLAYING" [ label="resume" ]
            "PAUSED" -> "STOPPED" [ label="stop" ]
        """
        validation.check_choice(new_state, validation.PLAYBACK_STATES)

        (old_state, self._state) = (self.get_state(), new_state)
        logger.debug('Changing state: %s -> %s', old_state, new_state)

        self._trigger_playback_state_changed(old_state, new_state)

    state = deprecation.deprecated_property(get_state, set_state)
    """
    .. deprecated:: 1.0
        Use :meth:`get_state` and :meth:`set_state` instead.
    """

    def get_time_position(self):
        """Get time position in milliseconds."""
        backend = self._get_backend()
        if backend:
            return backend.playback.get_time_position().get()
        else:
            return 0

    time_position = deprecation.deprecated_property(get_time_position)
    """
    .. deprecated:: 1.0
        Use :meth:`get_time_position` instead.
    """

    def get_volume(self):
        """
        .. deprecated:: 1.0
            Use :meth:`core.mixer.get_volume()
            <mopidy.core.MixerController.get_volume>` instead.
        """
        deprecation.warn('core.playback.get_volume')
        return self.core.mixer.get_volume()

    def set_volume(self, volume):
        """
        .. deprecated:: 1.0
            Use :meth:`core.mixer.set_volume()
            <mopidy.core.MixerController.set_volume>` instead.
        """
        deprecation.warn('core.playback.set_volume')
        return self.core.mixer.set_volume(volume)

    volume = deprecation.deprecated_property(get_volume, set_volume)
    """
    .. deprecated:: 1.0
        Use :meth:`core.mixer.get_volume()
        <mopidy.core.MixerController.get_volume>` and
        :meth:`core.mixer.set_volume()
        <mopidy.core.MixerController.set_volume>` instead.
    """

    def get_mute(self):
        """
        .. deprecated:: 1.0
            Use :meth:`core.mixer.get_mute()
            <mopidy.core.MixerController.get_mute>` instead.
        """
        deprecation.warn('core.playback.get_mute')
        return self.core.mixer.get_mute()

    def set_mute(self, mute):
        """
        .. deprecated:: 1.0
            Use :meth:`core.mixer.set_mute()
            <mopidy.core.MixerController.set_mute>` instead.
        """
        deprecation.warn('core.playback.set_mute')
        return self.core.mixer.set_mute(mute)

    mute = deprecation.deprecated_property(get_mute, set_mute)
    """
    .. deprecated:: 1.0
        Use :meth:`core.mixer.get_mute()
        <mopidy.core.MixerController.get_mute>` and
        :meth:`core.mixer.set_mute()
        <mopidy.core.MixerController.set_mute>` instead.
    """

    # Methods

    # TODO: remove this.
    def _change_track(self, tl_track, on_error_step=1):
        """
        Change to the given track, keeping the current playback state.

        :param tl_track: track to change to
        :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
        :param on_error_step: direction to step at play error, 1 for next
            track (default), -1 for previous track. **INTERNAL**
        :type on_error_step: int, -1 or 1
        """
        old_state = self.get_state()
        self.stop()
        self._set_current_tl_track(tl_track)
        if old_state == PlaybackState.PLAYING:
            self._play(on_error_step=on_error_step)
        elif old_state == PlaybackState.PAUSED:
            # NOTE: this is just a quick hack to fix #1177, #1352, and #1378
            # as this code has already been killed in the gapless branch.
            backend = self._get_backend()
            if backend:
                backend.playback.prepare_change()
                success = backend.playback.change_track(tl_track.track).get()
                if success:
                    self.core.tracklist._mark_playing(tl_track)
                    self.core.history._add_track(tl_track.track)
                else:
                    self.core.tracklist._mark_unplayable(tl_track)
                    if on_error_step == 1:
                        # TODO: can cause an endless loop for single track
                        # repeat.
                        self.next()
                    elif on_error_step == -1:
                        self.previous()
                self.pause()

    # TODO: this is not really end of track, this is on_need_next_track
    def _on_end_of_track(self):
        """
        Tell the playback controller that end of track is reached.

        Used by event handler in :class:`mopidy.core.Core`.
        """
        if self.get_state() == PlaybackState.STOPPED:
            return

        original_tl_track = self.get_current_tl_track()
        next_tl_track = self.core.tracklist.eot_track(original_tl_track)

        if next_tl_track:
            self._change_track(next_tl_track)
        else:
            self.stop()
            self._set_current_tl_track(None)

        self.core.tracklist._mark_played(original_tl_track)

    def _on_tracklist_change(self):
        """
        Tell the playback controller that the current playlist has changed.

        Used by :class:`mopidy.core.TracklistController`.
        """
        tracklist = self.core.tracklist.get_tl_tracks()
        if self.get_current_tl_track() not in tracklist:
            self.stop()
            self._set_current_tl_track(None)

    def _on_stream_changed(self, uri):
        self._stream_title = None

    def next(self):
        """
        Change to the next track.

        The current playback state will be kept. If it was playing, playing
        will continue. If it was paused, it will still be paused, etc.
        """
        original_tl_track = self.get_current_tl_track()
        next_tl_track = self.core.tracklist.next_track(original_tl_track)

        if next_tl_track:
            # TODO: switch to:
            # backend.play(track)
            # wait for state change?
            self._change_track(next_tl_track)
        else:
            self.stop()
            self._set_current_tl_track(None)

        self.core.tracklist._mark_played(original_tl_track)

    def pause(self):
        """Pause playback."""
        backend = self._get_backend()
        if not backend or backend.playback.pause().get():
            # TODO: switch to:
            # backend.track(pause)
            # wait for state change?
            self.set_state(PlaybackState.PAUSED)
            self._trigger_track_playback_paused()

    def play(self, tl_track=None, tlid=None):
        """
        Play the given track, or if the given tl_track and tlid is
        :class:`None`, play the currently active track.

        Note that the track **must** already be in the tracklist.

        :param tl_track: track to play
        :type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
        :param tlid: TLID of the track to play
        :type tlid: :class:`int` or :class:`None`
        """
        if sum(o is not None for o in [tl_track, tlid]) > 1:
            raise ValueError('At most one of "tl_track" and "tlid" may be set')

        tl_track is None or validation.check_instance(tl_track, models.TlTrack)
        tlid is None or validation.check_integer(tlid, min=0)

        if tl_track:
            deprecation.warn('core.playback.play:tl_track_kwarg', pending=True)

        self._play(tl_track=tl_track, tlid=tlid, on_error_step=1)

    def _play(self, tl_track=None, tlid=None, on_error_step=1):
        if tl_track is None and tlid is not None:
            for tl_track in self.core.tracklist.get_tl_tracks():
                if tl_track.tlid == tlid:
                    break
            else:
                tl_track = None

        if tl_track is None:
            if self.get_state() == PlaybackState.PAUSED:
                return self.resume()

            if self.get_current_tl_track() is not None:
                tl_track = self.get_current_tl_track()
            else:
                if on_error_step == 1:
                    tl_track = self.core.tracklist.next_track(tl_track)
                elif on_error_step == -1:
                    tl_track = self.core.tracklist.previous_track(tl_track)

            if tl_track is None:
                return

        assert tl_track in self.core.tracklist.get_tl_tracks()

        # TODO: switch to:
        # backend.play(track)
        # wait for state change?

        if self.get_state() == PlaybackState.PLAYING:
            self.stop()

        self._set_current_tl_track(tl_track)
        self.set_state(PlaybackState.PLAYING)
        backend = self._get_backend()
        success = False

        if backend:
            backend.playback.prepare_change()
            try:
                success = (backend.playback.change_track(tl_track.track).get()
                           and backend.playback.play().get())
            except TypeError:
                logger.error(
                    '%s needs to be updated to work with this '
                    'version of Mopidy.',
                    backend.actor_ref.actor_class.__name__)
                logger.debug('Backend exception', exc_info=True)

        if success:
            self.core.tracklist._mark_playing(tl_track)
            self.core.history._add_track(tl_track.track)
            # TODO: replace with stream-changed
            self._trigger_track_playback_started()
        else:
            self.core.tracklist._mark_unplayable(tl_track)
            if on_error_step == 1:
                # TODO: can cause an endless loop for single track repeat.
                self.next()
            elif on_error_step == -1:
                self.previous()

    def previous(self):
        """
        Change to the previous track.

        The current playback state will be kept. If it was playing, playing
        will continue. If it was paused, it will still be paused, etc.
        """
        tl_track = self.get_current_tl_track()
        # TODO: switch to:
        # self.play(....)
        # wait for state change?
        self._change_track(self.core.tracklist.previous_track(tl_track),
                           on_error_step=-1)

    def resume(self):
        """If paused, resume playing the current track."""
        if self.get_state() != PlaybackState.PAUSED:
            return
        backend = self._get_backend()
        if backend and backend.playback.resume().get():
            self.set_state(PlaybackState.PLAYING)
            # TODO: trigger via gst messages
            self._trigger_track_playback_resumed()
        # TODO: switch to:
        # backend.resume()
        # wait for state change?

    def seek(self, time_position):
        """
        Seeks to time position given in milliseconds.

        :param time_position: time position in milliseconds
        :type time_position: int
        :rtype: :class:`True` if successful, else :class:`False`
        """
        validation.check_integer(time_position)

        if time_position < 0:
            logger.debug(
                'Client seeked to negative position. Seeking to zero.')
            time_position = 0

        if not self.core.tracklist.tracks:
            return False

        if self.current_track and self.current_track.length is None:
            return False

        if self.get_state() == PlaybackState.STOPPED:
            self.play()

        if time_position < 0:
            time_position = 0
        elif time_position > self.current_track.length:
            self.next()
            return True

        backend = self._get_backend()
        if not backend:
            return False

        success = backend.playback.seek(time_position).get()
        if success:
            self._trigger_seeked(time_position)
        return success

    def stop(self):
        """Stop playing."""
        if self.get_state() != PlaybackState.STOPPED:
            backend = self._get_backend()
            time_position_before_stop = self.get_time_position()
            if not backend or backend.playback.stop().get():
                self.set_state(PlaybackState.STOPPED)
                self._trigger_track_playback_ended(time_position_before_stop)

    def _trigger_track_playback_paused(self):
        logger.debug('Triggering track playback paused event')
        if self.current_track is None:
            return
        listener.CoreListener.send('track_playback_paused',
                                   tl_track=self.get_current_tl_track(),
                                   time_position=self.get_time_position())

    def _trigger_track_playback_resumed(self):
        logger.debug('Triggering track playback resumed event')
        if self.current_track is None:
            return
        listener.CoreListener.send('track_playback_resumed',
                                   tl_track=self.get_current_tl_track(),
                                   time_position=self.get_time_position())

    def _trigger_track_playback_started(self):
        logger.debug('Triggering track playback started event')
        if self.get_current_tl_track() is None:
            return
        listener.CoreListener.send('track_playback_started',
                                   tl_track=self.get_current_tl_track())

    def _trigger_track_playback_ended(self, time_position_before_stop):
        logger.debug('Triggering track playback ended event')
        if self.get_current_tl_track() is None:
            return
        listener.CoreListener.send('track_playback_ended',
                                   tl_track=self.get_current_tl_track(),
                                   time_position=time_position_before_stop)

    def _trigger_playback_state_changed(self, old_state, new_state):
        logger.debug('Triggering playback state change event')
        listener.CoreListener.send('playback_state_changed',
                                   old_state=old_state,
                                   new_state=new_state)

    def _trigger_seeked(self, time_position):
        logger.debug('Triggering seeked event')
        listener.CoreListener.send('seeked', time_position=time_position)