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:
parent
d0e4e8e35d
commit
e9625e9feb
@ -53,6 +53,9 @@ in core see :class:`~mopidy.core.CoreListener`.
|
||||
|
||||
.. automethod:: get_version
|
||||
|
||||
.. automethod:: save_state
|
||||
|
||||
.. automethod:: load_state
|
||||
|
||||
Tracklist controller
|
||||
====================
|
||||
|
||||
@ -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
|
||||
----------------------
|
||||
|
||||
|
||||
@ -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
|
||||
-------------------
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
61
mopidy/models/storage.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user