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?
This commit is contained in:
Jens Luetjen 2015-12-27 19:28:41 +01:00
parent d0e4e8e35d
commit e9625e9feb
13 changed files with 269 additions and 46 deletions

View File

@ -53,6 +53,9 @@ in core see :class:`~mopidy.core.CoreListener`.
.. automethod:: get_version
.. automethod:: save_state
.. automethod:: load_state
Tracklist controller
====================

View File

@ -87,6 +87,13 @@ Data model (de)serialization
.. autoclass:: mopidy.models.ModelJSONEncoder
Data model import/export
----------------------------
.. autofunction:: mopidy.models.storage.save
.. autofunction:: mopidy.models.storage.load
Data model field types
----------------------

View File

@ -110,7 +110,17 @@ Core configuration
The original MPD server only supports 10000 tracks in the tracklist. Some
MPD clients will crash if this limit is exceeded.
.. confval:: core/restore_state
Restore last state at start. Defaults to ``off``.
Save state when Mopidy ends and restore state at next start.
Allowed values:
- ``off``: restore nothing
- ``load``: restore settings, volume and play queue
- ``play``: restore settings, volume, play queue and start playback
Audio configuration
-------------------

View File

@ -21,6 +21,7 @@ _core_schema['config_dir'] = Path()
_core_schema['data_dir'] = Path()
# MPD supports at most 10k tracks, some clients segfault when this is exceeded.
_core_schema['max_tracklist_length'] = Integer(minimum=1, maximum=10000)
_core_schema['restore_state'] = String(optional=True)
_logging_schema = ConfigSchema('logging')
_logging_schema['color'] = Boolean()

View File

@ -3,6 +3,7 @@ cache_dir = $XDG_CACHE_DIR/mopidy
config_dir = $XDG_CONFIG_DIR/mopidy
data_dir = $XDG_DATA_DIR/mopidy
max_tracklist_length = 10000
restore_state = off
[logging]
color = true

View File

