mopidy/mopidy/core/playback.py
Jens Luetjen e9625e9feb core: Fix #310: Persist mopidy state between runs.
Persist following properties:
mopidy.core.tracklist
    _tl_tracks
    _next_tlid
    get_consume()
    get_random()
    get_repeat()
    get_single()
mopidy.core.history
    _history
mopidy.core.playlist
    get_current_tl_track()
    get_time_position()
mopidy.core.mixer
    get_volume()

Details:
- moved json export/import write_library()/load_library() from mopidy/local to mopidy/models
- new core methods save_state(), load_state()
- save_state(), load_state() accessible via rpc
- save state to disk at stop
- load state from disk at start
- new config: core.restore_state ("off", "load", "play")

TODO:
- seek to play position does not work. Timing issue.
- use extra thread to load state from disk at start?
2015-12-27 19:28:41 +01:00

546 lines
19 KiB
Python

from __future__ import absolute_import, unicode_literals
import logging
from mopidy import models
from mopidy.audio import PlaybackState
from mopidy.compat import urllib
from mopidy.core import listener
from mopidy.internal import deprecation, validation
logger = logging.getLogger(__name__)
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
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."""
backend = self._get_backend(self.get_current_tl_track())
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
def _on_end_of_stream(self):
self.set_state(PlaybackState.STOPPED)
self._set_current_tl_track(None)
# TODO: self._trigger_track_playback_ended?
def _on_stream_changed(self, uri):
self._stream_title = None
if self._pending_tl_track:
self._set_current_tl_track(self._pending_tl_track)
self._pending_tl_track = None
self._trigger_track_playback_started()
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):
self._trigger_track_playback_ended(self.get_time_position())
# TODO: check that we always have a current track
original_tl_track = self.get_current_tl_track()
next_tl_track = self.core.tracklist.eot_track(original_tl_track)
# TODO: only set pending if we have a backend that can play it?
# TODO: skip tracks that don't have a backend?
self._pending_tl_track = next_tl_track
backend = self._get_backend(next_tl_track)
if backend:
backend.playback.change_track(next_tl_track.track).get()
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`.
"""
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
# TODO: move to pending track?
self._trigger_track_playback_ended(self.get_time_position())
self.core.tracklist._mark_played(self._current_tl_track)
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
# TODO return result?
def pause(self):
"""Pause playback."""
backend = self._get_backend(self.get_current_tl_track())
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
original = self._current_tl_track
current = self._pending_tl_track or self._current_tl_track
pending = tl_track or current or self.core.tracklist.next_track(None)
if original != pending and self.get_state() != PlaybackState.STOPPED:
self._trigger_track_playback_ended(self.get_time_position())
if pending:
# TODO: remove?
self.set_state(PlaybackState.PLAYING)
while pending:
# TODO: should we consume unplayable tracks in this loop?
if self._change(pending, PlaybackState.PLAYING):
break
else:
self.core.tracklist._mark_unplayable(pending)
current = pending
pending = self.core.tracklist.next_track(current)
# TODO: move to top and get rid of original?
self.core.tracklist._mark_played(original)
# 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
backend.playback.prepare_change()
if not backend.playback.change_track(pending_tl_track.track).get():
return False # TODO: test for this path
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._trigger_track_playback_ended(self.get_time_position())
state = self.get_state()
current = self._pending_tl_track or self._current_tl_track
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
# 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())
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()
# TODO: uncomment once we have tests for this. Should fix seek after
# about to finish doing wrong track.
# if self._current_tl_track and self._pending_tl_track:
# self.play(self._current_tl_track)
# 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
backend = self._get_backend(self.get_current_tl_track())
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(self.get_current_tl_track())
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):
# TODO: replace with stream-changed
logger.debug('Triggering track playback started event')
if self.get_current_tl_track() is None:
return
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):
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)
def _state_export(self, data):
"""Internal method for :class:`mopidy.Core`."""
data['playback'] = {}
data['playback']['current_tl_track'] = self.get_current_tl_track()
data['playback']['position'] = self.get_time_position()
# TODO: export/import get_state()?
def _state_import(self, data, coverage):
"""Internal method for :class:`mopidy.Core`."""
if 'playback' not in data:
return
if 'autoplay' in coverage:
if 'current_tl_track' in data['playback']:
tl_track = data['playback']['current_tl_track']
if tl_track is not None:
self.play(tl_track=tl_track)
# TODO: Seek not working. It seeks to early.
# if 'position' in data['playback']:
# self.seek(data['playback']['position'])