From e9625e9febc730229af95ce2d1036fd50530ca32 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sun, 27 Dec 2015 19:28:41 +0100 Subject: [PATCH] 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? --- docs/api/core.rst | 3 ++ docs/api/models.rst | 7 +++ docs/config.rst | 10 +++++ mopidy/config/__init__.py | 1 + mopidy/config/default.conf | 1 + mopidy/core/actor.py | 89 ++++++++++++++++++++++++++++++++++++++ mopidy/core/history.py | 13 ++++++ mopidy/core/mixer.py | 13 ++++++ mopidy/core/playback.py | 20 +++++++++ mopidy/core/tracklist.py | 32 ++++++++++++++ mopidy/http/handlers.py | 4 ++ mopidy/local/json.py | 61 +++++++------------------- mopidy/models/storage.py | 61 ++++++++++++++++++++++++++ 13 files changed, 269 insertions(+), 46 deletions(-) create mode 100644 mopidy/models/storage.py diff --git a/docs/api/core.rst b/docs/api/core.rst index 5f1e406f..3aca2504 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -53,6 +53,9 @@ in core see :class:`~mopidy.core.CoreListener`. .. automethod:: get_version + .. automethod:: save_state + + .. automethod:: load_state Tracklist controller ==================== diff --git a/docs/api/models.rst b/docs/api/models.rst index 27c7647f..cd6d1cf2 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -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 ---------------------- diff --git a/docs/config.rst b/docs/config.rst index 292a6a09..50d58e39 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -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 ------------------- diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 042c20d9..e89f0eb9 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -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() diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index 675381d9..a501ccb9 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -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 diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index e365e4b7..45a78baf 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -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): diff --git a/mopidy/core/history.py b/mopidy/core/history.py index ae697e8e..a2d31cc9 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -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'] diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 649ff270..787afa97 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -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']) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index fc20d412..b2e23fbc 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -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']) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 02508c97..fcd2c9e4 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -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() diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index a752a4f0..2994f8ed 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -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, diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 8e8b5b1e..96c96e49 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -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: diff --git a/mopidy/models/storage.py b/mopidy/models/storage.py new file mode 100644 index 00000000..20fc490f --- /dev/null +++ b/mopidy/models/storage.py @@ -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)