@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals
import collections
import itertools
import logging
import os
import pykka
@ -17,6 +18,7 @@ from mopidy.core.playlists import PlaylistsController
from mopidy.core.tracklist import TracklistController
from mopidy.internal import versioning
from mopidy.internal.deprecation import deprecated_property
from mopidy.models import storage
logger = logging.getLogger(__name__)
@ -133,6 +135,93 @@ class Core(
self.playback._stream_title = title
CoreListener.send('stream_title_changed', title=title)
def on_start(self):
logger.debug("core on_start")
try:
amount = self._config['core']['restore_state']
coverage = []
if not amount or 'off' == amount:
pass
elif 'load' == amount:
coverage = ['tracklist', 'mode', 'volume', 'history']
elif 'play' == amount:
coverage = ['tracklist', 'mode', 'autoplay', 'volume',
'history']
else:
logger.warn('Unknown value for config '
'core.restore_state: %s', amount)
if len(coverage):
self.load_state('persistent', coverage)
except Exception as e:
logger.warn('Unexpected error: %s', str(e))
pykka.ThreadingActor.on_start(self)
def on_stop(self):
logger.debug("core on_stop")
try:
amount = self._config['core']['restore_state']
if amount and 'off' != amount:
self.save_state('persistent')
except Exception as e:
logger.warn('on_stop: Unexpected error: %s', str(e))
pykka.ThreadingActor.on_stop(self)
def save_state(self, name):
"""
Save current state to disk.
:param name: a name (for later use with :meth:`load_state`)
:type name: str
"""
logger.info('Save state: "%s"', name)
if not name:
raise TypeError('missing file name')
file_name = os.path.join(
self._config['core']['config_dir'], name)
file_name += '.state'
data = {}
self.tracklist._state_export(data)
self.history._state_export(data)
self.playback._state_export(data)
self.mixer._state_export(data)
storage.save(file_name, data)
def load_state(self, name, coverage):
"""
Restore state from disk.
Load state from disk and restore it. Parameter `coverage`
limits the amount 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)
- 'autoplay' start playing ('tracklist' also required)
- 'volume' set mixer volume
- 'history' restore history
:param name: a name (used previously with :meth:`save_state`)
:type path: str
:param coverage: amount of data to restore
:type coverage: list of string (see above)
"""
logger.info('Load state: "%s"', name)
if not name:
raise TypeError('missing file name')
file_name = os.path.join(
self._config['core']['config_dir'], name)
file_name += '.state'
data = storage.load(file_name)
self.history._state_import(data, coverage)
self.tracklist._state_import(data, coverage)
self.playback._state_import(data, coverage)
self.mixer._state_import(data, coverage)
logger.info('Load state done')
class Backends(list):

View File

@ -57,3 +57,16 @@ class HistoryController(object):
:rtype: list of (timestamp, :class:`mopidy.models.Ref`) tuples
"""
return copy.copy(self._history)
def _state_export(self, data):
"""Internal method for :class:`mopidy.Core`."""
data['history'] = {}
data['history']['history'] = self._history
def _state_import(self, data, coverage):
"""Internal method for :class:`mopidy.Core`."""
if 'history' not in data:
return
if 'history' in coverage:
if 'history' in data['history']:
self._history = data['history']['history']

View File

@ -99,3 +99,16 @@ class MixerController(object):
return result
return False
def _state_export(self, data):
"""Internal method for :class:`mopidy.Core`."""
data['mixer'] = {}
data['mixer']['volume'] = self.get_volume()
def _state_import(self, data, coverage):
"""Internal method for :class:`mopidy.Core`."""
if 'mixer' not in data:
return
if 'volume' in coverage:
if 'volume' in data['mixer']:
self.set_volume(data['mixer']['volume'])

View File

@ -523,3 +523,23 @@ class PlaybackController(object):
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'])

View File

@ -644,3 +644,35 @@ class TracklistController(object):
def _trigger_options_changed(self):
logger.debug('Triggering options changed event')
listener.CoreListener.send('options_changed')
def _state_export(self, data):
"""Internal method for :class:`mopidy.Core`."""
data['tracklist'] = {}
data['tracklist']['tl_tracks'] = self._tl_tracks
data['tracklist']['next_tlid'] = self._next_tlid
data['tracklist']['consume'] = self.get_consume()
data['tracklist']['random'] = self.get_random()
data['tracklist']['repeat'] = self.get_repeat()
data['tracklist']['single'] = self.get_single()
def _state_import(self, data, coverage):
"""Internal method for :class:`mopidy.Core`."""
if 'tracklist' not in data:
return
if 'mode' in coverage:
# TODO: only one _trigger_options_changed() for all options
if 'consume' in data['tracklist']:
self.set_consume(data['tracklist']['consume'])
if 'random' in data['tracklist']:
self.set_random(data['tracklist']['random'])
if 'repeat' in data['tracklist']:
self.set_repeat(data['tracklist']['repeat'])
if 'single' in data['tracklist']:
self.set_single(data['tracklist']['single'])
if 'tracklist' in coverage:
if 'next_tlid' in data['tracklist']:
if data['tracklist']['next_tlid'] > self._next_tlid:
self._next_tlid = data['tracklist']['next_tlid']
if 'tl_tracks' in data['tracklist']:
self._tl_tracks = data['tracklist']['tl_tracks']
self._trigger_tracklist_changed()

View File

@ -43,6 +43,8 @@ def make_jsonrpc_wrapper(core_actor):
objects={
'core.get_uri_schemes': core.Core.get_uri_schemes,
'core.get_version': core.Core.get_version,
'core.load_state': core.Core.load_state,
'core.save_state': core.Core.save_state,
'core.history': core.HistoryController,
'core.library': core.LibraryController,
'core.mixer': core.MixerController,
@ -55,6 +57,8 @@ def make_jsonrpc_wrapper(core_actor):
'core.describe': inspector.describe,
'core.get_uri_schemes': core_actor.get_uri_schemes,
'core.get_version': core_actor.get_version,
'core.load_state': core_actor.load_state,
'core.save_state': core_actor.save_state,
'core.history': core_actor.history,
'core.library': core_actor.library,
'core.mixer': core_actor.mixer,

View File

@ -1,60 +1,19 @@
from __future__ import absolute_import, absolute_import, unicode_literals
import collections
import gzip
import json
import logging
import os
import re
import sys
import tempfile
import mopidy
from mopidy import compat, local, models
from mopidy.internal import encoding, timer
from mopidy.internal import timer
from mopidy.local import search, storage, translator
logger = logging.getLogger(__name__)
# TODO: move to load and dump in models?
def load_library(json_file):
if not os.path.isfile(json_file):
logger.info(
'No local library metadata cache found at %s. Please run '
'`mopidy local scan` to index your local music library. '
'If you do not have a local music collection, you can disable the '
'local backend to hide this message.',
json_file)
return {}
try:
with gzip.open(json_file, 'rb') as fp:
return json.load(fp, object_hook=models.model_json_decoder)
except (IOError, ValueError) as error:
logger.warning(
'Loading JSON local library failed: %s',
encoding.locale_decode(error))
return {}
def write_library(json_file, data):
data['version'] = mopidy.__version__
directory, basename = os.path.split(json_file)
# TODO: cleanup directory/basename.* files.
tmp = tempfile.NamedTemporaryFile(
prefix=basename + '.', dir=directory, delete=False)
try:
with gzip.GzipFile(fileobj=tmp, mode='wb') as fp:
json.dump(data, fp, cls=models.ModelJSONEncoder,
indent=2, separators=(',', ': '))
os.rename(tmp.name, json_file)
finally:
if os.path.exists(tmp.name):
os.remove(tmp.name)
class _BrowseCache(object):
encoding = sys.getfilesystemencoding()
splitpath_re = re.compile(r'([^/]+)')
@ -128,8 +87,18 @@ class JsonLibrary(local.Library):
def load(self):
logger.debug('Loading library: %s', self._json_file)
with timer.time_logger('Loading tracks'):
library = load_library(self._json_file)
self._tracks = dict((t.uri, t) for t in library.get('tracks', []))
if not os.path.isfile(self._json_file):
logger.info(
'No local library metadata cache found at %s. Please run '
'`mopidy local scan` to index your local music library. '
'If you do not have a local music collection, you can '
'disable the local backend to hide this message.',
self._json_file)
self._tracks = {}
else:
library = models.storage.load(self._json_file)
self._tracks = dict((t.uri, t) for t in
library.get('tracks', []))
with timer.time_logger('Building browse cache'):
self._browse_cache = _BrowseCache(sorted(self._tracks.keys()))
return len(self._tracks)
@ -195,7 +164,7 @@ class JsonLibrary(local.Library):
self._tracks.pop(uri, None)
def close(self):
write_library(self._json_file, {'tracks': self._tracks.values()})
models.storage.save(self._json_file, {'tracks': self._tracks.values()})
def clear(self):
try:

61
mopidy/models/storage.py Normal file
View File

@ -0,0 +1,61 @@
from __future__ import absolute_import, unicode_literals
import gzip
import json
import logging
import os
import tempfile
import mopidy
from mopidy import models
from mopidy.internal import encoding
logger = logging.getLogger(__name__)
def load(path):
"""
Deserialize data from file.
:param path: full path to import file
:type path: str
:return: deserialized data
:rtype: dict
"""
if not os.path.isfile(path):
logger.info('File does not exist: %s.', path)
return {}
try:
with gzip.open(path, 'rb') as fp:
return json.load(fp, object_hook=models.model_json_decoder)
except (IOError, ValueError) as error:
logger.warning(
'Loading JSON failed: %s',
encoding.locale_decode(error))
return {}
def save(path, data):
"""
Serialize data to file.
:param path: full path to export file
:type path: str
:param data: dictionary containing data to save
:type data: dict
"""
data['version'] = mopidy.__version__
directory, basename = os.path.split(path)
# TODO: cleanup directory/basename.* files.
tmp = tempfile.NamedTemporaryFile(
prefix=basename + '.', dir=directory, delete=False)
try:
with gzip.GzipFile(fileobj=tmp, mode='wb') as fp:
json.dump(data, fp, cls=models.ModelJSONEncoder,
indent=2, separators=(',', ': '))
os.rename(tmp.name, path)
finally:
if os.path.exists(tmp.name):
os.remove(tmp.name)