From e9625e9febc730229af95ce2d1036fd50530ca32 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sun, 27 Dec 2015 19:28:41 +0100 Subject: [PATCH 001/118] 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) From 44841710e010c16f78745e1e917b750645fd427f Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sun, 27 Dec 2015 21:03:00 +0100 Subject: [PATCH 002/118] Use data_dir instead of config_dir. Mopidy as service can not write to config_dir. --- mopidy/core/actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 45a78baf..12b6883d 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -178,7 +178,7 @@ class Core( raise TypeError('missing file name') file_name = os.path.join( - self._config['core']['config_dir'], name) + self._config['core']['data_dir'], name) file_name += '.state' data = {} @@ -212,7 +212,7 @@ class Core( raise TypeError('missing file name') file_name = os.path.join( - self._config['core']['config_dir'], name) + self._config['core']['data_dir'], name) file_name += '.state' data = storage.load(file_name) From a5a9178b060cd0be4eaeb0f898f2ec996a3e2397 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 2 Jan 2016 15:28:41 +0100 Subject: [PATCH 003/118] Use model(s) to save/restore current play state --- mopidy/core/actor.py | 1 + mopidy/core/history.py | 18 ++-- mopidy/core/mixer.py | 13 ++- mopidy/core/playback.py | 20 ++--- mopidy/core/tracklist.py | 47 +++++----- mopidy/models/__init__.py | 105 ++++++++++++++++++++++- mopidy/models/fields.py | 11 +++ mopidy/models/serialize.py | 4 +- tests/models/test_fields.py | 21 +++++ tests/models/test_models.py | 166 +++++++++++++++++++++++++++++++++++- 10 files changed, 351 insertions(+), 55 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 12b6883d..35035f5b 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -180,6 +180,7 @@ class Core( file_name = os.path.join( self._config['core']['data_dir'], name) file_name += '.state' + logger.info('Save state to "%s"', file_name) data = {} self.tracklist._state_export(data) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index a2d31cc9..0f6c20b7 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -60,13 +60,17 @@ class HistoryController(object): def _state_export(self, data): """Internal method for :class:`mopidy.Core`.""" - data['history'] = {} - data['history']['history'] = self._history + history_list = [] + for timestamp, track in self._history: + history_list.append(models.HistoryTrack( + timestamp=timestamp, track=track)) + data['history'] = models.HistoryState(history=history_list) 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'] + if 'history' in data: + hstate = data['history'] + if 'history' in coverage: + self._history = [] + for htrack in hstate.history: + self._history.append((htrack.timestamp, htrack.track)) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 787afa97..48b12758 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -5,6 +5,7 @@ import logging from mopidy import exceptions from mopidy.internal import validation +from mopidy.models import MixerState logger = logging.getLogger(__name__) @@ -102,13 +103,11 @@ class MixerController(object): def _state_export(self, data): """Internal method for :class:`mopidy.Core`.""" - data['mixer'] = {} - data['mixer']['volume'] = self.get_volume() + data['mixer'] = MixerState(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']) + if 'mixer' in data: + ms = data['mixer'] + if 'volume' in coverage: + self.set_volume(ms.volume) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index b2e23fbc..4a95f914 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -526,20 +526,18 @@ class PlaybackController(object): 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()? + data['playback'] = models.PlaybackState( + tl_track=self.get_current_tl_track(), + position=self.get_time_position(), + state=self.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 'playback' in data: + ps = data['playback'] + if 'autoplay' in coverage: + tl_track = ps.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']) + # self.seek(ps.position) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index fcd2c9e4..ecc6bcdb 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -6,7 +6,7 @@ import random from mopidy import exceptions from mopidy.core import listener from mopidy.internal import deprecation, validation -from mopidy.models import TlTrack, Track +from mopidy.models import TlTrack, Track, TracklistState logger = logging.getLogger(__name__) @@ -647,32 +647,27 @@ class TracklistController(object): 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() + data['tracklist'] = TracklistState( + tracks=self._tl_tracks, + next_tlid=self._next_tlid, + consume=self.get_consume(), + random=self.get_random(), + repeat=self.get_repeat(), + 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'] + if 'tracklist' in data: + tls = data['tracklist'] + if 'mode' in coverage: + self.set_consume(tls.consume) + self.set_random(tls.random) + self.set_repeat(tls.repeat) + self.set_single(tls.single) + if 'tracklist' in coverage: + if tls.next_tlid > self._next_tlid: + self._next_tlid = tls.next_tlid + self._tl_tracks = [] + for track in tls.tracks: + self._tl_tracks.append(track) self._trigger_tracklist_changed() diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 9f93a01b..fc6e9be6 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals from mopidy import compat +from mopidy.internal import validation from mopidy.models import fields from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder @@ -8,7 +9,8 @@ from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder __all__ = [ 'ImmutableObject', 'Ref', 'Image', 'Artist', 'Album', 'track', 'TlTrack', 'Playlist', 'SearchResult', 'model_json_decoder', 'ModelJSONEncoder', - 'ValidatedImmutableObject'] + 'ValidatedImmutableObject', 'HistoryTrack', 'HistoryState', 'MixerState', + 'PlaybackState', 'TracklistState'] class Ref(ValidatedImmutableObject): @@ -360,3 +362,104 @@ class SearchResult(ValidatedImmutableObject): # The albums matching the search query. Read-only. albums = fields.Collection(type=Album, container=tuple) + + +class HistoryTrack(ValidatedImmutableObject): + """ + A history track. Wraps a :class:`Ref` and it's timestamp. + + :param timestamp: the timestamp + :type timestamp: int + :param track: the track + :type track: :class:`Ref` + """ + + # The timestamp. Read-only. + timestamp = fields.Integer() + + # The track. Read-only. + track = fields.Field(type=Ref) + + +class HistoryState(ValidatedImmutableObject): + """ + State of the history controller. + Internally used for import/export of current state. + + :param history: the track history + :type history: list of :class:`HistoryTrack` + """ + + # The tracks. Read-only. + history = fields.Collection(type=HistoryTrack, container=tuple) + + +class MixerState(ValidatedImmutableObject): + """ + State of the mixer controller. + Internally used for import/export of current state. + + :param volume: the volume + :type volume: int + """ + + # The volume. Read-only. + volume = fields.Integer(min=0, max=100) + + +class PlaybackState(ValidatedImmutableObject): + """ + State of the playback controller. + Internally used for import/export of current state. + + :param tl_track: current track + :type tl_track: :class:`TlTrack` + :param position: play position + :type position: int + :param state: playback state + :type state: :class:`TlTrack` + """ + + # The current playing track. Read-only. + tl_track = fields.Field(type=TlTrack) + + # The playback position. Read-only. + position = fields.Integer(min=0) + + # The playback state. Read-only. + state = fields.Field(choices=validation.PLAYBACK_STATES) + + +class TracklistState(ValidatedImmutableObject): + + """ + State of the tracklist controller. + Internally used for import/export of current state. + + :param repeat: the repeat mode + :type repeat: bool + :param consume: the consume mode + :type consume: bool + :param random: the random mode + :type random: bool + :param single: the single mode + :type single: bool + """ + + # The repeat mode. Read-only. + repeat = fields.Boolean() + + # The consume mode. Read-only. + consume = fields.Boolean() + + # The random mode. Read-only. + random = fields.Boolean() + + # The single mode. Read-only. + single = fields.Boolean() + + # The repeat mode. Read-only. + next_tlid = fields.Integer(min=0) + + # The list of tracks. Read-only. + tracks = fields.Collection(type=TlTrack, container=tuple) diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py index c686b447..178618d1 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -135,6 +135,17 @@ class Integer(Field): return value +class Boolean(Field): + """ + :class:`Field` for storing boolean values + + :param default: default value for field + """ + + def __init__(self, default=None): + super(Boolean, self).__init__(type=bool, default=default) + + class Collection(Field): """ :class:`Field` for storing collections of a given type. diff --git a/mopidy/models/serialize.py b/mopidy/models/serialize.py index 5002a8f7..08162db4 100644 --- a/mopidy/models/serialize.py +++ b/mopidy/models/serialize.py @@ -4,7 +4,9 @@ import json from mopidy.models import immutable -_MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist'] +_MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist', + 'HistoryTrack', 'HistoryState', 'MixerState', 'PlaybackState', + 'TracklistState'] class ModelJSONEncoder(json.JSONEncoder): diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py index bf842fd5..825f66c6 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -173,6 +173,27 @@ class IntegerTest(unittest.TestCase): instance.attr = 11 +class BooleanTest(unittest.TestCase): + def test_default_handling(self): + instance = create_instance(Boolean(default=True)) + self.assertEqual(True, instance.attr) + + def test_true_allowed(self): + instance = create_instance(Boolean()) + instance.attr = True + self.assertEqual(True, instance.attr) + + def test_false_allowed(self): + instance = create_instance(Boolean()) + instance.attr = False + self.assertEqual(False, instance.attr) + + def test_int_forbidden(self): + instance = create_instance(Boolean()) + with self.assertRaises(TypeError): + instance.attr = 1 + + class CollectionTest(unittest.TestCase): def test_container_instance_is_default(self): instance = create_instance(Collection(type=int, container=frozenset)) diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 5108411a..1bf2b1fb 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -4,8 +4,9 @@ import json import unittest from mopidy.models import ( - Album, Artist, Image, ModelJSONEncoder, Playlist, Ref, SearchResult, - TlTrack, Track, model_json_decoder) + Album, Artist, HistoryState, HistoryTrack, Image, MixerState, + ModelJSONEncoder, PlaybackState, Playlist, + Ref, SearchResult, TlTrack, Track, TracklistState, model_json_decoder) class InheritanceTest(unittest.TestCase): @@ -1168,3 +1169,164 @@ class SearchResultTest(unittest.TestCase): self.assertDictEqual( {'__model__': 'SearchResult', 'uri': 'uri'}, SearchResult(uri='uri').serialize()) + + +class HistoryTrackTest(unittest.TestCase): + + def test_track(self): + track = Ref.track() + result = HistoryTrack(track=track) + self.assertEqual(result.track, track) + with self.assertRaises(AttributeError): + result.track = None + + def test_timestamp(self): + timestamp = 1234 + result = HistoryTrack(timestamp=timestamp) + self.assertEqual(result.timestamp, timestamp) + with self.assertRaises(AttributeError): + result.timestamp = None + + +class HistoryStateTest(unittest.TestCase): + + def test_history_list(self): + history = (HistoryTrack(), + HistoryTrack()) + result = HistoryState(history=history) + self.assertEqual(result.history, history) + with self.assertRaises(AttributeError): + result.history = None + + def test_history_string_fail(self): + history = 'not_a_valid_history' + with self.assertRaises(TypeError): + HistoryState(history=history) + + +class MixerStateTest(unittest.TestCase): + + def test_volume(self): + volume = 37 + result = MixerState(volume=volume) + self.assertEqual(result.volume, volume) + with self.assertRaises(AttributeError): + result.volume = None + + def test_volume_invalid(self): + volume = 105 + with self.assertRaises(ValueError): + MixerState(volume=volume) + + +class PlaybackStateTest(unittest.TestCase): + + def test_position(self): + position = 123456 + result = PlaybackState(position=position) + self.assertEqual(result.position, position) + with self.assertRaises(AttributeError): + result.position = None + + def test_position_invalid(self): + position = -1 + with self.assertRaises(ValueError): + PlaybackState(position=position) + + def test_tl_track(self): + tl_track = TlTrack() + result = PlaybackState(tl_track=tl_track) + self.assertEqual(result.tl_track, tl_track) + with self.assertRaises(AttributeError): + result.tl_track = None + + def test_tl_track_none(self): + tl_track = None + result = PlaybackState(tl_track=tl_track) + self.assertEqual(result.tl_track, tl_track) + with self.assertRaises(AttributeError): + result.tl_track = None + + def test_tl_track_invalid(self): + tl_track = Track() + with self.assertRaises(TypeError): + PlaybackState(tl_track=tl_track) + + def test_state(self): + state = 'playing' + result = PlaybackState(state=state) + self.assertEqual(result.state, state) + with self.assertRaises(AttributeError): + result.state = None + + def test_state_invalid(self): + state = 'not_a_state' + with self.assertRaises(TypeError): + PlaybackState(state=state) + + +class TracklistStateTest(unittest.TestCase): + + def test_repeat_true(self): + repeat = True + result = TracklistState(repeat=repeat) + self.assertEqual(result.repeat, repeat) + with self.assertRaises(AttributeError): + result.repeat = None + + def test_repeat_false(self): + repeat = False + result = TracklistState(repeat=repeat) + self.assertEqual(result.repeat, repeat) + with self.assertRaises(AttributeError): + result.repeat = None + + def test_repeat_invalid(self): + repeat = 33 + with self.assertRaises(TypeError): + TracklistState(repeat=repeat) + + def test_consume_true(self): + val = True + result = TracklistState(consume=val) + self.assertEqual(result.consume, val) + with self.assertRaises(AttributeError): + result.repeat = None + + def test_random_true(self): + val = True + result = TracklistState(random=val) + self.assertEqual(result.random, val) + with self.assertRaises(AttributeError): + result.random = None + + def test_single_true(self): + val = True + result = TracklistState(single=val) + self.assertEqual(result.single, val) + with self.assertRaises(AttributeError): + result.single = None + + def test_next_tlid(self): + val = 654 + result = TracklistState(next_tlid=val) + self.assertEqual(result.next_tlid, val) + with self.assertRaises(AttributeError): + result.next_tlid = None + + def test_next_tlid_invalid(self): + val = -1 + with self.assertRaises(ValueError): + TracklistState(next_tlid=val) + + def test_tracks(self): + tracks = (TlTrack(), TlTrack()) + result = TracklistState(tracks=tracks) + self.assertEqual(result.tracks, tracks) + with self.assertRaises(AttributeError): + result.tracks = None + + def test_tracks_invalid(self): + tracks = (Track(), Track()) + with self.assertRaises(TypeError): + TracklistState(tracks=tracks) From e56c39ee78358bc4a3ce404f3f7c5eecdb5bf1dc Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sun, 3 Jan 2016 18:29:35 +0100 Subject: [PATCH 004/118] Add unit tests for export/restore core state Fix issues shown by test code --- mopidy/core/actor.py | 52 +++++++++------- mopidy/core/history.py | 13 ++-- mopidy/core/mixer.py | 14 +++-- mopidy/core/playback.py | 19 +++--- mopidy/core/tracklist.py | 25 ++++---- tests/core/test_actor.py | 28 +++++++++ tests/core/test_history.py | 59 +++++++++++++++++- tests/core/test_mixer.py | 37 ++++++++++++ tests/core/test_playback.py | 58 +++++++++++++++++- tests/core/test_tracklist.py | 112 ++++++++++++++++++++++++++++++++++- 10 files changed, 357 insertions(+), 60 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 35035f5b..e017a13b 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -138,18 +138,19 @@ class Core( 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 self._config and 'restore_state' in self._config['core']: + amount = self._config['core']['restore_state'] + 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: @@ -159,9 +160,10 @@ class Core( 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') + if self._config and 'restore_state' in self._config['core']: + 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) @@ -183,10 +185,10 @@ class Core( logger.info('Save state to "%s"', file_name) data = {} - self.tracklist._state_export(data) - self.history._state_export(data) - self.playback._state_export(data) - self.mixer._state_export(data) + data['tracklist'] = self.tracklist._export_state() + data['history'] = self.history._export_state() + data['playback'] = self.playback._export_state() + data['mixer'] = self.mixer._export_state() storage.save(file_name, data) def load_state(self, name, coverage): @@ -217,11 +219,15 @@ class Core( 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') + if 'history' in data: + self.history._restore_state(data['history'], coverage) + if 'tracklist' in data: + self.tracklist._restore_state(data['tracklist'], coverage) + if 'playback' in data: + self.playback._restore_state(data['playback'], coverage) + if 'mixer' in data: + self.mixer._restore_state(data['mixer'], coverage) + logger.debug('Load state done. file_name="%s"', file_name) class Backends(list): diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 0f6c20b7..7cd62131 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -58,19 +58,20 @@ class HistoryController(object): """ return copy.copy(self._history) - def _state_export(self, data): + def _export_state(self): """Internal method for :class:`mopidy.Core`.""" history_list = [] for timestamp, track in self._history: history_list.append(models.HistoryTrack( timestamp=timestamp, track=track)) - data['history'] = models.HistoryState(history=history_list) + return models.HistoryState(history=history_list) - def _state_import(self, data, coverage): + def _restore_state(self, state, coverage): """Internal method for :class:`mopidy.Core`.""" - if 'history' in data: - hstate = data['history'] + if state: + if not isinstance(state, models.HistoryState): + raise TypeError('Expect an argument of type "HistoryState"') if 'history' in coverage: self._history = [] - for htrack in hstate.history: + for htrack in state.history: self._history.append((htrack.timestamp, htrack.track)) diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 48b12758..92938bf1 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -101,13 +101,15 @@ class MixerController(object): return False - def _state_export(self, data): + def _export_state(self): """Internal method for :class:`mopidy.Core`.""" - data['mixer'] = MixerState(volume=self.get_volume()) + return MixerState(volume=self.get_volume()) - def _state_import(self, data, coverage): + def _restore_state(self, state, coverage): """Internal method for :class:`mopidy.Core`.""" - if 'mixer' in data: - ms = data['mixer'] + if state: + if not isinstance(state, MixerState): + raise TypeError('Expect an argument of type "MixerState"') if 'volume' in coverage: - self.set_volume(ms.volume) + if state.volume: + self.set_volume(state.volume) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 4a95f914..33f37802 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -524,20 +524,19 @@ class PlaybackController(object): logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) - def _state_export(self, data): + def _export_state(self): """Internal method for :class:`mopidy.Core`.""" - data['playback'] = models.PlaybackState( + return models.PlaybackState( tl_track=self.get_current_tl_track(), position=self.get_time_position(), state=self.get_state()) - def _state_import(self, data, coverage): + def _restore_state(self, state, coverage): """Internal method for :class:`mopidy.Core`.""" - if 'playback' in data: - ps = data['playback'] + if state: + if not isinstance(state, models.PlaybackState): + raise TypeError('Expect an argument of type "PlaybackState"') if 'autoplay' in coverage: - tl_track = ps.tl_track - if tl_track is not None: - self.play(tl_track=tl_track) - # TODO: Seek not working. It seeks to early. - # self.seek(ps.position) + if state.tl_track is not None: + self.play(tl_track=state.tl_track) + # TODO: seek to state.position? diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index ecc6bcdb..cc3b8bef 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -645,9 +645,9 @@ class TracklistController(object): logger.debug('Triggering options changed event') listener.CoreListener.send('options_changed') - def _state_export(self, data): + def _export_state(self): """Internal method for :class:`mopidy.Core`.""" - data['tracklist'] = TracklistState( + return TracklistState( tracks=self._tl_tracks, next_tlid=self._next_tlid, consume=self.get_consume(), @@ -655,19 +655,20 @@ class TracklistController(object): repeat=self.get_repeat(), single=self.get_single()) - def _state_import(self, data, coverage): + def _restore_state(self, state, coverage): """Internal method for :class:`mopidy.Core`.""" - if 'tracklist' in data: - tls = data['tracklist'] + if state: + if not isinstance(state, TracklistState): + raise TypeError('Expect an argument of type "TracklistState"') if 'mode' in coverage: - self.set_consume(tls.consume) - self.set_random(tls.random) - self.set_repeat(tls.repeat) - self.set_single(tls.single) + self.set_consume(state.consume) + self.set_random(state.random) + self.set_repeat(state.repeat) + self.set_single(state.single) if 'tracklist' in coverage: - if tls.next_tlid > self._next_tlid: - self._next_tlid = tls.next_tlid + if state.next_tlid > self._next_tlid: + self._next_tlid = state.next_tlid self._tl_tracks = [] - for track in tls.tracks: + for track in state.tracks: self._tl_tracks.append(track) self._trigger_tracklist_changed() diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 8f062fa2..fda24c4c 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, unicode_literals +import shutil +import tempfile import unittest import mock @@ -43,3 +45,29 @@ class CoreActorTest(unittest.TestCase): def test_version(self): self.assertEqual(self.core.version, versioning.get_version()) + + +class CoreActorExportRestoreTest(unittest.TestCase): + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + config = { + 'core': { + 'max_tracklist_length': 10000, + 'restore_state': 'play', + 'data_dir': self.temp_dir, + } + } + + self.core = Core.start( + config=config, mixer=None, backends=[]).proxy() + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + shutil.rmtree(self.temp_dir) + + def test_restore_on_start(self): + # cover mopidy.core.actor.on_start and .on_stop + # starting the actor by calling any function: + self.core.get_version() + pass diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 7f034cad..8c204270 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -4,7 +4,7 @@ import unittest from mopidy import compat from mopidy.core import HistoryController -from mopidy.models import Artist, Track +from mopidy.models import Artist, HistoryState, HistoryTrack, Ref, Track class PlaybackHistoryTest(unittest.TestCase): @@ -46,3 +46,60 @@ class PlaybackHistoryTest(unittest.TestCase): self.assertIn(track.name, ref.name) for artist in track.artists: self.assertIn(artist.name, ref.name) + + +class CoreHistoryExportRestoreTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.tracks = [ + Track(uri='dummy1:a', name='foober'), + Track(uri='dummy2:a', name='foo'), + Track(uri='dummy3:a', name='bar') + ] + + self.refs = [] + for t in self.tracks: + self.refs.append(Ref.track(uri=t.uri, name=t.name)) + + self.history = HistoryController() + + def test_export(self): + self.history._add_track(self.tracks[2]) + self.history._add_track(self.tracks[1]) + + value = self.history._export_state() + + self.assertEqual(len(value.history), 2) + # last in, first out + self.assertEqual(value.history[0].track, self.refs[1]) + self.assertEqual(value.history[1].track, self.refs[2]) + + def test_import(self): + state = HistoryState(history=[ + HistoryTrack(timestamp=34, track=self.refs[0]), + HistoryTrack(timestamp=45, track=self.refs[2]), + HistoryTrack(timestamp=56, track=self.refs[1])]) + coverage = ['history'] + self.history._restore_state(state, coverage) + + hist = self.history.get_history() + self.assertEqual(len(hist), 3) + self.assertEqual(hist[0], (34, self.refs[0])) + self.assertEqual(hist[1], (45, self.refs[2])) + self.assertEqual(hist[2], (56, self.refs[1])) + + # after import, adding more tracks must be possible + self.history._add_track(self.tracks[1]) + hist = self.history.get_history() + self.assertEqual(len(hist), 4) + self.assertEqual(hist[0][1], self.refs[1]) + self.assertEqual(hist[1], (34, self.refs[0])) + self.assertEqual(hist[2], (45, self.refs[2])) + self.assertEqual(hist[3], (56, self.refs[1])) + + def test_import_invalid_type(self): + with self.assertRaises(TypeError): + self.history._restore_state(11, None) + + def test_import_none(self): + self.history._restore_state(None, None) diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 45241fec..dbfdd656 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -7,6 +7,7 @@ import mock import pykka from mopidy import core, mixer +from mopidy.models import MixerState from tests import dummy_mixer @@ -154,3 +155,39 @@ class SetMuteBadBackendTest(MockBackendCoreMixerBase): def test_backend_returns_wrong_type(self): self.mixer.set_mute.return_value.get.return_value = 'done' self.assertFalse(self.core.mixer.set_mute(True)) + + +class CoreMixerExportRestoreTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.mixer = dummy_mixer.create_proxy() + self.core = core.Core(mixer=self.mixer, backends=[]) + + def test_export(self): + volume = 32 + target = MixerState(volume=volume) + self.core.mixer.set_volume(volume) + value = self.core.mixer._export_state() + self.assertEqual(target, value) + + def test_import(self): + self.core.mixer.set_volume(11) + volume = 45 + target = MixerState(volume=volume) + coverage = ['volume'] + self.core.mixer._restore_state(target, coverage) + self.assertEqual(volume, self.core.mixer.get_volume()) + + def test_import_not_covered(self): + self.core.mixer.set_volume(21) + target = MixerState(volume=56) + coverage = ['other'] + self.core.mixer._restore_state(target, coverage) + self.assertEqual(21, self.core.mixer.get_volume()) + + def test_import_invalid_type(self): + with self.assertRaises(TypeError): + self.core.mixer._restore_state(11, None) + + def test_import_none(self): + self.core.mixer._restore_state(None, None) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 0869b3ec..a9c9ce9e 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -8,7 +8,7 @@ import pykka from mopidy import backend, core from mopidy.internal import deprecation -from mopidy.models import Track +from mopidy.models import PlaybackState, Track from tests import dummy_audio @@ -874,3 +874,59 @@ class Bug1177RegressionTest(unittest.TestCase): c.playback.pause() c.playback.next() b.playback.change_track.assert_called_once_with(track2) + + +class CorePlaybackExportRestoreTest(BaseTest): + + def test_export(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[1]) + self.replay_events() + + state = PlaybackState( + position=0, state='playing', tl_track=tl_tracks[1]) + value = self.core.playback._export_state() + + self.assertEqual(state, value) + + def test_import(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.stop() + self.replay_events() + self.assertEqual('stopped', self.core.playback.get_state()) + + state = PlaybackState( + position=0, state='playing', tl_track=tl_tracks[2]) + coverage = ['autoplay'] + self.core.playback._restore_state(state, coverage) + self.replay_events() + + self.assertEqual('playing', self.core.playback.get_state()) + self.assertEqual(tl_tracks[2], + self.core.playback.get_current_tl_track()) + + def test_import_not_covered(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.stop() + self.replay_events() + self.assertEqual('stopped', self.core.playback.get_state()) + + state = PlaybackState( + position=0, state='playing', tl_track=tl_tracks[2]) + coverage = ['other'] + self.core.playback._restore_state(state, coverage) + self.replay_events() + + self.assertEqual('stopped', self.core.playback.get_state()) + self.assertEqual(None, + self.core.playback.get_current_tl_track()) + + def test_import_invalid_type(self): + with self.assertRaises(TypeError): + self.core.playback._restore_state(11, None) + + def test_import_none(self): + self.core.playback._restore_state(None, None) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 24edb2e7..59f78d01 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -6,7 +6,7 @@ import mock from mopidy import backend, core from mopidy.internal import deprecation -from mopidy.models import TlTrack, Track +from mopidy.models import TlTrack, Track, TracklistState class TracklistTest(unittest.TestCase): @@ -177,3 +177,113 @@ class TracklistIndexTest(unittest.TestCase): self.assertEqual(0, self.core.tracklist.index()) self.assertEqual(1, self.core.tracklist.index()) self.assertEqual(2, self.core.tracklist.index()) + + +class TracklistExportRestoreTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + config = { + 'core': { + 'max_tracklist_length': 10000, + } + } + + self.tracks = [ + Track(uri='dummy1:a', name='foo'), + Track(uri='dummy1:b', name='foo'), + Track(uri='dummy1:c', name='bar'), + ] + + self.tl_tracks = [ + TlTrack(tlid=4, track=Track(uri='first', name='First')), + TlTrack(tlid=5, track=Track(uri='second', name='Second')), + TlTrack(tlid=6, track=Track(uri='third', name='Third')), + TlTrack(tlid=8, track=Track(uri='last', name='Last')) + ] + + def lookup(uris): + return {u: [t for t in self.tracks if t.uri == u] for u in uris} + + self.core = core.Core(config, mixer=None, backends=[]) + self.core.library = mock.Mock(spec=core.LibraryController) + self.core.library.lookup.side_effect = lookup + + self.core.playback = mock.Mock(spec=core.PlaybackController) + + def test_export(self): + tl_tracks = self.core.tracklist.add(uris=[ + t.uri for t in self.tracks]) + consume = True + next_tlid = len(tl_tracks) + 1 + self.core.tracklist.set_consume(consume) + target = TracklistState(consume=consume, + repeat=False, + single=False, + random=False, + next_tlid=next_tlid, + tracks=tl_tracks) + value = self.core.tracklist._export_state() + self.assertEqual(target, value) + + def test_import(self): + target = TracklistState(consume=False, + repeat=True, + single=True, + random=False, + next_tlid=12, + tracks=self.tl_tracks) + coverage = ['mode', 'tracklist'] + self.core.tracklist._restore_state(target, coverage) + self.assertEqual(False, self.core.tracklist.get_consume()) + self.assertEqual(True, self.core.tracklist.get_repeat()) + self.assertEqual(True, self.core.tracklist.get_single()) + self.assertEqual(False, self.core.tracklist.get_random()) + self.assertEqual(12, self.core.tracklist._next_tlid) + self.assertEqual(4, self.core.tracklist.get_length()) + self.assertEqual(self.tl_tracks, self.core.tracklist.get_tl_tracks()) + + # after import, adding more tracks must be possible + self.core.tracklist.add(uris=[self.tracks[1].uri]) + self.assertEqual(13, self.core.tracklist._next_tlid) + self.assertEqual(5, self.core.tracklist.get_length()) + + def test_import_mode_only(self): + target = TracklistState(consume=False, + repeat=True, + single=True, + random=False, + next_tlid=12, + tracks=self.tl_tracks) + coverage = ['mode'] + self.core.tracklist._restore_state(target, coverage) + self.assertEqual(False, self.core.tracklist.get_consume()) + self.assertEqual(True, self.core.tracklist.get_repeat()) + self.assertEqual(True, self.core.tracklist.get_single()) + self.assertEqual(False, self.core.tracklist.get_random()) + self.assertEqual(1, self.core.tracklist._next_tlid) + self.assertEqual(0, self.core.tracklist.get_length()) + self.assertEqual([], self.core.tracklist.get_tl_tracks()) + + def test_import_tracklist_only(self): + target = TracklistState(consume=False, + repeat=True, + single=True, + random=False, + next_tlid=12, + tracks=self.tl_tracks) + coverage = ['tracklist'] + self.core.tracklist._restore_state(target, coverage) + self.assertEqual(False, self.core.tracklist.get_consume()) + self.assertEqual(False, self.core.tracklist.get_repeat()) + self.assertEqual(False, self.core.tracklist.get_single()) + self.assertEqual(False, self.core.tracklist.get_random()) + self.assertEqual(12, self.core.tracklist._next_tlid) + self.assertEqual(4, self.core.tracklist.get_length()) + self.assertEqual(self.tl_tracks, self.core.tracklist.get_tl_tracks()) + + def test_import_invalid_type(self): + with self.assertRaises(TypeError): + self.core.tracklist._restore_state(11, None) + + def test_import_none(self): + self.core.tracklist._restore_state(None, None) From 6746dd019679e9f8d50318672a761e56a6ee07a7 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Tue, 5 Jan 2016 07:41:02 +0100 Subject: [PATCH 005/118] More function for config value core.restore_state - New values for core.restore_state : "volume", "last" - Update changelog - Adjust logger output --- docs/changelog.rst | 3 +++ docs/config.rst | 5 ++++- mopidy/core/actor.py | 16 +++++++++++----- mopidy/core/playback.py | 9 +++++++-- mopidy/models/__init__.py | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4f49b8e5..db76c501 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -16,6 +16,9 @@ Core API - Start ``tlid`` counting at 1 instead of 0 to keep in sync with MPD's ``songid``. +- Persist state between runs. The amount of data to persist can be + controlled by config value :confval:`core/restore_state` + Local backend -------------- diff --git a/docs/config.rst b/docs/config.rst index 50d58e39..5b7a2c29 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -118,8 +118,11 @@ Core configuration Allowed values: - ``off``: restore nothing + - ``volume``: restore volume - ``load``: restore settings, volume and play queue - - ``play``: restore settings, volume, play queue and start playback + - ``last``: like ``load``, additional start playback if last state was + 'playing' + - ``play``: like ``load``, additional start playback Audio configuration ------------------- diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index e017a13b..cc229827 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -143,10 +143,15 @@ class Core( amount = self._config['core']['restore_state'] if not amount or 'off' == amount: pass + elif 'volume' == amount: + coverage = ['volume'] elif 'load' == amount: coverage = ['tracklist', 'mode', 'volume', 'history'] + elif 'last' == amount: + coverage = ['tracklist', 'mode', 'play-last', 'volume', + 'history'] elif 'play' == amount: - coverage = ['tracklist', 'mode', 'autoplay', 'volume', + coverage = ['tracklist', 'mode', 'play-always', 'volume', 'history'] else: logger.warn('Unknown value for config ' @@ -175,14 +180,13 @@ class Core( :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']['data_dir'], name) file_name += '.state' - logger.info('Save state to "%s"', file_name) + logger.info('Save state to %s', file_name) data = {} data['tracklist'] = self.tracklist._export_state() @@ -190,6 +194,7 @@ class Core( data['playback'] = self.playback._export_state() data['mixer'] = self.mixer._export_state() storage.save(file_name, data) + logger.debug('Save state done') def load_state(self, name, coverage): """ @@ -210,13 +215,13 @@ class Core( :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']['data_dir'], name) file_name += '.state' + logger.info('Load state from %s', file_name) data = storage.load(file_name) if 'history' in data: @@ -224,10 +229,11 @@ class Core( if 'tracklist' in data: self.tracklist._restore_state(data['tracklist'], coverage) if 'playback' in data: + # playback after tracklist self.playback._restore_state(data['playback'], coverage) if 'mixer' in data: self.mixer._restore_state(data['mixer'], coverage) - logger.debug('Load state done. file_name="%s"', file_name) + logger.debug('Load state done.') class Backends(list): diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 33f37802..5efbd382 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -536,7 +536,12 @@ class PlaybackController(object): if state: if not isinstance(state, models.PlaybackState): raise TypeError('Expect an argument of type "PlaybackState"') - if 'autoplay' in coverage: - if state.tl_track is not None: + new_state = '' + if 'play-always' in coverage: + new_state = PlaybackState.PLAYING + if 'play-last' in coverage: + new_state = state.state + if state.tl_track is not None: + if PlaybackState.PLAYING == new_state: self.play(tl_track=state.tl_track) # TODO: seek to state.position? diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index fc6e9be6..e919bbbf 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -417,7 +417,7 @@ class PlaybackState(ValidatedImmutableObject): :param position: play position :type position: int :param state: playback state - :type state: :class:`TlTrack` + :type state: :class:`validation.PLAYBACK_STATES` """ # The current playing track. Read-only. From d5a45516efe08f4a30e56a6bcae1ffbc48b4a603 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Tue, 5 Jan 2016 07:53:45 +0100 Subject: [PATCH 006/118] Adujst test code for testing auto-play --- tests/core/test_playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index a9c9ce9e..801c79f5 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -899,7 +899,7 @@ class CorePlaybackExportRestoreTest(BaseTest): state = PlaybackState( position=0, state='playing', tl_track=tl_tracks[2]) - coverage = ['autoplay'] + coverage = ['play-always'] self.core.playback._restore_state(state, coverage) self.replay_events() From 46bb780a4632967e78e329592e5c18dd20e55c43 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 9 Jan 2016 11:46:09 +0100 Subject: [PATCH 007/118] Rename TracklistState 'tracks' to 'tl_tracks' Correct documentation. --- mopidy/core/tracklist.py | 4 ++-- mopidy/models/__init__.py | 10 +++++++--- tests/core/test_tracklist.py | 8 ++++---- tests/models/test_models.py | 8 ++++---- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index cc3b8bef..50068d92 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -648,7 +648,7 @@ class TracklistController(object): def _export_state(self): """Internal method for :class:`mopidy.Core`.""" return TracklistState( - tracks=self._tl_tracks, + tl_tracks=self._tl_tracks, next_tlid=self._next_tlid, consume=self.get_consume(), random=self.get_random(), @@ -669,6 +669,6 @@ class TracklistController(object): if state.next_tlid > self._next_tlid: self._next_tlid = state.next_tlid self._tl_tracks = [] - for track in state.tracks: + for track in state.tl_tracks: self._tl_tracks.append(track) self._trigger_tracklist_changed() diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 5a980866..eb396e05 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -374,14 +374,14 @@ class HistoryTrack(ValidatedImmutableObject): :param timestamp: the timestamp :type timestamp: int - :param track: the track + :param track: the track reference :type track: :class:`Ref` """ # The timestamp. Read-only. timestamp = fields.Integer() - # The track. Read-only. + # The track reference. Read-only. track = fields.Field(type=Ref) @@ -448,6 +448,10 @@ class TracklistState(ValidatedImmutableObject): :type random: bool :param single: the single mode :type single: bool + :param next_tlid: the single mode + :type next_tlid: bool + :param tl_tracks: the single mode + :type tl_tracks: list of :class:`TlTrack` """ # The repeat mode. Read-only. @@ -466,4 +470,4 @@ class TracklistState(ValidatedImmutableObject): next_tlid = fields.Integer(min=0) # The list of tracks. Read-only. - tracks = fields.Collection(type=TlTrack, container=tuple) + tl_tracks = fields.Collection(type=TlTrack, container=tuple) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 59f78d01..40de840b 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -221,7 +221,7 @@ class TracklistExportRestoreTest(unittest.TestCase): single=False, random=False, next_tlid=next_tlid, - tracks=tl_tracks) + tl_tracks=tl_tracks) value = self.core.tracklist._export_state() self.assertEqual(target, value) @@ -231,7 +231,7 @@ class TracklistExportRestoreTest(unittest.TestCase): single=True, random=False, next_tlid=12, - tracks=self.tl_tracks) + tl_tracks=self.tl_tracks) coverage = ['mode', 'tracklist'] self.core.tracklist._restore_state(target, coverage) self.assertEqual(False, self.core.tracklist.get_consume()) @@ -253,7 +253,7 @@ class TracklistExportRestoreTest(unittest.TestCase): single=True, random=False, next_tlid=12, - tracks=self.tl_tracks) + tl_tracks=self.tl_tracks) coverage = ['mode'] self.core.tracklist._restore_state(target, coverage) self.assertEqual(False, self.core.tracklist.get_consume()) @@ -270,7 +270,7 @@ class TracklistExportRestoreTest(unittest.TestCase): single=True, random=False, next_tlid=12, - tracks=self.tl_tracks) + tl_tracks=self.tl_tracks) coverage = ['tracklist'] self.core.tracklist._restore_state(target, coverage) self.assertEqual(False, self.core.tracklist.get_consume()) diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 1bf2b1fb..0281fd65 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -1321,12 +1321,12 @@ class TracklistStateTest(unittest.TestCase): def test_tracks(self): tracks = (TlTrack(), TlTrack()) - result = TracklistState(tracks=tracks) - self.assertEqual(result.tracks, tracks) + result = TracklistState(tl_tracks=tracks) + self.assertEqual(result.tl_tracks, tracks) with self.assertRaises(AttributeError): - result.tracks = None + result.tl_tracks = None def test_tracks_invalid(self): tracks = (Track(), Track()) with self.assertRaises(TypeError): - TracklistState(tracks=tracks) + TracklistState(tl_tracks=tracks) From a9327c559f1c4b796759b076d283ae6c15a7bfb7 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 9 Jan 2016 12:00:35 +0100 Subject: [PATCH 008/118] Don't use pykka callbacks on_start and on_stop. Introduce setup() and teardown() for Core. --- mopidy/commands.py | 11 ++++++++--- mopidy/core/actor.py | 30 +++++++++++++----------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/mopidy/commands.py b/mopidy/commands.py index 4890c722..e5adbc09 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -291,6 +291,7 @@ class RootCommand(Command): mixer_class = self.get_mixer_class(config, args.registry['mixer']) backend_classes = args.registry['backend'] frontend_classes = args.registry['frontend'] + core = None exit_status_code = 0 try: @@ -316,7 +317,7 @@ class RootCommand(Command): finally: loop.quit() self.stop_frontends(frontend_classes) - self.stop_core() + self.stop_core(core) self.stop_backends(backend_classes) self.stop_audio() if mixer_class is not None: @@ -392,8 +393,10 @@ class RootCommand(Command): def start_core(self, config, mixer, backends, audio): logger.info('Starting Mopidy core') - return Core.start( + core = Core.start( config=config, mixer=mixer, backends=backends, audio=audio).proxy() + core.setup().get() + return core def start_frontends(self, config, frontend_classes, core): logger.info( @@ -410,8 +413,10 @@ class RootCommand(Command): for frontend_class in frontend_classes: process.stop_actors_by_class(frontend_class) - def stop_core(self): + def stop_core(self, core): logger.info('Stopping Mopidy core') + if core: + core.teardown().get() process.stop_actors_by_class(Core) def stop_backends(self, backend_classes): diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 817beb0f..0c877477 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -138,43 +138,39 @@ class Core( self.playback._stream_title = title CoreListener.send('stream_title_changed', title=title) - def on_start(self): - logger.debug("core on_start") + def setup(self): try: coverage = [] if self._config and 'restore_state' in self._config['core']: - amount = self._config['core']['restore_state'] - if not amount or 'off' == amount: + value = self._config['core']['restore_state'] + if not value or 'off' == value: pass - elif 'volume' == amount: + elif 'volume' == value: coverage = ['volume'] - elif 'load' == amount: + elif 'load' == value: coverage = ['tracklist', 'mode', 'volume', 'history'] - elif 'last' == amount: + elif 'last' == value: coverage = ['tracklist', 'mode', 'play-last', 'volume', 'history'] - elif 'play' == amount: + elif 'play' == value: coverage = ['tracklist', 'mode', 'play-always', 'volume', 'history'] else: logger.warn('Unknown value for config ' - 'core.restore_state: %s', amount) + 'core.restore_state: %s', value) if len(coverage): self.load_state('persistent', coverage) except Exception as e: - logger.warn('Unexpected error: %s', str(e)) - pykka.ThreadingActor.on_start(self) + logger.warn('setup: Unexpected error: %s', str(e)) - def on_stop(self): - logger.debug("core on_stop") + def teardown(self): try: if self._config and 'restore_state' in self._config['core']: 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) + logger.warn('teardown: Unexpected error: %s', str(e)) def save_state(self, name): """ @@ -184,7 +180,7 @@ class Core( :type name: str """ if not name: - raise TypeError('missing file name') + raise TypeError('Missing file name.') file_name = os.path.join( self._config['core']['data_dir'], name) @@ -219,7 +215,7 @@ class Core( :type coverage: list of string (see above) """ if not name: - raise TypeError('missing file name') + raise TypeError('Missing file name.') file_name = os.path.join( self._config['core']['data_dir'], name) From 6e99a95aae41bb2e816ef00a977b89f93003b894 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 9 Jan 2016 12:05:14 +0100 Subject: [PATCH 009/118] Don't modify data in library function. - storage.save: Don't modify data. mopidy.__version__ has to be added by caller. - storage.load: Added a Todo. Postponed decision, if load() shall raise an exception in case of error. See PR #310. --- mopidy/core/actor.py | 3 +++ mopidy/local/json.py | 6 +++++- mopidy/models/storage.py | 3 +-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 0c877477..750b965b 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -7,6 +7,8 @@ import os import pykka +import mopidy + from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState from mopidy.core.history import HistoryController @@ -188,6 +190,7 @@ class Core( logger.info('Save state to %s', file_name) data = {} + data['version'] = mopidy.__version__ data['tracklist'] = self.tracklist._export_state() data['history'] = self.history._export_state() data['playback'] = self.playback._export_state() diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 96c96e49..de40e15b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -6,6 +6,8 @@ import os import re import sys +import mopidy + from mopidy import compat, local, models from mopidy.internal import timer from mopidy.local import search, storage, translator @@ -164,7 +166,9 @@ class JsonLibrary(local.Library): self._tracks.pop(uri, None) def close(self): - models.storage.save(self._json_file, {'tracks': self._tracks.values()}) + models.storage.save(self._json_file, + {'version': mopidy.__version__, + 'tracks': self._tracks.values()}) def clear(self): try: diff --git a/mopidy/models/storage.py b/mopidy/models/storage.py index 20fc490f..faa53d57 100644 --- a/mopidy/models/storage.py +++ b/mopidy/models/storage.py @@ -6,7 +6,6 @@ import logging import os import tempfile -import mopidy from mopidy import models from mopidy.internal import encoding @@ -22,6 +21,7 @@ def load(path): :return: deserialized data :rtype: dict """ + # Todo: raise an exception in case of error? if not os.path.isfile(path): logger.info('File does not exist: %s.', path) return {} @@ -44,7 +44,6 @@ def save(path, data): :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. From abe3d67bc11e1b28d13324c0645d3c3a8a3d86d8 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 9 Jan 2016 12:07:49 +0100 Subject: [PATCH 010/118] Some smaller fixes. - Limit config core.restore_state to a known set of values. - Initialize new_state to None instead of '' --- mopidy/config/__init__.py | 3 ++- mopidy/core/playback.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index e89f0eb9..7d5b300b 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -21,7 +21,8 @@ _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) +_core_schema['restore_state'] = String( + optional=True, choices=['off', 'volume', 'load', 'last', 'play']) _logging_schema = ConfigSchema('logging') _logging_schema['color'] = Boolean() diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 1779ed77..f06fb64c 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -554,7 +554,7 @@ class PlaybackController(object): if state: if not isinstance(state, models.PlaybackState): raise TypeError('Expect an argument of type "PlaybackState"') - new_state = '' + new_state = None if 'play-always' in coverage: new_state = PlaybackState.PLAYING if 'play-last' in coverage: From 74344f2b19d079ae0d22160e43a31e39cca20989 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 9 Jan 2016 12:52:01 +0100 Subject: [PATCH 011/118] Use tlid instead of full tl_track To export/restore the PlayState the tlid is enough. --- mopidy/core/playback.py | 6 +++--- mopidy/models/__init__.py | 8 ++++---- tests/core/test_playback.py | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index f06fb64c..3a72e2e6 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -545,7 +545,7 @@ class PlaybackController(object): def _export_state(self): """Internal method for :class:`mopidy.Core`.""" return models.PlaybackState( - tl_track=self.get_current_tl_track(), + tlid=self.get_current_tlid(), position=self.get_time_position(), state=self.get_state()) @@ -559,7 +559,7 @@ class PlaybackController(object): new_state = PlaybackState.PLAYING if 'play-last' in coverage: new_state = state.state - if state.tl_track is not None: + if state.tlid is not None: if PlaybackState.PLAYING == new_state: - self.play(tl_track=state.tl_track) + self.play(tlid=state.tlid) # TODO: seek to state.position? diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index eb396e05..6df3e550 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -416,16 +416,16 @@ class PlaybackState(ValidatedImmutableObject): State of the playback controller. Internally used for import/export of current state. - :param tl_track: current track - :type tl_track: :class:`TlTrack` + :param tlid: current track tlid + :type tlid: int :param position: play position :type position: int :param state: playback state :type state: :class:`validation.PLAYBACK_STATES` """ - # The current playing track. Read-only. - tl_track = fields.Field(type=TlTrack) + # The tlid of current playing track. Read-only. + tlid = fields.Integer(min=1) # The playback position. Read-only. position = fields.Integer(min=0) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 128ec3ce..a6af330b 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -948,7 +948,7 @@ class CorePlaybackExportRestoreTest(BaseTest): self.replay_events() state = PlaybackState( - position=0, state='playing', tl_track=tl_tracks[1]) + position=0, state='playing', tlid=tl_tracks[1].tlid) value = self.core.playback._export_state() self.assertEqual(state, value) @@ -961,7 +961,7 @@ class CorePlaybackExportRestoreTest(BaseTest): self.assertEqual('stopped', self.core.playback.get_state()) state = PlaybackState( - position=0, state='playing', tl_track=tl_tracks[2]) + position=0, state='playing', tlid=tl_tracks[2].tlid) coverage = ['play-always'] self.core.playback._restore_state(state, coverage) self.replay_events() @@ -978,7 +978,7 @@ class CorePlaybackExportRestoreTest(BaseTest): self.assertEqual('stopped', self.core.playback.get_state()) state = PlaybackState( - position=0, state='playing', tl_track=tl_tracks[2]) + position=0, state='playing', tlid=tl_tracks[2].tlid) coverage = ['other'] self.core.playback._restore_state(state, coverage) self.replay_events() From 4869619bb98abf021b34b2a8b758687adf3e2781 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sun, 10 Jan 2016 13:24:14 +0100 Subject: [PATCH 012/118] New CoreState to hold all core states - Introduce a CoreState class that holds all core states - Move xState classes to internal - Use validation.check_instance for consistent error messages - Store tlid instead of TlTrack to restore last played track --- mopidy/core/actor.py | 34 ++--- mopidy/core/history.py | 8 +- mopidy/core/mixer.py | 4 +- mopidy/core/playback.py | 6 +- mopidy/core/tracklist.py | 9 +- mopidy/internal/models.py | 142 ++++++++++++++++++ mopidy/{models => internal}/storage.py | 0 mopidy/local/json.py | 9 +- mopidy/models/__init__.py | 109 +------------- mopidy/models/serialize.py | 7 +- tests/core/test_history.py | 3 +- tests/core/test_mixer.py | 2 +- tests/core/test_playback.py | 3 +- tests/core/test_tracklist.py | 3 +- tests/internal/test_models.py | 200 +++++++++++++++++++++++++ tests/models/test_models.py | 166 +------------------- 16 files changed, 390 insertions(+), 315 deletions(-) create mode 100644 mopidy/internal/models.py rename mopidy/{models => internal}/storage.py (100%) create mode 100644 tests/internal/test_models.py diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 750b965b..8b9010ab 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -18,9 +18,9 @@ from mopidy.core.mixer import MixerController from mopidy.core.playback import PlaybackController from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController -from mopidy.internal import versioning +from mopidy.internal import storage, validation, versioning from mopidy.internal.deprecation import deprecated_property -from mopidy.models import storage +from mopidy.internal.models import CoreState logger = logging.getLogger(__name__) @@ -163,7 +163,7 @@ class Core( if len(coverage): self.load_state('persistent', coverage) except Exception as e: - logger.warn('setup: Unexpected error: %s', str(e)) + logger.warn('Restore state: Unexpected error: %s', str(e)) def teardown(self): try: @@ -172,7 +172,7 @@ class Core( if amount and 'off' != amount: self.save_state('persistent') except Exception as e: - logger.warn('teardown: Unexpected error: %s', str(e)) + logger.warn('Export state: Unexpected error: %s', str(e)) def save_state(self, name): """ @@ -191,12 +191,13 @@ class Core( data = {} data['version'] = mopidy.__version__ - data['tracklist'] = self.tracklist._export_state() - data['history'] = self.history._export_state() - data['playback'] = self.playback._export_state() - data['mixer'] = self.mixer._export_state() + data['state'] = CoreState( + tracklist=self.tracklist._export_state(), + history=self.history._export_state(), + playback=self.playback._export_state(), + mixer=self.mixer._export_state()) storage.save(file_name, data) - logger.debug('Save state done') + logger.debug('Save state done.') def load_state(self, name, coverage): """ @@ -226,15 +227,14 @@ class Core( logger.info('Load state from %s', file_name) data = storage.load(file_name) - if 'history' in data: - self.history._restore_state(data['history'], coverage) - if 'tracklist' in data: - self.tracklist._restore_state(data['tracklist'], coverage) - if 'playback' in data: + if 'state' in data: + core_state = data['state'] + validation.check_instance(core_state, CoreState) + self.history._restore_state(core_state.history, coverage) + self.tracklist._restore_state(core_state.tracklist, coverage) # playback after tracklist - self.playback._restore_state(data['playback'], coverage) - if 'mixer' in data: - self.mixer._restore_state(data['mixer'], coverage) + self.playback._restore_state(core_state.playback, coverage) + self.mixer._restore_state(core_state.mixer, coverage) logger.debug('Load state done.') diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 7cd62131..3c0a2446 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -5,7 +5,7 @@ import logging import time from mopidy import models - +from mopidy.internal.models import HistoryState, HistoryTrack logger = logging.getLogger(__name__) @@ -62,15 +62,13 @@ class HistoryController(object): """Internal method for :class:`mopidy.Core`.""" history_list = [] for timestamp, track in self._history: - history_list.append(models.HistoryTrack( + history_list.append(HistoryTrack( timestamp=timestamp, track=track)) - return models.HistoryState(history=history_list) + return HistoryState(history=history_list) def _restore_state(self, state, coverage): """Internal method for :class:`mopidy.Core`.""" if state: - if not isinstance(state, models.HistoryState): - raise TypeError('Expect an argument of type "HistoryState"') if 'history' in coverage: self._history = [] for htrack in state.history: diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 92938bf1..cceb0ebe 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -5,7 +5,7 @@ import logging from mopidy import exceptions from mopidy.internal import validation -from mopidy.models import MixerState +from mopidy.internal.models import MixerState logger = logging.getLogger(__name__) @@ -108,8 +108,6 @@ class MixerController(object): def _restore_state(self, state, coverage): """Internal method for :class:`mopidy.Core`.""" if state: - if not isinstance(state, MixerState): - raise TypeError('Expect an argument of type "MixerState"') if 'volume' in coverage: if state.volume: self.set_volume(state.volume) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 3a72e2e6..54d30230 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -2,11 +2,10 @@ 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 +from mopidy.internal import deprecation, models, validation logger = logging.getLogger(__name__) @@ -552,14 +551,13 @@ class PlaybackController(object): def _restore_state(self, state, coverage): """Internal method for :class:`mopidy.Core`.""" if state: - if not isinstance(state, models.PlaybackState): - raise TypeError('Expect an argument of type "PlaybackState"') new_state = None if 'play-always' in coverage: new_state = PlaybackState.PLAYING if 'play-last' in coverage: new_state = state.state if state.tlid is not None: + # TODO: restore to 'paused' state if PlaybackState.PLAYING == new_state: self.play(tlid=state.tlid) # TODO: seek to state.position? diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 50068d92..f99f3917 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -6,7 +6,8 @@ import random from mopidy import exceptions from mopidy.core import listener from mopidy.internal import deprecation, validation -from mopidy.models import TlTrack, Track, TracklistState +from mopidy.internal.models import TracklistState +from mopidy.models import TlTrack, Track logger = logging.getLogger(__name__) @@ -658,8 +659,6 @@ class TracklistController(object): def _restore_state(self, state, coverage): """Internal method for :class:`mopidy.Core`.""" if state: - if not isinstance(state, TracklistState): - raise TypeError('Expect an argument of type "TracklistState"') if 'mode' in coverage: self.set_consume(state.consume) self.set_random(state.random) @@ -670,5 +669,9 @@ class TracklistController(object): self._next_tlid = state.next_tlid self._tl_tracks = [] for track in state.tl_tracks: + # TODO: check if any backend will play the track. + # Could be an issue with music streaming services + # (login), disabled extensions and automatically + # generated playlists (pandora). self._tl_tracks.append(track) self._trigger_tracklist_changed() diff --git a/mopidy/internal/models.py b/mopidy/internal/models.py new file mode 100644 index 00000000..5214cc65 --- /dev/null +++ b/mopidy/internal/models.py @@ -0,0 +1,142 @@ +from __future__ import absolute_import, unicode_literals + +from mopidy.internal import validation +from mopidy.models import Ref, TlTrack, fields +from mopidy.models.immutable import ValidatedImmutableObject + +_MODELS = ['HistoryTrack', 'HistoryState', 'MixerState', 'PlaybackState', + 'TracklistState', 'CoreState'] + + +class HistoryTrack(ValidatedImmutableObject): + """ + A history track. Wraps a :class:`Ref` and it's timestamp. + + :param timestamp: the timestamp + :type timestamp: int + :param track: the track reference + :type track: :class:`Ref` + """ + + # The timestamp. Read-only. + timestamp = fields.Integer() + + # The track reference. Read-only. + track = fields.Field(type=Ref) + + +class HistoryState(ValidatedImmutableObject): + """ + State of the history controller. + Internally used for import/export of current state. + + :param history: the track history + :type history: list of :class:`HistoryTrack` + """ + + # The tracks. Read-only. + history = fields.Collection(type=HistoryTrack, container=tuple) + + +class MixerState(ValidatedImmutableObject): + """ + State of the mixer controller. + Internally used for import/export of current state. + + :param volume: the volume + :type volume: int + """ + + # The volume. Read-only. + volume = fields.Integer(min=0, max=100) + + +class PlaybackState(ValidatedImmutableObject): + """ + State of the playback controller. + Internally used for import/export of current state. + + :param tlid: current track tlid + :type tlid: int + :param position: play position + :type position: int + :param state: playback state + :type state: :class:`validation.PLAYBACK_STATES` + """ + + # The tlid of current playing track. Read-only. + tlid = fields.Integer(min=1) + + # The playback position. Read-only. + position = fields.Integer(min=0) + + # The playback state. Read-only. + state = fields.Field(choices=validation.PLAYBACK_STATES) + + +class TracklistState(ValidatedImmutableObject): + + """ + State of the tracklist controller. + Internally used for import/export of current state. + + :param repeat: the repeat mode + :type repeat: bool + :param consume: the consume mode + :type consume: bool + :param random: the random mode + :type random: bool + :param single: the single mode + :type single: bool + :param next_tlid: the single mode + :type next_tlid: bool + :param tl_tracks: the single mode + :type tl_tracks: list of :class:`TlTrack` + """ + + # The repeat mode. Read-only. + repeat = fields.Boolean() + + # The consume mode. Read-only. + consume = fields.Boolean() + + # The random mode. Read-only. + random = fields.Boolean() + + # The single mode. Read-only. + single = fields.Boolean() + + # The repeat mode. Read-only. + next_tlid = fields.Integer(min=0) + + # The list of tracks. Read-only. + tl_tracks = fields.Collection(type=TlTrack, container=tuple) + + +class CoreState(ValidatedImmutableObject): + + """ + State of all Core controller. + Internally used for import/export of current state. + + :param history: State of the history controller + :type history: :class:`HistorState` + :param mixer: State of the mixer controller + :type mixer: :class:`MixerState` + :param playback: State of the playback controller + :type playback: :class:`PlaybackState` + :param tracklist: State of the tracklist controller + :type tracklist: :class:`TracklistState` + """ + + # State of the history controller. + history = fields.Field(type=HistoryState) + + # State of the mixer controller. + mixer = fields.Field(type=MixerState) + + # State of the playback controller. + playback = fields.Field(type=PlaybackState) + + # State of the tracklist controller. + tracklist = fields.Field(type=TracklistState) diff --git a/mopidy/models/storage.py b/mopidy/internal/storage.py similarity index 100% rename from mopidy/models/storage.py rename to mopidy/internal/storage.py diff --git a/mopidy/local/json.py b/mopidy/local/json.py index de40e15b..378cab75 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -9,6 +9,7 @@ import sys import mopidy from mopidy import compat, local, models +from mopidy import internal from mopidy.internal import timer from mopidy.local import search, storage, translator @@ -98,7 +99,7 @@ class JsonLibrary(local.Library): self._json_file) self._tracks = {} else: - library = models.storage.load(self._json_file) + library = internal.storage.load(self._json_file) self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) with timer.time_logger('Building browse cache'): @@ -166,9 +167,9 @@ class JsonLibrary(local.Library): self._tracks.pop(uri, None) def close(self): - models.storage.save(self._json_file, - {'version': mopidy.__version__, - 'tracks': self._tracks.values()}) + internal.storage.save(self._json_file, + {'version': mopidy.__version__, + 'tracks': self._tracks.values()}) def clear(self): try: diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 6df3e550..1e63d02f 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -1,7 +1,6 @@ from __future__ import absolute_import, unicode_literals from mopidy import compat -from mopidy.internal import validation from mopidy.models import fields from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder @@ -9,8 +8,7 @@ from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder __all__ = [ 'ImmutableObject', 'Ref', 'Image', 'Artist', 'Album', 'track', 'TlTrack', 'Playlist', 'SearchResult', 'model_json_decoder', 'ModelJSONEncoder', - 'ValidatedImmutableObject', 'HistoryTrack', 'HistoryState', 'MixerState', - 'PlaybackState', 'TracklistState'] + 'ValidatedImmutableObject'] class Ref(ValidatedImmutableObject): @@ -366,108 +364,3 @@ class SearchResult(ValidatedImmutableObject): # The albums matching the search query. Read-only. albums = fields.Collection(type=Album, container=tuple) - - -class HistoryTrack(ValidatedImmutableObject): - """ - A history track. Wraps a :class:`Ref` and it's timestamp. - - :param timestamp: the timestamp - :type timestamp: int - :param track: the track reference - :type track: :class:`Ref` - """ - - # The timestamp. Read-only. - timestamp = fields.Integer() - - # The track reference. Read-only. - track = fields.Field(type=Ref) - - -class HistoryState(ValidatedImmutableObject): - """ - State of the history controller. - Internally used for import/export of current state. - - :param history: the track history - :type history: list of :class:`HistoryTrack` - """ - - # The tracks. Read-only. - history = fields.Collection(type=HistoryTrack, container=tuple) - - -class MixerState(ValidatedImmutableObject): - """ - State of the mixer controller. - Internally used for import/export of current state. - - :param volume: the volume - :type volume: int - """ - - # The volume. Read-only. - volume = fields.Integer(min=0, max=100) - - -class PlaybackState(ValidatedImmutableObject): - """ - State of the playback controller. - Internally used for import/export of current state. - - :param tlid: current track tlid - :type tlid: int - :param position: play position - :type position: int - :param state: playback state - :type state: :class:`validation.PLAYBACK_STATES` - """ - - # The tlid of current playing track. Read-only. - tlid = fields.Integer(min=1) - - # The playback position. Read-only. - position = fields.Integer(min=0) - - # The playback state. Read-only. - state = fields.Field(choices=validation.PLAYBACK_STATES) - - -class TracklistState(ValidatedImmutableObject): - - """ - State of the tracklist controller. - Internally used for import/export of current state. - - :param repeat: the repeat mode - :type repeat: bool - :param consume: the consume mode - :type consume: bool - :param random: the random mode - :type random: bool - :param single: the single mode - :type single: bool - :param next_tlid: the single mode - :type next_tlid: bool - :param tl_tracks: the single mode - :type tl_tracks: list of :class:`TlTrack` - """ - - # The repeat mode. Read-only. - repeat = fields.Boolean() - - # The consume mode. Read-only. - consume = fields.Boolean() - - # The random mode. Read-only. - random = fields.Boolean() - - # The single mode. Read-only. - single = fields.Boolean() - - # The repeat mode. Read-only. - next_tlid = fields.Integer(min=0) - - # The list of tracks. Read-only. - tl_tracks = fields.Collection(type=TlTrack, container=tuple) diff --git a/mopidy/models/serialize.py b/mopidy/models/serialize.py index 08162db4..68d170c4 100644 --- a/mopidy/models/serialize.py +++ b/mopidy/models/serialize.py @@ -4,9 +4,7 @@ import json from mopidy.models import immutable -_MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist', - 'HistoryTrack', 'HistoryState', 'MixerState', 'PlaybackState', - 'TracklistState'] +_MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist'] class ModelJSONEncoder(json.JSONEncoder): @@ -46,4 +44,7 @@ def model_json_decoder(dct): model_name = dct.pop('__model__') if model_name in _MODELS: return getattr(models, model_name)(**dct) + from mopidy import internal + if model_name in internal.models._MODELS: + return getattr(internal.models, model_name)(**dct) return dct diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 8c204270..65babde8 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -4,7 +4,8 @@ import unittest from mopidy import compat from mopidy.core import HistoryController -from mopidy.models import Artist, HistoryState, HistoryTrack, Ref, Track +from mopidy.internal.models import HistoryState, HistoryTrack +from mopidy.models import Artist, Ref, Track class PlaybackHistoryTest(unittest.TestCase): diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index dbfdd656..0b7b789b 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -7,7 +7,7 @@ import mock import pykka from mopidy import core, mixer -from mopidy.models import MixerState +from mopidy.internal.models import MixerState from tests import dummy_mixer diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index a6af330b..6d66bca6 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -8,7 +8,8 @@ import pykka from mopidy import backend, core from mopidy.internal import deprecation -from mopidy.models import PlaybackState, Track +from mopidy.internal.models import PlaybackState +from mopidy.models import Track from tests import dummy_audio diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 40de840b..7b26f3d7 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -6,7 +6,8 @@ import mock from mopidy import backend, core from mopidy.internal import deprecation -from mopidy.models import TlTrack, Track, TracklistState +from mopidy.internal.models import TracklistState +from mopidy.models import TlTrack, Track class TracklistTest(unittest.TestCase): diff --git a/tests/internal/test_models.py b/tests/internal/test_models.py new file mode 100644 index 00000000..9c181bdd --- /dev/null +++ b/tests/internal/test_models.py @@ -0,0 +1,200 @@ +from __future__ import absolute_import, unicode_literals + +import json +import unittest + +from mopidy.internal.models import ( + HistoryState, HistoryTrack, MixerState, PlaybackState, TracklistState) +from mopidy.models import ( + ModelJSONEncoder, Ref, TlTrack, Track, model_json_decoder) + + +class HistoryTrackTest(unittest.TestCase): + + def test_track(self): + track = Ref.track() + result = HistoryTrack(track=track) + self.assertEqual(result.track, track) + with self.assertRaises(AttributeError): + result.track = None + + def test_timestamp(self): + timestamp = 1234 + result = HistoryTrack(timestamp=timestamp) + self.assertEqual(result.timestamp, timestamp) + with self.assertRaises(AttributeError): + result.timestamp = None + + def test_to_json_and_back(self): + result = HistoryTrack(track=Ref.track(), timestamp=1234) + serialized = json.dumps(result, cls=ModelJSONEncoder) + deserialized = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(result, deserialized) + + +class HistoryStateTest(unittest.TestCase): + + def test_history_list(self): + history = (HistoryTrack(), + HistoryTrack()) + result = HistoryState(history=history) + self.assertEqual(result.history, history) + with self.assertRaises(AttributeError): + result.history = None + + def test_history_string_fail(self): + history = 'not_a_valid_history' + with self.assertRaises(TypeError): + HistoryState(history=history) + + def test_to_json_and_back(self): + result = HistoryState(history=(HistoryTrack(), HistoryTrack())) + serialized = json.dumps(result, cls=ModelJSONEncoder) + deserialized = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(result, deserialized) + + +class MixerStateTest(unittest.TestCase): + + def test_volume(self): + volume = 37 + result = MixerState(volume=volume) + self.assertEqual(result.volume, volume) + with self.assertRaises(AttributeError): + result.volume = None + + def test_volume_invalid(self): + volume = 105 + with self.assertRaises(ValueError): + MixerState(volume=volume) + + def test_to_json_and_back(self): + result = MixerState(volume=77) + serialized = json.dumps(result, cls=ModelJSONEncoder) + deserialized = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(result, deserialized) + + +class PlaybackStateTest(unittest.TestCase): + + def test_position(self): + position = 123456 + result = PlaybackState(position=position) + self.assertEqual(result.position, position) + with self.assertRaises(AttributeError): + result.position = None + + def test_position_invalid(self): + position = -1 + with self.assertRaises(ValueError): + PlaybackState(position=position) + + def test_tl_track(self): + tlid = 42 + result = PlaybackState(tlid=tlid) + self.assertEqual(result.tlid, tlid) + with self.assertRaises(AttributeError): + result.tlid = None + + def test_tl_track_none(self): + tlid = None + result = PlaybackState(tlid=tlid) + self.assertEqual(result.tlid, tlid) + with self.assertRaises(AttributeError): + result.tl_track = None + + def test_tl_track_invalid(self): + tl_track = Track() + with self.assertRaises(TypeError): + PlaybackState(tlid=tl_track) + + def test_state(self): + state = 'playing' + result = PlaybackState(state=state) + self.assertEqual(result.state, state) + with self.assertRaises(AttributeError): + result.state = None + + def test_state_invalid(self): + state = 'not_a_state' + with self.assertRaises(TypeError): + PlaybackState(state=state) + + def test_to_json_and_back(self): + result = PlaybackState(state='playing', tlid=4321) + serialized = json.dumps(result, cls=ModelJSONEncoder) + deserialized = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(result, deserialized) + + +class TracklistStateTest(unittest.TestCase): + + def test_repeat_true(self): + repeat = True + result = TracklistState(repeat=repeat) + self.assertEqual(result.repeat, repeat) + with self.assertRaises(AttributeError): + result.repeat = None + + def test_repeat_false(self): + repeat = False + result = TracklistState(repeat=repeat) + self.assertEqual(result.repeat, repeat) + with self.assertRaises(AttributeError): + result.repeat = None + + def test_repeat_invalid(self): + repeat = 33 + with self.assertRaises(TypeError): + TracklistState(repeat=repeat) + + def test_consume_true(self): + val = True + result = TracklistState(consume=val) + self.assertEqual(result.consume, val) + with self.assertRaises(AttributeError): + result.repeat = None + + def test_random_true(self): + val = True + result = TracklistState(random=val) + self.assertEqual(result.random, val) + with self.assertRaises(AttributeError): + result.random = None + + def test_single_true(self): + val = True + result = TracklistState(single=val) + self.assertEqual(result.single, val) + with self.assertRaises(AttributeError): + result.single = None + + def test_next_tlid(self): + val = 654 + result = TracklistState(next_tlid=val) + self.assertEqual(result.next_tlid, val) + with self.assertRaises(AttributeError): + result.next_tlid = None + + def test_next_tlid_invalid(self): + val = -1 + with self.assertRaises(ValueError): + TracklistState(next_tlid=val) + + def test_tracks(self): + tracks = (TlTrack(), TlTrack()) + result = TracklistState(tl_tracks=tracks) + self.assertEqual(result.tl_tracks, tracks) + with self.assertRaises(AttributeError): + result.tl_tracks = None + + def test_tracks_invalid(self): + tracks = (Track(), Track()) + with self.assertRaises(TypeError): + TracklistState(tl_tracks=tracks) + + def test_to_json_and_back(self): + result = TracklistState(tl_tracks=(TlTrack(), TlTrack()), next_tlid=4) + serialized = json.dumps(result, cls=ModelJSONEncoder) + deserialized = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(result, deserialized) diff --git a/tests/models/test_models.py b/tests/models/test_models.py index 0281fd65..35e77aef 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -4,9 +4,8 @@ import json import unittest from mopidy.models import ( - Album, Artist, HistoryState, HistoryTrack, Image, MixerState, - ModelJSONEncoder, PlaybackState, Playlist, - Ref, SearchResult, TlTrack, Track, TracklistState, model_json_decoder) + Album, Artist, Image, ModelJSONEncoder, Playlist, + Ref, SearchResult, TlTrack, Track, model_json_decoder) class InheritanceTest(unittest.TestCase): @@ -1169,164 +1168,3 @@ class SearchResultTest(unittest.TestCase): self.assertDictEqual( {'__model__': 'SearchResult', 'uri': 'uri'}, SearchResult(uri='uri').serialize()) - - -class HistoryTrackTest(unittest.TestCase): - - def test_track(self): - track = Ref.track() - result = HistoryTrack(track=track) - self.assertEqual(result.track, track) - with self.assertRaises(AttributeError): - result.track = None - - def test_timestamp(self): - timestamp = 1234 - result = HistoryTrack(timestamp=timestamp) - self.assertEqual(result.timestamp, timestamp) - with self.assertRaises(AttributeError): - result.timestamp = None - - -class HistoryStateTest(unittest.TestCase): - - def test_history_list(self): - history = (HistoryTrack(), - HistoryTrack()) - result = HistoryState(history=history) - self.assertEqual(result.history, history) - with self.assertRaises(AttributeError): - result.history = None - - def test_history_string_fail(self): - history = 'not_a_valid_history' - with self.assertRaises(TypeError): - HistoryState(history=history) - - -class MixerStateTest(unittest.TestCase): - - def test_volume(self): - volume = 37 - result = MixerState(volume=volume) - self.assertEqual(result.volume, volume) - with self.assertRaises(AttributeError): - result.volume = None - - def test_volume_invalid(self): - volume = 105 - with self.assertRaises(ValueError): - MixerState(volume=volume) - - -class PlaybackStateTest(unittest.TestCase): - - def test_position(self): - position = 123456 - result = PlaybackState(position=position) - self.assertEqual(result.position, position) - with self.assertRaises(AttributeError): - result.position = None - - def test_position_invalid(self): - position = -1 - with self.assertRaises(ValueError): - PlaybackState(position=position) - - def test_tl_track(self): - tl_track = TlTrack() - result = PlaybackState(tl_track=tl_track) - self.assertEqual(result.tl_track, tl_track) - with self.assertRaises(AttributeError): - result.tl_track = None - - def test_tl_track_none(self): - tl_track = None - result = PlaybackState(tl_track=tl_track) - self.assertEqual(result.tl_track, tl_track) - with self.assertRaises(AttributeError): - result.tl_track = None - - def test_tl_track_invalid(self): - tl_track = Track() - with self.assertRaises(TypeError): - PlaybackState(tl_track=tl_track) - - def test_state(self): - state = 'playing' - result = PlaybackState(state=state) - self.assertEqual(result.state, state) - with self.assertRaises(AttributeError): - result.state = None - - def test_state_invalid(self): - state = 'not_a_state' - with self.assertRaises(TypeError): - PlaybackState(state=state) - - -class TracklistStateTest(unittest.TestCase): - - def test_repeat_true(self): - repeat = True - result = TracklistState(repeat=repeat) - self.assertEqual(result.repeat, repeat) - with self.assertRaises(AttributeError): - result.repeat = None - - def test_repeat_false(self): - repeat = False - result = TracklistState(repeat=repeat) - self.assertEqual(result.repeat, repeat) - with self.assertRaises(AttributeError): - result.repeat = None - - def test_repeat_invalid(self): - repeat = 33 - with self.assertRaises(TypeError): - TracklistState(repeat=repeat) - - def test_consume_true(self): - val = True - result = TracklistState(consume=val) - self.assertEqual(result.consume, val) - with self.assertRaises(AttributeError): - result.repeat = None - - def test_random_true(self): - val = True - result = TracklistState(random=val) - self.assertEqual(result.random, val) - with self.assertRaises(AttributeError): - result.random = None - - def test_single_true(self): - val = True - result = TracklistState(single=val) - self.assertEqual(result.single, val) - with self.assertRaises(AttributeError): - result.single = None - - def test_next_tlid(self): - val = 654 - result = TracklistState(next_tlid=val) - self.assertEqual(result.next_tlid, val) - with self.assertRaises(AttributeError): - result.next_tlid = None - - def test_next_tlid_invalid(self): - val = -1 - with self.assertRaises(ValueError): - TracklistState(next_tlid=val) - - def test_tracks(self): - tracks = (TlTrack(), TlTrack()) - result = TracklistState(tl_tracks=tracks) - self.assertEqual(result.tl_tracks, tracks) - with self.assertRaises(AttributeError): - result.tl_tracks = None - - def test_tracks_invalid(self): - tracks = (Track(), Track()) - with self.assertRaises(TypeError): - TracklistState(tl_tracks=tracks) From 606e87b1bbbf3c97923ca24d0a523a872953c0f7 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 14 Jan 2016 19:56:38 +0100 Subject: [PATCH 013/118] Make export/restore state internal - drop filename parameter - make save_state/load_state internal - remove save_state/load_state from docu and RPC. - remove models load/save from docu - build the config path - folder for 'core' state files - move restore_state-to-coverage-translation into a method --- docs/api/core.rst | 4 --- docs/api/models.rst | 7 ---- mopidy/core/actor.py | 74 ++++++++++++++++++++-------------------- mopidy/http/handlers.py | 4 --- tests/core/test_actor.py | 36 +++++++++++++++---- 5 files changed, 67 insertions(+), 58 deletions(-) diff --git a/docs/api/core.rst b/docs/api/core.rst index de686028..abc046bd 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -53,10 +53,6 @@ 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 cd6d1cf2..27c7647f 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -87,13 +87,6 @@ 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/mopidy/core/actor.py b/mopidy/core/actor.py index 8b9010ab..9c03d035 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -18,7 +18,7 @@ from mopidy.core.mixer import MixerController from mopidy.core.playback import PlaybackController from mopidy.core.playlists import PlaylistsController from mopidy.core.tracklist import TracklistController -from mopidy.internal import storage, validation, versioning +from mopidy.internal import path, storage, validation, versioning from mopidy.internal.deprecation import deprecated_property from mopidy.internal.models import CoreState @@ -144,24 +144,10 @@ class Core( try: coverage = [] if self._config and 'restore_state' in self._config['core']: - value = self._config['core']['restore_state'] - if not value or 'off' == value: - pass - elif 'volume' == value: - coverage = ['volume'] - elif 'load' == value: - coverage = ['tracklist', 'mode', 'volume', 'history'] - elif 'last' == value: - coverage = ['tracklist', 'mode', 'play-last', 'volume', - 'history'] - elif 'play' == value: - coverage = ['tracklist', 'mode', 'play-always', 'volume', - 'history'] - else: - logger.warn('Unknown value for config ' - 'core.restore_state: %s', value) + coverage = self._config_to_coverage( + self._config['core']['restore_state']) if len(coverage): - self.load_state('persistent', coverage) + self._load_state(coverage) except Exception as e: logger.warn('Restore state: Unexpected error: %s', str(e)) @@ -170,23 +156,43 @@ class Core( if self._config and 'restore_state' in self._config['core']: amount = self._config['core']['restore_state'] if amount and 'off' != amount: - self.save_state('persistent') + self._save_state() except Exception as e: - logger.warn('Export state: Unexpected error: %s', str(e)) + logger.warn('Unexpected error while saving state: %s', str(e)) - def save_state(self, name): + @staticmethod + def _config_to_coverage(value): + coverage = [] + if not value or 'off' == value: + pass + elif 'volume' == value: + coverage = ['volume'] + elif 'load' == value: + coverage = ['tracklist', 'mode', 'volume', 'history'] + elif 'last' == value: + coverage = ['tracklist', 'mode', 'play-last', 'volume', + 'history'] + elif 'play' == value: + coverage = ['tracklist', 'mode', 'play-always', 'volume', + 'history'] + else: + logger.warn('Unknown value for config ' + 'core.restore_state: %s', value) + return coverage + + def _get_data_dir(self): + # get or create data director for core + data_dir_path = bytes( + os.path.join(self._config['core']['data_dir'], 'core')) + path.get_or_create_dir(data_dir_path) + return data_dir_path + + def _save_state(self): """ Save current state to disk. - - :param name: a name (for later use with :meth:`load_state`) - :type name: str """ - if not name: - raise TypeError('Missing file name.') - file_name = os.path.join( - self._config['core']['data_dir'], name) - file_name += '.state' + file_name = os.path.join(self._get_data_dir(), 'persistent.state') logger.info('Save state to %s', file_name) data = {} @@ -199,7 +205,7 @@ class Core( storage.save(file_name, data) logger.debug('Save state done.') - def load_state(self, name, coverage): + def _load_state(self, coverage): """ Restore state from disk. @@ -213,17 +219,11 @@ class Core( - '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) """ - if not name: - raise TypeError('Missing file name.') - file_name = os.path.join( - self._config['core']['data_dir'], name) - file_name += '.state' + file_name = os.path.join(self._get_data_dir(), 'persistent.state') logger.info('Load state from %s', file_name) data = storage.load(file_name) diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 2994f8ed..a752a4f0 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -43,8 +43,6 @@ 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, @@ -57,8 +55,6 @@ 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/tests/core/test_actor.py b/tests/core/test_actor.py index fda24c4c..e0e9d9a0 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -54,7 +54,7 @@ class CoreActorExportRestoreTest(unittest.TestCase): config = { 'core': { 'max_tracklist_length': 10000, - 'restore_state': 'play', + 'restore_state': 'last', 'data_dir': self.temp_dir, } } @@ -66,8 +66,32 @@ class CoreActorExportRestoreTest(unittest.TestCase): pykka.ActorRegistry.stop_all() shutil.rmtree(self.temp_dir) - def test_restore_on_start(self): - # cover mopidy.core.actor.on_start and .on_stop - # starting the actor by calling any function: - self.core.get_version() - pass + def test_export_state(self): + self.core.teardown() + # TODO: implement meaningful test + + def test_restore_state(self): + self.core.setup() + # TODO: implement meaningful test + + def test_export_coverage_none(self): + result = Core._config_to_coverage(None) + self.assertEqual(result, []) + result = Core._config_to_coverage('off') + self.assertEqual(result, []) + + def test_export_coverage(self): + result = Core._config_to_coverage('volume') + self.assertEqual(result, ['volume']) + + result = Core._config_to_coverage('load') + target = ['tracklist', 'mode', 'volume', 'history'] + self.assertEqual(result, target) + + result = Core._config_to_coverage('last') + target = ['tracklist', 'mode', 'play-last', 'volume', 'history'] + self.assertEqual(result, target) + + result = Core._config_to_coverage('play') + target = ['tracklist', 'mode', 'play-always', 'volume', 'history'] + self.assertEqual(result, target) From 49b84f4a61ed3bb2bdaa13738533904ff0ee7112 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 14 Jan 2016 22:58:41 +0100 Subject: [PATCH 014/118] Fix a flake8 error --- mopidy/audio/scan.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index ca2c308c..a54120d1 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -141,8 +141,9 @@ def _process(pipeline, timeout_ms): have_audio = False missing_message = None - types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR - | gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG) + types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | + gst.MESSAGE_ERROR | gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | + gst.MESSAGE_TAG) previous = clock.get_time() while timeout > 0: From 3647df61c86276e5b959a536d690a7ba1ab93582 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sun, 24 Jan 2016 17:58:44 +0100 Subject: [PATCH 015/118] More stability if a backend rejects tracks - Catch exceptions raised by backend inside 'PlaybackProvider.change_track' - Avoid endless loop if 'repeat' is 'true' and not a single track is playable --- mopidy/core/playback.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index cb89658a..81eed172 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -259,7 +259,10 @@ class PlaybackController(object): backend = self._get_backend(next_tl_track) if backend: - backend.playback.change_track(next_tl_track.track).get() + try: + backend.playback.change_track(next_tl_track.track).get() + except Exception as e: + logger.error('Change track failed: %s', e) def _on_tracklist_change(self): """ @@ -343,6 +346,8 @@ class PlaybackController(object): 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 + count = self.core.tracklist.get_length() while pending: # TODO: should we consume unplayable tracks in this loop? @@ -352,6 +357,10 @@ class PlaybackController(object): 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? @@ -368,8 +377,12 @@ class PlaybackController(object): return False backend.playback.prepare_change() - if not backend.playback.change_track(pending_tl_track.track).get(): - return False # TODO: test for this path + try: + if not backend.playback.change_track(pending_tl_track.track).get(): + return False # TODO: test for this path + except Exception as e: + logger.error('Change track failed: %s', e) + return False if state == PlaybackState.PLAYING: try: From 2401229871b744d93bbc972b47813efbfc90a4fb Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 30 Jan 2016 13:13:38 +0100 Subject: [PATCH 016/118] Catch backend exceptions with a helper function --- mopidy/core/playback.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 81eed172..208f0619 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,7 +1,9 @@ from __future__ import absolute_import, unicode_literals +import contextlib import logging +from mopidy import exceptions from mopidy.audio import PlaybackState from mopidy.compat import urllib from mopidy.core import listener @@ -10,6 +12,20 @@ from mopidy.internal import deprecation, models, validation logger = logging.getLogger(__name__) +@contextlib.contextmanager +def _backend_error_handling(backend, reraise=None): + try: + yield + except exceptions.ValidationError as e: + logger.error('%s backend returned bad data: %s', + backend.actor_ref.actor_class.__name__, e) + except Exception as e: + if reraise and isinstance(e, reraise): + raise + logger.exception('%s backend caused an exception.', + backend.actor_ref.actor_class.__name__) + + class PlaybackController(object): pykka_traversable = True @@ -259,10 +275,8 @@ class PlaybackController(object): backend = self._get_backend(next_tl_track) if backend: - try: + with _backend_error_handling(backend): backend.playback.change_track(next_tl_track.track).get() - except Exception as e: - logger.error('Change track failed: %s', e) def _on_tracklist_change(self): """ @@ -377,12 +391,12 @@ class PlaybackController(object): return False backend.playback.prepare_change() - try: - if not backend.playback.change_track(pending_tl_track.track).get(): - return False # TODO: test for this path - except Exception as e: - logger.error('Change track failed: %s', e) - return False + track_change_result = False + with _backend_error_handling(backend): + track_change_result = backend.playback.change_track( + pending_tl_track.track).get() + if not track_change_result: + return False # TODO: test for this path if state == PlaybackState.PLAYING: try: From 2b3b2e5808d987a2609ab6de776402b16c97b897 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 30 Jan 2016 13:38:29 +0100 Subject: [PATCH 017/118] Add a docstring to 'setup' and 'teardown' Inform that 'setup' and 'teardown' are for internally use only. --- mopidy/core/actor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 9c03d035..8eb3bb36 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -141,6 +141,7 @@ class Core( 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']: @@ -152,6 +153,7 @@ class Core( 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']: amount = self._config['core']['restore_state'] From cfc2d59c820cecb9cfc9072dafc547ec2bedd5e4 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 30 Jan 2016 13:51:16 +0100 Subject: [PATCH 018/118] Add a TODO for missing tracks in shuffled playlist --- mopidy/core/playback.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 208f0619..240c4454 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -361,6 +361,7 @@ class PlaybackController(object): 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 + # TODO: could miss a playable track in a shuffled playlist count = self.core.tracklist.get_length() while pending: From e51b8c58be56a17f3ece5ffb91f6dfe4d05da445 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Wed, 3 Feb 2016 20:47:10 +0100 Subject: [PATCH 019/118] Fix for: mpd client starts with empty tracklist. When restoring state, increment the tracklist version number. --- mopidy/core/tracklist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index f99f3917..ea60c408 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -674,4 +674,4 @@ class TracklistController(object): # (login), disabled extensions and automatically # generated playlists (pandora). self._tl_tracks.append(track) - self._trigger_tracklist_changed() + self._increase_version() From 49849fa5b39664f4b66583b08c3e6f75dcb5099d Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Wed, 3 Feb 2016 20:49:43 +0100 Subject: [PATCH 020/118] Find all playable tracks in a shuffled playlist. Run two times through the tracklist to be sure to not miss a playable track. --- mopidy/core/playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 240c4454..2ae8dbb3 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -361,8 +361,8 @@ class PlaybackController(object): 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 - # TODO: could miss a playable track in a shuffled playlist - count = self.core.tracklist.get_length() + # * 2 -> second run to get all playable track in a shuffled playlist + count = self.core.tracklist.get_length() * 2 while pending: # TODO: should we consume unplayable tracks in this loop? From 49325c62dde3595c5057193649a87b4427110348 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 4 Feb 2016 20:54:49 +0100 Subject: [PATCH 021/118] Test tracklist.get_version() change. The tracklist version shall increase when loading state. --- tests/core/test_tracklist.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 7b26f3d7..e3beb957 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -227,6 +227,7 @@ class TracklistExportRestoreTest(unittest.TestCase): self.assertEqual(target, value) def test_import(self): + old_version = self.core.tracklist.get_version() target = TracklistState(consume=False, repeat=True, single=True, @@ -242,6 +243,7 @@ class TracklistExportRestoreTest(unittest.TestCase): self.assertEqual(12, self.core.tracklist._next_tlid) self.assertEqual(4, self.core.tracklist.get_length()) self.assertEqual(self.tl_tracks, self.core.tracklist.get_tl_tracks()) + self.assertGreater(self.core.tracklist.get_version(), old_version) # after import, adding more tracks must be possible self.core.tracklist.add(uris=[self.tracks[1].uri]) From d8405082e9d5e5f1ad157ee11e0ccf5e8ef936f5 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 4 Feb 2016 21:14:20 +0100 Subject: [PATCH 022/118] Test tracklist.get_version() change. The tracklist version shall increase when loading state. --- tests/core/test_tracklist.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index e3beb957..5e9170b2 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -251,6 +251,7 @@ class TracklistExportRestoreTest(unittest.TestCase): self.assertEqual(5, self.core.tracklist.get_length()) def test_import_mode_only(self): + old_version = self.core.tracklist.get_version() target = TracklistState(consume=False, repeat=True, single=True, @@ -266,8 +267,10 @@ class TracklistExportRestoreTest(unittest.TestCase): self.assertEqual(1, self.core.tracklist._next_tlid) self.assertEqual(0, self.core.tracklist.get_length()) self.assertEqual([], self.core.tracklist.get_tl_tracks()) + self.assertEqual(self.core.tracklist.get_version(), old_version) def test_import_tracklist_only(self): + old_version = self.core.tracklist.get_version() target = TracklistState(consume=False, repeat=True, single=True, @@ -283,6 +286,7 @@ class TracklistExportRestoreTest(unittest.TestCase): self.assertEqual(12, self.core.tracklist._next_tlid) self.assertEqual(4, self.core.tracklist.get_length()) self.assertEqual(self.tl_tracks, self.core.tracklist.get_tl_tracks()) + self.assertGreater(self.core.tracklist.get_version(), old_version) def test_import_invalid_type(self): with self.assertRaises(TypeError): From 9d8034869d3d71ba56f6599858d17680f15af763 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 6 Feb 2016 15:48:43 +0100 Subject: [PATCH 023/118] Chance type of core.restore_state config value Change to boolean to simplify the user configuration. --- docs/config.rst | 14 +++----------- mopidy/config/__init__.py | 3 +-- mopidy/config/default.conf | 2 +- mopidy/core/actor.py | 28 ++++------------------------ tests/core/test_actor.py | 24 +----------------------- 5 files changed, 10 insertions(+), 61 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 2c302acd..b7874f83 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -113,21 +113,13 @@ Core config section .. confval:: core/restore_state - Restore last state at start. Defaults to ``off``. + When set to ``true``, Mopidy saves the state when it ends and + restores the state at next start. - Save state when Mopidy ends and restore state at next start. - Allowed values: - - - ``off``: restore nothing - - ``volume``: restore volume - - ``load``: restore settings, volume and play queue - - ``last``: like ``load``, additional start playback if last state was - 'playing' - - ``play``: like ``load``, additional start playback + Default is ``false``. .. _audio-config: - Audio configuration =================== diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 7d5b300b..fac89ac9 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -21,8 +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, choices=['off', 'volume', 'load', 'last', 'play']) +_core_schema['restore_state'] = Boolean(optional=True) _logging_schema = ConfigSchema('logging') _logging_schema['color'] = Boolean() diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index a501ccb9..8076a0f4 100644 --- a/mopidy/config/default.conf +++ b/mopidy/config/default.conf @@ -3,7 +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 +restore_state = false [logging] color = true diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 8eb3bb36..678796fb 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -145,8 +145,9 @@ class Core( try: coverage = [] if self._config and 'restore_state' in self._config['core']: - coverage = self._config_to_coverage( - self._config['core']['restore_state']) + if self._config['core']['restore_state']: + coverage = ['tracklist', 'mode', 'play-last', 'volume', + 'history'] if len(coverage): self._load_state(coverage) except Exception as e: @@ -156,32 +157,11 @@ class Core( """ Do not call this function. It is for internal use at shutdown.""" try: if self._config and 'restore_state' in self._config['core']: - amount = self._config['core']['restore_state'] - if amount and 'off' != amount: + if self._config['core']['restore_state']: self._save_state() except Exception as e: logger.warn('Unexpected error while saving state: %s', str(e)) - @staticmethod - def _config_to_coverage(value): - coverage = [] - if not value or 'off' == value: - pass - elif 'volume' == value: - coverage = ['volume'] - elif 'load' == value: - coverage = ['tracklist', 'mode', 'volume', 'history'] - elif 'last' == value: - coverage = ['tracklist', 'mode', 'play-last', 'volume', - 'history'] - elif 'play' == value: - coverage = ['tracklist', 'mode', 'play-always', 'volume', - 'history'] - else: - logger.warn('Unknown value for config ' - 'core.restore_state: %s', value) - return coverage - def _get_data_dir(self): # get or create data director for core data_dir_path = bytes( diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index e0e9d9a0..290d1c60 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -54,7 +54,7 @@ class CoreActorExportRestoreTest(unittest.TestCase): config = { 'core': { 'max_tracklist_length': 10000, - 'restore_state': 'last', + 'restore_state': True, 'data_dir': self.temp_dir, } } @@ -73,25 +73,3 @@ class CoreActorExportRestoreTest(unittest.TestCase): def test_restore_state(self): self.core.setup() # TODO: implement meaningful test - - def test_export_coverage_none(self): - result = Core._config_to_coverage(None) - self.assertEqual(result, []) - result = Core._config_to_coverage('off') - self.assertEqual(result, []) - - def test_export_coverage(self): - result = Core._config_to_coverage('volume') - self.assertEqual(result, ['volume']) - - result = Core._config_to_coverage('load') - target = ['tracklist', 'mode', 'volume', 'history'] - self.assertEqual(result, target) - - result = Core._config_to_coverage('last') - target = ['tracklist', 'mode', 'play-last', 'volume', 'history'] - self.assertEqual(result, target) - - result = Core._config_to_coverage('play') - target = ['tracklist', 'mode', 'play-always', 'volume', 'history'] - self.assertEqual(result, target) From 3bf6b9896c2fd2fd6d30cdc5aa49e8c26e3b8428 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 6 Feb 2016 16:22:54 +0100 Subject: [PATCH 024/118] Limit history to 500 track To avoid an too large history list, save/restore maximum 500 tracks. 500 tracks a 3 minuts gives 24 hours history. --- mopidy/core/history.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 3c0a2446..a6b4c817 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -60,10 +60,18 @@ class HistoryController(object): def _export_state(self): """Internal method for :class:`mopidy.Core`.""" + + # 500 tracks a 3 minutes -> 24 hours history + count_max = 500 + count = 1 history_list = [] for timestamp, track in self._history: history_list.append(HistoryTrack( timestamp=timestamp, track=track)) + count += 1 + if count_max < count: + logger.info('Limit history to %s tracks.', count_max) + break return HistoryState(history=history_list) def _restore_state(self, state, coverage): From d04ff285147d8be30d92ae45fd66d633f4d8fd57 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sun, 7 Feb 2016 17:31:38 +0100 Subject: [PATCH 025/118] Implement 'start paused' and 'start at position' --- mopidy/core/playback.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ea6d6569..aa735262 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -45,6 +45,9 @@ class PlaybackController(object): self._last_position = None self._previous = False + self._start_at_pos = None + self._start_paused = False + if self._audio: self._audio.set_about_to_finish_callback( self._on_about_to_finish_callback) @@ -240,6 +243,13 @@ class PlaybackController(object): if self._pending_position is None: self.set_state(PlaybackState.PLAYING) self._trigger_track_playback_started() + seek_ok = False + if self._start_at_pos: + seek_ok = self.seek(self._start_at_pos) + self._start_at_pos = None + if not seek_ok and self._start_paused: + self.pause() + self._start_paused = False else: self._seek(self._pending_position) @@ -247,6 +257,9 @@ class PlaybackController(object): if self._pending_position == position: self._trigger_seeked(position) self._pending_position = None + if self._start_paused: + self._start_paused = False + self.pause() def _on_about_to_finish_callback(self): """Callback that performs a blocking actor call to the real callback. @@ -586,7 +599,9 @@ class PlaybackController(object): if 'play-last' in coverage: new_state = state.state if state.tlid is not None: - # TODO: restore to 'paused' state - if PlaybackState.PLAYING == new_state: + if PlaybackState.PAUSED == new_state: + self._start_paused = True + if (PlaybackState.PLAYING == new_state or + PlaybackState.PAUSED == new_state): + self._start_at_pos = state.position self.play(tlid=state.tlid) - # TODO: seek to state.position? From 32c135a12470e9641acd322cfcd7d275259e7c7d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 15 Feb 2016 22:14:14 +0100 Subject: [PATCH 026/118] docs: Add changelog section for 2.1.0 --- docs/changelog.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7dc4a747..6bd55372 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,14 @@ Changelog This changelog is used to track all major changes to Mopidy. +v2.1.0 (UNRELEASED) +=================== + +Feature release. + +- Nothing yet. + + v2.0.0 (2016-02-15) =================== From 3eac5895570e84a6ff4c7ffcebd7fcc1c6a512d9 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Mon, 29 Feb 2016 20:47:11 +0100 Subject: [PATCH 027/118] Try to restore state only one time. Delete the persistant file after read. If something goes wrong during restore, the next start is clean. --- mopidy/core/actor.py | 4 ++++ tests/core/test_actor.py | 21 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 678796fb..59f10450 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -209,6 +209,10 @@ class Core( logger.info('Load state from %s', file_name) data = storage.load(file_name) + + # Try only once. If something goes wrong, the next start is clean. + os.remove(file_name) + if 'state' in data: core_state = data['state'] validation.check_instance(core_state, CoreState) diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 290d1c60..e935241f 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, unicode_literals +import os import shutil import tempfile import unittest @@ -9,7 +10,7 @@ import mock import pykka from mopidy.core import Core -from mopidy.internal import versioning +from mopidy.internal import storage, versioning class CoreActorTest(unittest.TestCase): @@ -59,6 +60,8 @@ class CoreActorExportRestoreTest(unittest.TestCase): } } + os.mkdir(os.path.join(self.temp_dir, 'core')) + self.core = Core.start( config=config, mixer=None, backends=[]).proxy() @@ -67,9 +70,21 @@ class CoreActorExportRestoreTest(unittest.TestCase): shutil.rmtree(self.temp_dir) def test_export_state(self): - self.core.teardown() + self.core.teardown().get() # TODO: implement meaningful test def test_restore_state(self): - self.core.setup() + self.core.setup().get() # TODO: implement meaningful test + + def test_delete_state_file_on_restore(self): + file_path = os.path.join(self.temp_dir, 'core', 'persistent.state') + + data = {} + storage.save(file_path, data) + self.assertTrue(os.path.isfile(file_path), 'missing persistent file') + + self.core.setup().get() + + self.assertFalse(os.path.isfile(file_path), + 'persistent file has to be deleted') From 58db550bd60d23025cae8ef1ece399bcd0792189 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sun, 6 Mar 2016 12:42:13 +0100 Subject: [PATCH 028/118] Register mopidy models for deserialization. All from ValidatedImmutableObject derived classes are registered for automatically deserialization by model_json_decoder(). --- mopidy/internal/models.py | 3 --- mopidy/models/immutable.py | 11 ++++++++++- mopidy/models/serialize.py | 11 +++-------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/mopidy/internal/models.py b/mopidy/internal/models.py index 5214cc65..690b1802 100644 --- a/mopidy/internal/models.py +++ b/mopidy/internal/models.py @@ -4,9 +4,6 @@ from mopidy.internal import validation from mopidy.models import Ref, TlTrack, fields from mopidy.models.immutable import ValidatedImmutableObject -_MODELS = ['HistoryTrack', 'HistoryState', 'MixerState', 'PlaybackState', - 'TracklistState', 'CoreState'] - class HistoryTrack(ValidatedImmutableObject): """ diff --git a/mopidy/models/immutable.py b/mopidy/models/immutable.py index 18de7d76..fadff89b 100644 --- a/mopidy/models/immutable.py +++ b/mopidy/models/immutable.py @@ -8,6 +8,10 @@ from mopidy.internal import deprecation from mopidy.models.fields import Field +# Registered models for automatic deserialization +_models = {} + + class ImmutableObject(object): """ Superclass for immutable objects whose fields can only be modified via the @@ -150,9 +154,14 @@ class _ValidatedImmutableObjectMeta(type): attrs['_instances'] = weakref.WeakValueDictionary() attrs['__slots__'] = list(attrs.get('__slots__', [])) + fields.values() - return super(_ValidatedImmutableObjectMeta, cls).__new__( + clsc = super(_ValidatedImmutableObjectMeta, cls).__new__( cls, name, bases, attrs) + if clsc.__name__ != 'ValidatedImmutableObject': + _models[clsc.__name__] = clsc + + return clsc + def __call__(cls, *args, **kwargs): # noqa: N805 instance = super(_ValidatedImmutableObjectMeta, cls).__call__( *args, **kwargs) diff --git a/mopidy/models/serialize.py b/mopidy/models/serialize.py index 68d170c4..ab173aae 100644 --- a/mopidy/models/serialize.py +++ b/mopidy/models/serialize.py @@ -4,8 +4,6 @@ import json from mopidy.models import immutable -_MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist'] - class ModelJSONEncoder(json.JSONEncoder): @@ -40,11 +38,8 @@ def model_json_decoder(dct): """ if '__model__' in dct: - from mopidy import models model_name = dct.pop('__model__') - if model_name in _MODELS: - return getattr(models, model_name)(**dct) - from mopidy import internal - if model_name in internal.models._MODELS: - return getattr(internal.models, model_name)(**dct) + if model_name in immutable._models: + cls = immutable._models[model_name] + return cls(**dct) return dct From 7fd989ac9bed3490cdf3a7150065242489036a8c Mon Sep 17 00:00:00 2001 From: Lina He Date: Fri, 11 Mar 2016 15:19:18 +0100 Subject: [PATCH 029/118] Modify a mistake in annotations I found this mistake when reading the documentation of Mopidy. Just add a "to" to the annotation. --- mopidy/local/translator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/local/translator.py b/mopidy/local/translator.py index 16842f59..b8fcff90 100644 --- a/mopidy/local/translator.py +++ b/mopidy/local/translator.py @@ -46,7 +46,7 @@ def path_to_local_track_uri(relpath): def path_to_local_directory_uri(relpath): - """Convert path relative to :confval:`local/media_dir` directory URI.""" + """Convert path relative to :confval:`local/media_dir` to directory URI.""" if isinstance(relpath, compat.text_type): relpath = relpath.encode('utf-8') return 'local:directory:%s' % urllib.quote(relpath) From a372793cbe634ed4bd2441ea1b3042d125a0e2df Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 23 Mar 2016 08:57:43 +0100 Subject: [PATCH 030/118] meta: Create placeholder ISSUE_TEMPLATE We probably want more here, but it's a start. --- .github/ISSUE_TEMPLATE.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..c7cfc136 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,2 @@ +Please use https://discuss.mopidy.com for support questions. +Bugs should only be used for confirmed problems with Mopidy and feature requests. From 86b032736973c0050f79905e2647ff398bb92296 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 23 Mar 2016 19:45:59 +0100 Subject: [PATCH 031/118] github: Tweak issues template --- .github/ISSUE_TEMPLATE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index c7cfc136..f2b1e2e5 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,2 +1,3 @@ -Please use https://discuss.mopidy.com for support questions. -Bugs should only be used for confirmed problems with Mopidy and feature requests. +Please use [discuss.mopidy.com](https://discuss.mopidy.com) for support questions. + +GtHub Issues should only be used for confirmed problems with Mopidy and well-defined feature requests. From a1057eca543593756f7bfbcb0f3a30718be3d701 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 23 Mar 2016 19:47:37 +0100 Subject: [PATCH 032/118] github: Remove Markdown link --- .github/ISSUE_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index f2b1e2e5..a04dac51 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,3 +1,3 @@ -Please use [discuss.mopidy.com](https://discuss.mopidy.com) for support questions. +Please use https://discuss.mopidy.com/ for support questions. GtHub Issues should only be used for confirmed problems with Mopidy and well-defined feature requests. From 77358806dec8a73b0530d5d32fc2a67e22290b3c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Fri, 25 Mar 2016 12:44:39 +0100 Subject: [PATCH 033/118] github: Fix typo --- .github/ISSUE_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index a04dac51..4fd5e866 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,3 +1,3 @@ Please use https://discuss.mopidy.com/ for support questions. -GtHub Issues should only be used for confirmed problems with Mopidy and well-defined feature requests. +GitHub Issues should only be used for confirmed problems with Mopidy and well-defined feature requests. From d4d2ad80dc4c74b8361e5ef2d789f805cc1520eb Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 31 Mar 2016 20:24:31 +0200 Subject: [PATCH 034/118] Changed name persistant file to 'state.json.gz' Changed filename from persistent.state to state.json.gz. Now the file extension matches the content. --- mopidy/core/actor.py | 4 ++-- tests/core/test_actor.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 59f10450..52c3dfb3 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -174,7 +174,7 @@ class Core( Save current state to disk. """ - file_name = os.path.join(self._get_data_dir(), 'persistent.state') + file_name = os.path.join(self._get_data_dir(), b'state.json.gz') logger.info('Save state to %s', file_name) data = {} @@ -205,7 +205,7 @@ class Core( :type coverage: list of string (see above) """ - file_name = os.path.join(self._get_data_dir(), 'persistent.state') + file_name = os.path.join(self._get_data_dir(), b'state.json.gz') logger.info('Load state from %s', file_name) data = storage.load(file_name) diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index e935241f..9d71fcef 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -78,7 +78,7 @@ class CoreActorExportRestoreTest(unittest.TestCase): # TODO: implement meaningful test def test_delete_state_file_on_restore(self): - file_path = os.path.join(self.temp_dir, 'core', 'persistent.state') + file_path = os.path.join(self.temp_dir, 'core', 'state.json.gz') data = {} storage.save(file_path, data) From b581d5a574ab90fd4cc195bc7ba438628c80ac0a Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 31 Mar 2016 20:41:47 +0200 Subject: [PATCH 035/118] Update documentation. Update persistent feature in changelog and config description. --- docs/changelog.rst | 6 ++---- docs/config.rst | 5 +++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 08f0396b..b048e726 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,7 +10,8 @@ v2.1.0 (UNRELEASED) Feature release. -- Nothing yet. +- Core: Mopidy restores its last state when started. Can be enabled by setting + the config value :confval:`core/restore_state` to `true`. v2.0.1 (UNRELEASED) @@ -95,9 +96,6 @@ Core API - Add :meth:`mopidy.core.PlaylistsController.get_uri_schemes`. (PR: :issue:`1362`) -- Persist state between runs. The amount of data to persist can be - controlled by config value :confval:`core/restore_state` - - The ``track_playback_ended`` event now includes the correct ``tl_track`` reference when changing to the next track in consume mode. (Fixes: :issue:`1402` PR: :issue:`1403` PR: :issue:`1406`) diff --git a/docs/config.rst b/docs/config.rst index 08381aaa..df8e2a7d 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -113,8 +113,9 @@ Core config section .. confval:: core/restore_state - When set to ``true``, Mopidy saves the state when it ends and - restores the state at next start. + When set to ``true``, Mopidy restores its last state when started. + The restored state includes the tracklist, playback history, + the playback state, and the mixers volume and mute state. Default is ``false``. From 7928caeb7124cb0a501c4659e12e6e2a80362c2b Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 31 Mar 2016 21:30:48 +0200 Subject: [PATCH 036/118] Persist mixer mute state. Restore mixer.mute state at start. Update mopidy.models docstrings. --- mopidy/core/actor.py | 4 ++-- mopidy/core/mixer.py | 6 ++++-- mopidy/internal/models.py | 15 ++++++++++----- tests/core/test_mixer.py | 37 +++++++++++++++++++++++++++++++++---- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 52c3dfb3..56fe6e74 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -146,7 +146,7 @@ class Core( coverage = [] if self._config and 'restore_state' in self._config['core']: if self._config['core']['restore_state']: - coverage = ['tracklist', 'mode', 'play-last', 'volume', + coverage = ['tracklist', 'mode', 'play-last', 'mixer', 'history'] if len(coverage): self._load_state(coverage) @@ -198,7 +198,7 @@ class Core( - 'tracklist' fill the tracklist - 'mode' set tracklist properties (consume, random, repeat, single) - 'autoplay' start playing ('tracklist' also required) - - 'volume' set mixer volume + - 'mixer' set mixer volume and mute state - 'history' restore history :param coverage: amount of data to restore diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index cceb0ebe..3d9b7003 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -103,11 +103,13 @@ class MixerController(object): def _export_state(self): """Internal method for :class:`mopidy.Core`.""" - return MixerState(volume=self.get_volume()) + return MixerState(volume=self.get_volume(), + mute=self.get_mute()) def _restore_state(self, state, coverage): """Internal method for :class:`mopidy.Core`.""" if state: - if 'volume' in coverage: + if 'mixer' in coverage: if state.volume: self.set_volume(state.volume) + self.set_mute(state.mute) diff --git a/mopidy/internal/models.py b/mopidy/internal/models.py index 690b1802..b66e0504 100644 --- a/mopidy/internal/models.py +++ b/mopidy/internal/models.py @@ -7,7 +7,7 @@ from mopidy.models.immutable import ValidatedImmutableObject class HistoryTrack(ValidatedImmutableObject): """ - A history track. Wraps a :class:`Ref` and it's timestamp. + A history track. Wraps a :class:`Ref` and its timestamp. :param timestamp: the timestamp :type timestamp: int @@ -42,11 +42,16 @@ class MixerState(ValidatedImmutableObject): :param volume: the volume :type volume: int + :param mute: the volume + :type mute: int """ # The volume. Read-only. volume = fields.Integer(min=0, max=100) + # The mute state. Read-only. + mute = fields.Boolean(default=False) + class PlaybackState(ValidatedImmutableObject): """ @@ -85,9 +90,9 @@ class TracklistState(ValidatedImmutableObject): :type random: bool :param single: the single mode :type single: bool - :param next_tlid: the single mode - :type next_tlid: bool - :param tl_tracks: the single mode + :param next_tlid: the id of the next track to play + :type next_tlid: int + :param tl_tracks: the list of tracks :type tl_tracks: list of :class:`TlTrack` """ @@ -103,7 +108,7 @@ class TracklistState(ValidatedImmutableObject): # The single mode. Read-only. single = fields.Boolean() - # The repeat mode. Read-only. + # The id of the track to play. Read-only. next_tlid = fields.Integer(min=0) # The list of tracks. Read-only. diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 0b7b789b..17ecdfb5 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -163,10 +163,21 @@ class CoreMixerExportRestoreTest(unittest.TestCase): self.mixer = dummy_mixer.create_proxy() self.core = core.Core(mixer=self.mixer, backends=[]) - def test_export(self): + def test_export_mute(self): volume = 32 - target = MixerState(volume=volume) + mute = False + target = MixerState(volume=volume, mute=mute) self.core.mixer.set_volume(volume) + self.core.mixer.set_mute(mute) + value = self.core.mixer._export_state() + self.assertEqual(target, value) + + def test_export_unmute(self): + volume = 33 + mute = True + target = MixerState(volume=volume, mute=mute) + self.core.mixer.set_volume(volume) + self.core.mixer.set_mute(mute) value = self.core.mixer._export_state() self.assertEqual(target, value) @@ -174,16 +185,34 @@ class CoreMixerExportRestoreTest(unittest.TestCase): self.core.mixer.set_volume(11) volume = 45 target = MixerState(volume=volume) - coverage = ['volume'] + coverage = ['mixer'] self.core.mixer._restore_state(target, coverage) self.assertEqual(volume, self.core.mixer.get_volume()) def test_import_not_covered(self): self.core.mixer.set_volume(21) - target = MixerState(volume=56) + self.core.mixer.set_mute(True) + target = MixerState(volume=56, mute=False) coverage = ['other'] self.core.mixer._restore_state(target, coverage) self.assertEqual(21, self.core.mixer.get_volume()) + self.assertEqual(True, self.core.mixer.get_mute()) + + def test_import_mute_on(self): + self.core.mixer.set_mute(False) + self.assertEqual(False, self.core.mixer.get_mute()) + target = MixerState(mute=True) + coverage = ['mixer'] + self.core.mixer._restore_state(target, coverage) + self.assertEqual(True, self.core.mixer.get_mute()) + + def test_import_mute_off(self): + self.core.mixer.set_mute(True) + self.assertEqual(True, self.core.mixer.get_mute()) + target = MixerState(mute=False) + coverage = ['mixer'] + self.core.mixer._restore_state(target, coverage) + self.assertEqual(False, self.core.mixer.get_mute()) def test_import_invalid_type(self): with self.assertRaises(TypeError): From 3251722a289b4803dd161bfc4c219b1a41e29e8a Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 31 Mar 2016 21:55:32 +0200 Subject: [PATCH 037/118] Rename storage.save() to storage.dump(). The name matches the load/dump in the json library. Import olny storage from mopidy.internal (not all of mopidy.internal). --- mopidy/core/actor.py | 2 +- mopidy/internal/storage.py | 2 +- mopidy/local/json.py | 6 +++--- tests/core/test_actor.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 56fe6e74..7b457ac1 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -184,7 +184,7 @@ class Core( history=self.history._export_state(), playback=self.playback._export_state(), mixer=self.mixer._export_state()) - storage.save(file_name, data) + storage.dump(file_name, data) logger.debug('Save state done.') def _load_state(self, coverage): diff --git a/mopidy/internal/storage.py b/mopidy/internal/storage.py index faa53d57..660b22ae 100644 --- a/mopidy/internal/storage.py +++ b/mopidy/internal/storage.py @@ -35,7 +35,7 @@ def load(path): return {} -def save(path, data): +def dump(path, data): """ Serialize data to file. diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 378cab75..1a0307e5 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -9,7 +9,7 @@ import sys import mopidy from mopidy import compat, local, models -from mopidy import internal +from mopidy.internal import storage as internal_storage from mopidy.internal import timer from mopidy.local import search, storage, translator @@ -99,7 +99,7 @@ class JsonLibrary(local.Library): self._json_file) self._tracks = {} else: - library = internal.storage.load(self._json_file) + library = internal_storage.load(self._json_file) self._tracks = dict((t.uri, t) for t in library.get('tracks', [])) with timer.time_logger('Building browse cache'): @@ -167,7 +167,7 @@ class JsonLibrary(local.Library): self._tracks.pop(uri, None) def close(self): - internal.storage.save(self._json_file, + internal_storage.dump(self._json_file, {'version': mopidy.__version__, 'tracks': self._tracks.values()}) diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 9d71fcef..a83ae93e 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -81,7 +81,7 @@ class CoreActorExportRestoreTest(unittest.TestCase): file_path = os.path.join(self.temp_dir, 'core', 'state.json.gz') data = {} - storage.save(file_path, data) + storage.dump(file_path, data) self.assertTrue(os.path.isfile(file_path), 'missing persistent file') self.core.setup().get() From 67044cecabe326146377955cd6126fbbb345080a Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 31 Mar 2016 22:07:53 +0200 Subject: [PATCH 038/118] Remove 'TODO: check if playable' We don't need to as each backend a title is still playable. The backend must be able to handle unplayable title. Replace for loop with arrar.extend() --- mopidy/core/tracklist.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index c6dbda6a..bff0190e 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -667,13 +667,7 @@ class TracklistController(object): self.set_repeat(state.repeat) self.set_single(state.single) if 'tracklist' in coverage: - if state.next_tlid > self._next_tlid: - self._next_tlid = state.next_tlid + self._next_tlid = max(state.next_tlid, self._next_tlid) self._tl_tracks = [] - for track in state.tl_tracks: - # TODO: check if any backend will play the track. - # Could be an issue with music streaming services - # (login), disabled extensions and automatically - # generated playlists (pandora). - self._tl_tracks.append(track) + self._tl_tracks.extend(state.tl_tracks) self._increase_version() From 01201d142c2a600f87a573d5b3fca7cea7775ce0 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 31 Mar 2016 23:17:29 +0200 Subject: [PATCH 039/118] Renamed member in HistoryController - add 'play-always' and 'play-last' to docstring for _load_state. - renamed _start_at_pos to _start_at_position - changed 'if const == value:' to 'if value == const:' - changed info text to 'Limiting history' --- mopidy/core/actor.py | 3 ++- mopidy/core/history.py | 5 ++--- mopidy/core/playback.py | 15 +++++++-------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 7b457ac1..e6c76789 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -197,7 +197,8 @@ class Core( - 'tracklist' fill the tracklist - 'mode' set tracklist properties (consume, random, repeat, single) - - 'autoplay' start playing ('tracklist' also required) + - 'play-last' restore play state ('tracklist' also required) + - 'play-always' start playing ('tracklist' also required) - 'mixer' set mixer volume and mute state - 'history' restore history diff --git a/mopidy/core/history.py b/mopidy/core/history.py index a6b4c817..14d847b3 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -70,7 +70,7 @@ class HistoryController(object): timestamp=timestamp, track=track)) count += 1 if count_max < count: - logger.info('Limit history to %s tracks.', count_max) + logger.info('Limiting history to %s tracks.', count_max) break return HistoryState(history=history_list) @@ -79,5 +79,4 @@ class HistoryController(object): if state: if 'history' in coverage: self._history = [] - for htrack in state.history: - self._history.append((htrack.timestamp, htrack.track)) + self._history = [(h.timestamp, h.track) for h in state.history] diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index b569aebf..3df18b0b 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -29,7 +29,7 @@ class PlaybackController(object): self._last_position = None self._previous = False - self._start_at_pos = None + self._start_at_position = None self._start_paused = False if self._audio: @@ -229,9 +229,9 @@ class PlaybackController(object): self.set_state(PlaybackState.PLAYING) self._trigger_track_playback_started() seek_ok = False - if self._start_at_pos: - seek_ok = self.seek(self._start_at_pos) - self._start_at_pos = None + if self._start_at_position: + seek_ok = self.seek(self._start_at_position) + self._start_at_position = None if not seek_ok and self._start_paused: self.pause() self._start_paused = False @@ -615,9 +615,8 @@ class PlaybackController(object): if 'play-last' in coverage: new_state = state.state if state.tlid is not None: - if PlaybackState.PAUSED == new_state: + if new_state == PlaybackState.PAUSED: self._start_paused = True - if (PlaybackState.PLAYING == new_state or - PlaybackState.PAUSED == new_state): - self._start_at_pos = state.position + if new_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): + self._start_at_position = state.position self.play(tlid=state.tlid) From 44b7974f7919c6f9802d664c68c4662149eae960 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 31 Mar 2016 23:20:26 +0200 Subject: [PATCH 040/118] Restore mixer.volume/mute before starting playback --- mopidy/core/actor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index e6c76789..77bfdd67 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -220,8 +220,8 @@ class Core( self.history._restore_state(core_state.history, coverage) self.tracklist._restore_state(core_state.tracklist, coverage) # playback after tracklist - self.playback._restore_state(core_state.playback, coverage) self.mixer._restore_state(core_state.mixer, coverage) + self.playback._restore_state(core_state.playback, coverage) logger.debug('Load state done.') From e9e9646d61b5e46c9424c1b0f6eb3944b0e750fa Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 31 Mar 2016 23:24:25 +0200 Subject: [PATCH 041/118] Removed unnecessary docstrings. --- mopidy/core/history.py | 3 --- mopidy/core/mixer.py | 2 -- mopidy/core/playback.py | 2 -- mopidy/core/tracklist.py | 2 -- 4 files changed, 9 deletions(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 14d847b3..d0c936ee 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -59,8 +59,6 @@ class HistoryController(object): return copy.copy(self._history) def _export_state(self): - """Internal method for :class:`mopidy.Core`.""" - # 500 tracks a 3 minutes -> 24 hours history count_max = 500 count = 1 @@ -75,7 +73,6 @@ class HistoryController(object): return HistoryState(history=history_list) def _restore_state(self, state, coverage): - """Internal method for :class:`mopidy.Core`.""" if state: if 'history' in coverage: self._history = [] diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 3d9b7003..20e1fb56 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -102,12 +102,10 @@ class MixerController(object): return False def _export_state(self): - """Internal method for :class:`mopidy.Core`.""" return MixerState(volume=self.get_volume(), mute=self.get_mute()) def _restore_state(self, state, coverage): - """Internal method for :class:`mopidy.Core`.""" if state: if 'mixer' in coverage: if state.volume: diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 3df18b0b..7d597d71 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -600,14 +600,12 @@ class PlaybackController(object): listener.CoreListener.send('seeked', time_position=time_position) def _export_state(self): - """Internal method for :class:`mopidy.Core`.""" return models.PlaybackState( tlid=self.get_current_tlid(), position=self.get_time_position(), state=self.get_state()) def _restore_state(self, state, coverage): - """Internal method for :class:`mopidy.Core`.""" if state: new_state = None if 'play-always' in coverage: diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index bff0190e..61183438 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -649,7 +649,6 @@ class TracklistController(object): listener.CoreListener.send('options_changed') def _export_state(self): - """Internal method for :class:`mopidy.Core`.""" return TracklistState( tl_tracks=self._tl_tracks, next_tlid=self._next_tlid, @@ -659,7 +658,6 @@ class TracklistController(object): single=self.get_single()) def _restore_state(self, state, coverage): - """Internal method for :class:`mopidy.Core`.""" if state: if 'mode' in coverage: self.set_consume(state.consume) From eb930a1679d8bcb12337afc9b4c1728917a66b4b Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Thu, 31 Mar 2016 23:34:57 +0200 Subject: [PATCH 042/118] Catch exception when deleting persistent file. --- mopidy/core/actor.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 77bfdd67..de6c64d0 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -211,8 +211,11 @@ class Core( data = storage.load(file_name) - # Try only once. If something goes wrong, the next start is clean. - os.remove(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'] From df0d534a56fd10682816b87f8f3145ee4c7162ac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 1 Apr 2016 08:12:29 +0200 Subject: [PATCH 043/118] docs: Depend on sphinx_rtd_theme As of Sphinx 1.4, it is no longer a transitive dependency, so we need to depend on it explicitly. --- docs/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index c75793d9..62c7e3e5 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ Sphinx >= 1.0 +sphinx_rtd_theme pygraphviz From cf5dfccee225b9bade6ae438bd3b1cd05b20b39f Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Fri, 1 Apr 2016 19:13:26 +0200 Subject: [PATCH 044/118] Rename PlaybackState.position to time_position. --- mopidy/core/playback.py | 4 ++-- mopidy/internal/models.py | 6 +++--- tests/core/test_playback.py | 6 +++--- tests/internal/test_models.py | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 7d597d71..ed6acc4b 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -602,7 +602,7 @@ class PlaybackController(object): def _export_state(self): return models.PlaybackState( tlid=self.get_current_tlid(), - position=self.get_time_position(), + time_position=self.get_time_position(), state=self.get_state()) def _restore_state(self, state, coverage): @@ -616,5 +616,5 @@ class PlaybackController(object): if new_state == PlaybackState.PAUSED: self._start_paused = True if new_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): - self._start_at_position = state.position + self._start_at_position = state.time_position self.play(tlid=state.tlid) diff --git a/mopidy/internal/models.py b/mopidy/internal/models.py index b66e0504..7d0ed6b1 100644 --- a/mopidy/internal/models.py +++ b/mopidy/internal/models.py @@ -60,8 +60,8 @@ class PlaybackState(ValidatedImmutableObject): :param tlid: current track tlid :type tlid: int - :param position: play position - :type position: int + :param time_position: play position + :type time_position: int :param state: playback state :type state: :class:`validation.PLAYBACK_STATES` """ @@ -70,7 +70,7 @@ class PlaybackState(ValidatedImmutableObject): tlid = fields.Integer(min=1) # The playback position. Read-only. - position = fields.Integer(min=0) + time_position = fields.Integer(min=0) # The playback state. Read-only. state = fields.Field(choices=validation.PLAYBACK_STATES) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 7a130bad..909e999c 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -1141,7 +1141,7 @@ class TesetCorePlaybackExportRestore(BaseTest): self.replay_events() state = PlaybackState( - position=0, state='playing', tlid=tl_tracks[1].tlid) + time_position=0, state='playing', tlid=tl_tracks[1].tlid) value = self.core.playback._export_state() self.assertEqual(state, value) @@ -1154,7 +1154,7 @@ class TesetCorePlaybackExportRestore(BaseTest): self.assertEqual('stopped', self.core.playback.get_state()) state = PlaybackState( - position=0, state='playing', tlid=tl_tracks[2].tlid) + time_position=0, state='playing', tlid=tl_tracks[2].tlid) coverage = ['play-always'] self.core.playback._restore_state(state, coverage) self.replay_events() @@ -1171,7 +1171,7 @@ class TesetCorePlaybackExportRestore(BaseTest): self.assertEqual('stopped', self.core.playback.get_state()) state = PlaybackState( - position=0, state='playing', tlid=tl_tracks[2].tlid) + time_position=0, state='playing', tlid=tl_tracks[2].tlid) coverage = ['other'] self.core.playback._restore_state(state, coverage) self.replay_events() diff --git a/tests/internal/test_models.py b/tests/internal/test_models.py index 9c181bdd..f780ab98 100644 --- a/tests/internal/test_models.py +++ b/tests/internal/test_models.py @@ -78,16 +78,16 @@ class MixerStateTest(unittest.TestCase): class PlaybackStateTest(unittest.TestCase): def test_position(self): - position = 123456 - result = PlaybackState(position=position) - self.assertEqual(result.position, position) + time_position = 123456 + result = PlaybackState(time_position=time_position) + self.assertEqual(result.time_position, time_position) with self.assertRaises(AttributeError): - result.position = None + result.time_position = None def test_position_invalid(self): - position = -1 + time_position = -1 with self.assertRaises(ValueError): - PlaybackState(position=position) + PlaybackState(time_position=time_position) def test_tl_track(self): tlid = 42 From e9db19a3526307cdffe362569ee864033ae2dea6 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Fri, 1 Apr 2016 19:39:48 +0200 Subject: [PATCH 045/118] Handle all path- and file-names as bytestings --- mopidy/core/actor.py | 4 ++-- mopidy/internal/storage.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index de6c64d0..32b5f47f 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -174,7 +174,7 @@ class Core( Save current state to disk. """ - file_name = os.path.join(self._get_data_dir(), b'state.json.gz') + file_name = bytes(os.path.join(self._get_data_dir(), b'state.json.gz')) logger.info('Save state to %s', file_name) data = {} @@ -206,7 +206,7 @@ class Core( :type coverage: list of string (see above) """ - file_name = os.path.join(self._get_data_dir(), b'state.json.gz') + file_name = bytes(os.path.join(self._get_data_dir(), b'state.json.gz')) logger.info('Load state from %s', file_name) data = storage.load(file_name) diff --git a/mopidy/internal/storage.py b/mopidy/internal/storage.py index 660b22ae..3b7106a0 100644 --- a/mopidy/internal/storage.py +++ b/mopidy/internal/storage.py @@ -17,7 +17,7 @@ def load(path): Deserialize data from file. :param path: full path to import file - :type path: str + :type path: bytes :return: deserialized data :rtype: dict """ @@ -40,7 +40,7 @@ def dump(path, data): Serialize data to file. :param path: full path to export file - :type path: str + :type path: bytes :param data: dictionary containing data to save :type data: dict """ From 6e33bbcadd1a6101b30a120d19c01e40d76d5590 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Fri, 1 Apr 2016 19:50:37 +0200 Subject: [PATCH 046/118] Replace restore/export with load/save Rename _restore_state to _load_state Rename _export_state to _save_state --- mopidy/core/actor.py | 16 ++++++++-------- mopidy/core/history.py | 4 ++-- mopidy/core/mixer.py | 4 ++-- mopidy/core/playback.py | 4 ++-- mopidy/core/tracklist.py | 4 ++-- tests/core/test_actor.py | 4 ++-- tests/core/test_history.py | 8 ++++---- tests/core/test_mixer.py | 16 ++++++++-------- tests/core/test_playback.py | 10 +++++----- tests/core/test_tracklist.py | 12 ++++++------ 10 files changed, 41 insertions(+), 41 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 32b5f47f..bd77b37e 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -180,10 +180,10 @@ class Core( data = {} data['version'] = mopidy.__version__ data['state'] = CoreState( - tracklist=self.tracklist._export_state(), - history=self.history._export_state(), - playback=self.playback._export_state(), - mixer=self.mixer._export_state()) + 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('Save state done.') @@ -220,11 +220,11 @@ class Core( if 'state' in data: core_state = data['state'] validation.check_instance(core_state, CoreState) - self.history._restore_state(core_state.history, coverage) - self.tracklist._restore_state(core_state.tracklist, coverage) + 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.mixer._restore_state(core_state.mixer, coverage) - self.playback._restore_state(core_state.playback, coverage) + self.playback._load_state(core_state.playback, coverage) logger.debug('Load state done.') diff --git a/mopidy/core/history.py b/mopidy/core/history.py index d0c936ee..da688980 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -58,7 +58,7 @@ class HistoryController(object): """ return copy.copy(self._history) - def _export_state(self): + def _save_state(self): # 500 tracks a 3 minutes -> 24 hours history count_max = 500 count = 1 @@ -72,7 +72,7 @@ class HistoryController(object): break return HistoryState(history=history_list) - def _restore_state(self, state, coverage): + def _load_state(self, state, coverage): if state: if 'history' in coverage: self._history = [] diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 20e1fb56..15fafad1 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -101,11 +101,11 @@ class MixerController(object): return False - def _export_state(self): + def _save_state(self): return MixerState(volume=self.get_volume(), mute=self.get_mute()) - def _restore_state(self, state, coverage): + def _load_state(self, state, coverage): if state: if 'mixer' in coverage: if state.volume: diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ed6acc4b..0dd73714 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -599,13 +599,13 @@ class PlaybackController(object): logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) - def _export_state(self): + def _save_state(self): return models.PlaybackState( tlid=self.get_current_tlid(), time_position=self.get_time_position(), state=self.get_state()) - def _restore_state(self, state, coverage): + def _load_state(self, state, coverage): if state: new_state = None if 'play-always' in coverage: diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 61183438..2700bef5 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -648,7 +648,7 @@ class TracklistController(object): logger.debug('Triggering options changed event') listener.CoreListener.send('options_changed') - def _export_state(self): + def _save_state(self): return TracklistState( tl_tracks=self._tl_tracks, next_tlid=self._next_tlid, @@ -657,7 +657,7 @@ class TracklistController(object): repeat=self.get_repeat(), single=self.get_single()) - def _restore_state(self, state, coverage): + def _load_state(self, state, coverage): if state: if 'mode' in coverage: self.set_consume(state.consume) diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index a83ae93e..a22ed642 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -69,11 +69,11 @@ class CoreActorExportRestoreTest(unittest.TestCase): pykka.ActorRegistry.stop_all() shutil.rmtree(self.temp_dir) - def test_export_state(self): + def test_save_state(self): self.core.teardown().get() # TODO: implement meaningful test - def test_restore_state(self): + def test_load_state(self): self.core.setup().get() # TODO: implement meaningful test diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 65babde8..84f8b1b1 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -68,7 +68,7 @@ class CoreHistoryExportRestoreTest(unittest.TestCase): self.history._add_track(self.tracks[2]) self.history._add_track(self.tracks[1]) - value = self.history._export_state() + value = self.history._save_state() self.assertEqual(len(value.history), 2) # last in, first out @@ -81,7 +81,7 @@ class CoreHistoryExportRestoreTest(unittest.TestCase): HistoryTrack(timestamp=45, track=self.refs[2]), HistoryTrack(timestamp=56, track=self.refs[1])]) coverage = ['history'] - self.history._restore_state(state, coverage) + self.history._load_state(state, coverage) hist = self.history.get_history() self.assertEqual(len(hist), 3) @@ -100,7 +100,7 @@ class CoreHistoryExportRestoreTest(unittest.TestCase): def test_import_invalid_type(self): with self.assertRaises(TypeError): - self.history._restore_state(11, None) + self.history._load_state(11, None) def test_import_none(self): - self.history._restore_state(None, None) + self.history._load_state(None, None) diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 17ecdfb5..be4b314c 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -169,7 +169,7 @@ class CoreMixerExportRestoreTest(unittest.TestCase): target = MixerState(volume=volume, mute=mute) self.core.mixer.set_volume(volume) self.core.mixer.set_mute(mute) - value = self.core.mixer._export_state() + value = self.core.mixer._save_state() self.assertEqual(target, value) def test_export_unmute(self): @@ -178,7 +178,7 @@ class CoreMixerExportRestoreTest(unittest.TestCase): target = MixerState(volume=volume, mute=mute) self.core.mixer.set_volume(volume) self.core.mixer.set_mute(mute) - value = self.core.mixer._export_state() + value = self.core.mixer._save_state() self.assertEqual(target, value) def test_import(self): @@ -186,7 +186,7 @@ class CoreMixerExportRestoreTest(unittest.TestCase): volume = 45 target = MixerState(volume=volume) coverage = ['mixer'] - self.core.mixer._restore_state(target, coverage) + self.core.mixer._load_state(target, coverage) self.assertEqual(volume, self.core.mixer.get_volume()) def test_import_not_covered(self): @@ -194,7 +194,7 @@ class CoreMixerExportRestoreTest(unittest.TestCase): self.core.mixer.set_mute(True) target = MixerState(volume=56, mute=False) coverage = ['other'] - self.core.mixer._restore_state(target, coverage) + self.core.mixer._load_state(target, coverage) self.assertEqual(21, self.core.mixer.get_volume()) self.assertEqual(True, self.core.mixer.get_mute()) @@ -203,7 +203,7 @@ class CoreMixerExportRestoreTest(unittest.TestCase): self.assertEqual(False, self.core.mixer.get_mute()) target = MixerState(mute=True) coverage = ['mixer'] - self.core.mixer._restore_state(target, coverage) + self.core.mixer._load_state(target, coverage) self.assertEqual(True, self.core.mixer.get_mute()) def test_import_mute_off(self): @@ -211,12 +211,12 @@ class CoreMixerExportRestoreTest(unittest.TestCase): self.assertEqual(True, self.core.mixer.get_mute()) target = MixerState(mute=False) coverage = ['mixer'] - self.core.mixer._restore_state(target, coverage) + self.core.mixer._load_state(target, coverage) self.assertEqual(False, self.core.mixer.get_mute()) def test_import_invalid_type(self): with self.assertRaises(TypeError): - self.core.mixer._restore_state(11, None) + self.core.mixer._load_state(11, None) def test_import_none(self): - self.core.mixer._restore_state(None, None) + self.core.mixer._load_state(None, None) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 909e999c..3f4c8fdc 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -1142,7 +1142,7 @@ class TesetCorePlaybackExportRestore(BaseTest): state = PlaybackState( time_position=0, state='playing', tlid=tl_tracks[1].tlid) - value = self.core.playback._export_state() + value = self.core.playback._save_state() self.assertEqual(state, value) @@ -1156,7 +1156,7 @@ class TesetCorePlaybackExportRestore(BaseTest): state = PlaybackState( time_position=0, state='playing', tlid=tl_tracks[2].tlid) coverage = ['play-always'] - self.core.playback._restore_state(state, coverage) + self.core.playback._load_state(state, coverage) self.replay_events() self.assertEqual('playing', self.core.playback.get_state()) @@ -1173,7 +1173,7 @@ class TesetCorePlaybackExportRestore(BaseTest): state = PlaybackState( time_position=0, state='playing', tlid=tl_tracks[2].tlid) coverage = ['other'] - self.core.playback._restore_state(state, coverage) + self.core.playback._load_state(state, coverage) self.replay_events() self.assertEqual('stopped', self.core.playback.get_state()) @@ -1182,10 +1182,10 @@ class TesetCorePlaybackExportRestore(BaseTest): def test_import_invalid_type(self): with self.assertRaises(TypeError): - self.core.playback._restore_state(11, None) + self.core.playback._load_state(11, None) def test_import_none(self): - self.core.playback._restore_state(None, None) + self.core.playback._load_state(None, None) class TestBug1352Regression(BaseTest): diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 5e9170b2..4cd13d1e 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -223,7 +223,7 @@ class TracklistExportRestoreTest(unittest.TestCase): random=False, next_tlid=next_tlid, tl_tracks=tl_tracks) - value = self.core.tracklist._export_state() + value = self.core.tracklist._save_state() self.assertEqual(target, value) def test_import(self): @@ -235,7 +235,7 @@ class TracklistExportRestoreTest(unittest.TestCase): next_tlid=12, tl_tracks=self.tl_tracks) coverage = ['mode', 'tracklist'] - self.core.tracklist._restore_state(target, coverage) + self.core.tracklist._load_state(target, coverage) self.assertEqual(False, self.core.tracklist.get_consume()) self.assertEqual(True, self.core.tracklist.get_repeat()) self.assertEqual(True, self.core.tracklist.get_single()) @@ -259,7 +259,7 @@ class TracklistExportRestoreTest(unittest.TestCase): next_tlid=12, tl_tracks=self.tl_tracks) coverage = ['mode'] - self.core.tracklist._restore_state(target, coverage) + self.core.tracklist._load_state(target, coverage) self.assertEqual(False, self.core.tracklist.get_consume()) self.assertEqual(True, self.core.tracklist.get_repeat()) self.assertEqual(True, self.core.tracklist.get_single()) @@ -278,7 +278,7 @@ class TracklistExportRestoreTest(unittest.TestCase): next_tlid=12, tl_tracks=self.tl_tracks) coverage = ['tracklist'] - self.core.tracklist._restore_state(target, coverage) + self.core.tracklist._load_state(target, coverage) self.assertEqual(False, self.core.tracklist.get_consume()) self.assertEqual(False, self.core.tracklist.get_repeat()) self.assertEqual(False, self.core.tracklist.get_single()) @@ -290,7 +290,7 @@ class TracklistExportRestoreTest(unittest.TestCase): def test_import_invalid_type(self): with self.assertRaises(TypeError): - self.core.tracklist._restore_state(11, None) + self.core.tracklist._load_state(11, None) def test_import_none(self): - self.core.tracklist._restore_state(None, None) + self.core.tracklist._load_state(None, None) From 6ee36752bd02aa3376bde76d30121a0fb9a42e41 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Fri, 1 Apr 2016 22:16:05 +0200 Subject: [PATCH 047/118] Test whole save/load state. --- tests/core/test_actor.py | 94 +++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index a22ed642..62c0ba2b 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -9,8 +9,12 @@ import mock import pykka +import mopidy + from mopidy.core import Core -from mopidy.internal import storage, versioning +from mopidy.internal import models, storage, versioning +from mopidy.models import Track +from tests import dummy_mixer class CoreActorTest(unittest.TestCase): @@ -52,6 +56,8 @@ class CoreActorExportRestoreTest(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.mkdtemp() + self.state_file = os.path.join(self.temp_dir, 'core', 'state.json.gz') + config = { 'core': { 'max_tracklist_length': 10000, @@ -62,29 +68,87 @@ class CoreActorExportRestoreTest(unittest.TestCase): os.mkdir(os.path.join(self.temp_dir, 'core')) - self.core = Core.start( - config=config, mixer=None, backends=[]).proxy() + self.mixer = dummy_mixer.create_proxy() + self.core = Core( + config=config, mixer=self.mixer, backends=[]) def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() shutil.rmtree(self.temp_dir) def test_save_state(self): - self.core.teardown().get() - # TODO: implement meaningful test + self.core.teardown() - def test_load_state(self): - self.core.setup().get() - # TODO: implement meaningful test + assert os.path.isfile(self.state_file) + reload_data = storage.load(self.state_file) + data = {} + data['version'] = mopidy.__version__ + data['state'] = models.CoreState( + tracklist=models.TracklistState( + repeat=False, random=False, + consume=False, single=False, + next_tlid=1), + history=models.HistoryState(), + playback=models.PlaybackState(state='stopped', + time_position=0), + mixer=models.MixerState()) + assert data == reload_data + + def test_load_state_no_file(self): + self.core.setup() + + assert self.core.mixer.get_mute() is None + assert self.core.mixer.get_volume() is None + assert self.core.tracklist._next_tlid == 1 + assert self.core.tracklist.get_repeat() is False + assert self.core.tracklist.get_random() is False + assert self.core.tracklist.get_consume() is False + assert self.core.tracklist.get_single() is False + assert self.core.tracklist.get_length() == 0 + assert self.core.playback._start_paused is False + assert self.core.playback._start_at_position is None + assert self.core.history.get_length() == 0 + + def test_load_state_with_data(self): + data = {} + data['version'] = mopidy.__version__ + data['state'] = models.CoreState( + tracklist=models.TracklistState( + repeat=True, random=True, + consume=False, single=False, + tl_tracks=[models.TlTrack(tlid=12, track=Track(uri='a:a'))], + next_tlid=14), + history=models.HistoryState(history=[ + models.HistoryTrack( + timestamp=12, + track=models.Ref.track(uri='a:a', name='a')), + models.HistoryTrack( + timestamp=13, + track=models.Ref.track(uri='a:b', name='b'))]), + playback=models.PlaybackState(tlid=12, state='paused', + time_position=432), + mixer=models.MixerState(mute=True, volume=12)) + storage.dump(self.state_file, data) + + self.core.setup() + + assert self.core.mixer.get_mute() is True + assert self.core.mixer.get_volume() == 12 + assert self.core.tracklist._next_tlid == 14 + assert self.core.tracklist.get_repeat() is True + assert self.core.tracklist.get_random() is True + assert self.core.tracklist.get_consume() is False + assert self.core.tracklist.get_single() is False + assert self.core.tracklist.get_length() == 1 + assert self.core.playback._start_paused is True + assert self.core.playback._start_at_position == 432 + assert self.core.history.get_length() == 2 def test_delete_state_file_on_restore(self): - file_path = os.path.join(self.temp_dir, 'core', 'state.json.gz') - data = {} - storage.dump(file_path, data) - self.assertTrue(os.path.isfile(file_path), 'missing persistent file') + storage.dump(self.state_file, data) + assert os.path.isfile(self.state_file) - self.core.setup().get() + self.core.setup() - self.assertFalse(os.path.isfile(file_path), - 'persistent file has to be deleted') + assert not os.path.isfile(self.state_file) From ac47d254a3a12301ef1f53da559e0684dae06beb Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 2 Apr 2016 15:58:05 +0200 Subject: [PATCH 048/118] Remove dead code for 'play-always' --- mopidy/core/actor.py | 1 - mopidy/core/playback.py | 2 -- tests/core/test_playback.py | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index bd77b37e..fba11d82 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -198,7 +198,6 @@ class Core( - 'tracklist' fill the tracklist - 'mode' set tracklist properties (consume, random, repeat, single) - 'play-last' restore play state ('tracklist' also required) - - 'play-always' start playing ('tracklist' also required) - 'mixer' set mixer volume and mute state - 'history' restore history diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 0dd73714..aa5c8e01 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -608,8 +608,6 @@ class PlaybackController(object): def _load_state(self, state, coverage): if state: new_state = None - if 'play-always' in coverage: - new_state = PlaybackState.PLAYING if 'play-last' in coverage: new_state = state.state if state.tlid is not None: diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 3f4c8fdc..e23c168e 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -1155,7 +1155,7 @@ class TesetCorePlaybackExportRestore(BaseTest): state = PlaybackState( time_position=0, state='playing', tlid=tl_tracks[2].tlid) - coverage = ['play-always'] + coverage = ['play-last'] self.core.playback._load_state(state, coverage) self.replay_events() From a6e33e537f88a6e810b703992e28aa6571529955 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 2 Apr 2016 16:01:09 +0200 Subject: [PATCH 049/118] Correct wrong docstring --- mopidy/internal/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/internal/models.py b/mopidy/internal/models.py index 7d0ed6b1..11d09bf7 100644 --- a/mopidy/internal/models.py +++ b/mopidy/internal/models.py @@ -42,7 +42,7 @@ class MixerState(ValidatedImmutableObject): :param volume: the volume :type volume: int - :param mute: the volume + :param mute: the mute state :type mute: int """ From d93cc1b44dcd35255cd95e848b7f99ac314a8288 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 2 Apr 2016 16:04:45 +0200 Subject: [PATCH 050/118] Remove unnecessary array initialzation --- mopidy/core/history.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index da688980..7f7e4fe9 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -75,5 +75,4 @@ class HistoryController(object): def _load_state(self, state, coverage): if state: if 'history' in coverage: - self._history = [] self._history = [(h.timestamp, h.track) for h in state.history] From 00c47117d56534952de22d810add8e668bf93d6a Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 2 Apr 2016 16:11:47 +0200 Subject: [PATCH 051/118] Test models.MixerState.mute --- tests/internal/test_models.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/internal/test_models.py b/tests/internal/test_models.py index f780ab98..eaa638cb 100644 --- a/tests/internal/test_models.py +++ b/tests/internal/test_models.py @@ -68,6 +68,24 @@ class MixerStateTest(unittest.TestCase): with self.assertRaises(ValueError): MixerState(volume=volume) + def test_mute_false(self): + mute = False + result = MixerState(mute=mute) + self.assertEqual(result.mute, mute) + with self.assertRaises(AttributeError): + result.mute = None + + def test_mute_true(self): + mute = True + result = MixerState(mute=mute) + self.assertEqual(result.mute, mute) + with self.assertRaises(AttributeError): + result.mute = False + + def test_mute_default(self): + result = MixerState() + self.assertEqual(result.mute, False) + def test_to_json_and_back(self): result = MixerState(volume=77) serialized = json.dumps(result, cls=ModelJSONEncoder) From b55996da6a4d70dca5dcba9a81fc9371905382f3 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 2 Apr 2016 16:28:04 +0200 Subject: [PATCH 052/118] Changed wording export/restore to save/load --- mopidy/internal/models.py | 10 +++++----- tests/core/test_actor.py | 2 +- tests/core/test_history.py | 10 +++++----- tests/core/test_mixer.py | 18 +++++++++--------- tests/core/test_playback.py | 12 ++++++------ tests/core/test_tracklist.py | 16 ++++++++-------- 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/mopidy/internal/models.py b/mopidy/internal/models.py index 11d09bf7..67fff40d 100644 --- a/mopidy/internal/models.py +++ b/mopidy/internal/models.py @@ -25,7 +25,7 @@ class HistoryTrack(ValidatedImmutableObject): class HistoryState(ValidatedImmutableObject): """ State of the history controller. - Internally used for import/export of current state. + Internally used for save/load state. :param history: the track history :type history: list of :class:`HistoryTrack` @@ -38,7 +38,7 @@ class HistoryState(ValidatedImmutableObject): class MixerState(ValidatedImmutableObject): """ State of the mixer controller. - Internally used for import/export of current state. + Internally used for save/load state. :param volume: the volume :type volume: int @@ -56,7 +56,7 @@ class MixerState(ValidatedImmutableObject): class PlaybackState(ValidatedImmutableObject): """ State of the playback controller. - Internally used for import/export of current state. + Internally used for save/load state. :param tlid: current track tlid :type tlid: int @@ -80,7 +80,7 @@ class TracklistState(ValidatedImmutableObject): """ State of the tracklist controller. - Internally used for import/export of current state. + Internally used for save/load state. :param repeat: the repeat mode :type repeat: bool @@ -119,7 +119,7 @@ class CoreState(ValidatedImmutableObject): """ State of all Core controller. - Internally used for import/export of current state. + Internally used for save/load state. :param history: State of the history controller :type history: :class:`HistorState` diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 62c0ba2b..b6669b58 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -52,7 +52,7 @@ class CoreActorTest(unittest.TestCase): self.assertEqual(self.core.version, versioning.get_version()) -class CoreActorExportRestoreTest(unittest.TestCase): +class CoreActorSaveLoadStateTest(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.mkdtemp() diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 84f8b1b1..57cc58ee 100644 --- a/tests/core/test_history.py +++ b/tests/core/test_history.py @@ -49,7 +49,7 @@ class PlaybackHistoryTest(unittest.TestCase): self.assertIn(artist.name, ref.name) -class CoreHistoryExportRestoreTest(unittest.TestCase): +class CoreHistorySaveLoadStateTest(unittest.TestCase): def setUp(self): # noqa: N802 self.tracks = [ @@ -64,7 +64,7 @@ class CoreHistoryExportRestoreTest(unittest.TestCase): self.history = HistoryController() - def test_export(self): + def test_save(self): self.history._add_track(self.tracks[2]) self.history._add_track(self.tracks[1]) @@ -75,7 +75,7 @@ class CoreHistoryExportRestoreTest(unittest.TestCase): self.assertEqual(value.history[0].track, self.refs[1]) self.assertEqual(value.history[1].track, self.refs[2]) - def test_import(self): + def test_load(self): state = HistoryState(history=[ HistoryTrack(timestamp=34, track=self.refs[0]), HistoryTrack(timestamp=45, track=self.refs[2]), @@ -98,9 +98,9 @@ class CoreHistoryExportRestoreTest(unittest.TestCase): self.assertEqual(hist[2], (45, self.refs[2])) self.assertEqual(hist[3], (56, self.refs[1])) - def test_import_invalid_type(self): + def test_load_invalid_type(self): with self.assertRaises(TypeError): self.history._load_state(11, None) - def test_import_none(self): + def test_load_none(self): self.history._load_state(None, None) diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index be4b314c..996b7c23 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -157,13 +157,13 @@ class SetMuteBadBackendTest(MockBackendCoreMixerBase): self.assertFalse(self.core.mixer.set_mute(True)) -class CoreMixerExportRestoreTest(unittest.TestCase): +class CoreMixerSaveLoadStateTest(unittest.TestCase): def setUp(self): # noqa: N802 self.mixer = dummy_mixer.create_proxy() self.core = core.Core(mixer=self.mixer, backends=[]) - def test_export_mute(self): + def test_save_mute(self): volume = 32 mute = False target = MixerState(volume=volume, mute=mute) @@ -172,7 +172,7 @@ class CoreMixerExportRestoreTest(unittest.TestCase): value = self.core.mixer._save_state() self.assertEqual(target, value) - def test_export_unmute(self): + def test_save_unmute(self): volume = 33 mute = True target = MixerState(volume=volume, mute=mute) @@ -181,7 +181,7 @@ class CoreMixerExportRestoreTest(unittest.TestCase): value = self.core.mixer._save_state() self.assertEqual(target, value) - def test_import(self): + def test_load(self): self.core.mixer.set_volume(11) volume = 45 target = MixerState(volume=volume) @@ -189,7 +189,7 @@ class CoreMixerExportRestoreTest(unittest.TestCase): self.core.mixer._load_state(target, coverage) self.assertEqual(volume, self.core.mixer.get_volume()) - def test_import_not_covered(self): + def test_load_not_covered(self): self.core.mixer.set_volume(21) self.core.mixer.set_mute(True) target = MixerState(volume=56, mute=False) @@ -198,7 +198,7 @@ class CoreMixerExportRestoreTest(unittest.TestCase): self.assertEqual(21, self.core.mixer.get_volume()) self.assertEqual(True, self.core.mixer.get_mute()) - def test_import_mute_on(self): + def test_load_mute_on(self): self.core.mixer.set_mute(False) self.assertEqual(False, self.core.mixer.get_mute()) target = MixerState(mute=True) @@ -206,7 +206,7 @@ class CoreMixerExportRestoreTest(unittest.TestCase): self.core.mixer._load_state(target, coverage) self.assertEqual(True, self.core.mixer.get_mute()) - def test_import_mute_off(self): + def test_load_mute_off(self): self.core.mixer.set_mute(True) self.assertEqual(True, self.core.mixer.get_mute()) target = MixerState(mute=False) @@ -214,9 +214,9 @@ class CoreMixerExportRestoreTest(unittest.TestCase): self.core.mixer._load_state(target, coverage) self.assertEqual(False, self.core.mixer.get_mute()) - def test_import_invalid_type(self): + def test_load_invalid_type(self): with self.assertRaises(TypeError): self.core.mixer._load_state(11, None) - def test_import_none(self): + def test_load_none(self): self.core.mixer._load_state(None, None) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index e23c168e..e63609bf 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -1132,9 +1132,9 @@ class TestBug1177Regression(unittest.TestCase): b.playback.change_track.assert_called_once_with(track2) -class TesetCorePlaybackExportRestore(BaseTest): +class TestCorePlaybackSaveLoadState(BaseTest): - def test_export(self): + def test_save(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.play(tl_tracks[1]) @@ -1146,7 +1146,7 @@ class TesetCorePlaybackExportRestore(BaseTest): self.assertEqual(state, value) - def test_import(self): + def test_load(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.stop() @@ -1163,7 +1163,7 @@ class TesetCorePlaybackExportRestore(BaseTest): self.assertEqual(tl_tracks[2], self.core.playback.get_current_tl_track()) - def test_import_not_covered(self): + def test_load_not_covered(self): tl_tracks = self.core.tracklist.get_tl_tracks() self.core.playback.stop() @@ -1180,11 +1180,11 @@ class TesetCorePlaybackExportRestore(BaseTest): self.assertEqual(None, self.core.playback.get_current_tl_track()) - def test_import_invalid_type(self): + def test_load_invalid_type(self): with self.assertRaises(TypeError): self.core.playback._load_state(11, None) - def test_import_none(self): + def test_load_none(self): self.core.playback._load_state(None, None) diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 4cd13d1e..120ae1f0 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -180,7 +180,7 @@ class TracklistIndexTest(unittest.TestCase): self.assertEqual(2, self.core.tracklist.index()) -class TracklistExportRestoreTest(unittest.TestCase): +class TracklistSaveLoadStateTest(unittest.TestCase): def setUp(self): # noqa: N802 config = { @@ -211,7 +211,7 @@ class TracklistExportRestoreTest(unittest.TestCase): self.core.playback = mock.Mock(spec=core.PlaybackController) - def test_export(self): + def test_save(self): tl_tracks = self.core.tracklist.add(uris=[ t.uri for t in self.tracks]) consume = True @@ -226,7 +226,7 @@ class TracklistExportRestoreTest(unittest.TestCase): value = self.core.tracklist._save_state() self.assertEqual(target, value) - def test_import(self): + def test_load(self): old_version = self.core.tracklist.get_version() target = TracklistState(consume=False, repeat=True, @@ -245,12 +245,12 @@ class TracklistExportRestoreTest(unittest.TestCase): self.assertEqual(self.tl_tracks, self.core.tracklist.get_tl_tracks()) self.assertGreater(self.core.tracklist.get_version(), old_version) - # after import, adding more tracks must be possible + # after load, adding more tracks must be possible self.core.tracklist.add(uris=[self.tracks[1].uri]) self.assertEqual(13, self.core.tracklist._next_tlid) self.assertEqual(5, self.core.tracklist.get_length()) - def test_import_mode_only(self): + def test_load_mode_only(self): old_version = self.core.tracklist.get_version() target = TracklistState(consume=False, repeat=True, @@ -269,7 +269,7 @@ class TracklistExportRestoreTest(unittest.TestCase): self.assertEqual([], self.core.tracklist.get_tl_tracks()) self.assertEqual(self.core.tracklist.get_version(), old_version) - def test_import_tracklist_only(self): + def test_load_tracklist_only(self): old_version = self.core.tracklist.get_version() target = TracklistState(consume=False, repeat=True, @@ -288,9 +288,9 @@ class TracklistExportRestoreTest(unittest.TestCase): self.assertEqual(self.tl_tracks, self.core.tracklist.get_tl_tracks()) self.assertGreater(self.core.tracklist.get_version(), old_version) - def test_import_invalid_type(self): + def test_load_invalid_type(self): with self.assertRaises(TypeError): self.core.tracklist._load_state(11, None) - def test_import_none(self): + def test_load_none(self): self.core.tracklist._load_state(None, None) From 4693fa7f8ef919d465fe980f089cb57ac43f0018 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 2 Apr 2016 16:55:18 +0200 Subject: [PATCH 053/118] Correct wrong docstring --- mopidy/internal/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/internal/models.py b/mopidy/internal/models.py index 67fff40d..6ff17b5b 100644 --- a/mopidy/internal/models.py +++ b/mopidy/internal/models.py @@ -90,7 +90,7 @@ class TracklistState(ValidatedImmutableObject): :type random: bool :param single: the single mode :type single: bool - :param next_tlid: the id of the next track to play + :param next_tlid: the id for the next added track :type next_tlid: int :param tl_tracks: the list of tracks :type tl_tracks: list of :class:`TlTrack` From 100d7dba7b390a7dc4b557f075598162b27f65fd Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 2 Apr 2016 17:12:31 +0200 Subject: [PATCH 054/118] Reworked if conditions in _load_state --- mopidy/core/history.py | 5 ++--- mopidy/core/mixer.py | 9 ++++----- mopidy/core/playback.py | 16 ++++++---------- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 7f7e4fe9..22adb4a9 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -73,6 +73,5 @@ class HistoryController(object): return HistoryState(history=history_list) def _load_state(self, state, coverage): - if state: - if 'history' in coverage: - self._history = [(h.timestamp, h.track) for h in state.history] + if state and 'history' in coverage: + self._history = [(h.timestamp, h.track) for h in state.history] diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 15fafad1..8707c096 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -106,8 +106,7 @@ class MixerController(object): mute=self.get_mute()) def _load_state(self, state, coverage): - if state: - if 'mixer' in coverage: - if state.volume: - self.set_volume(state.volume) - self.set_mute(state.mute) + if state and 'mixer' in coverage: + self.set_mute(state.mute) + if state.volume: + self.set_volume(state.volume) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index aa5c8e01..1c809656 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -606,13 +606,9 @@ class PlaybackController(object): state=self.get_state()) def _load_state(self, state, coverage): - if state: - new_state = None - if 'play-last' in coverage: - new_state = state.state - if state.tlid is not None: - if new_state == PlaybackState.PAUSED: - self._start_paused = True - if new_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): - self._start_at_position = state.time_position - self.play(tlid=state.tlid) + if state and 'play-last' in coverage and state.tlid is not None: + if state.state == PlaybackState.PAUSED: + self._start_paused = True + if state.state in (PlaybackState.PLAYING, PlaybackState.PAUSED): + self._start_at_position = state.time_position + self.play(tlid=state.tlid) From c2c64620b14aa0360f49212c51820289af6c7503 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Mon, 4 Apr 2016 20:40:56 +0200 Subject: [PATCH 055/118] Ignore position of _on_position_changed callback --- mopidy/core/playback.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ab96171e..96c11b2e 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -230,8 +230,8 @@ class PlaybackController(object): self._seek(self._pending_position) def _on_position_changed(self, position): - if self._pending_position == position: - self._trigger_seeked(position) + if self._pending_position: + self._trigger_seeked(self._pending_position) self._pending_position = None def _on_about_to_finish_callback(self): From 7757d306eaed772ba65e382841602f2777e736b6 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Mon, 4 Apr 2016 21:45:02 +0200 Subject: [PATCH 056/118] Include _pending_position 0 (zero) as valid target --- mopidy/core/playback.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 96c11b2e..da505b22 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -230,7 +230,7 @@ class PlaybackController(object): self._seek(self._pending_position) def _on_position_changed(self, position): - if self._pending_position: + if self._pending_position is not None: self._trigger_seeked(self._pending_position) self._pending_position = None From c24380679f2cde841048ad349391cdf5241fe82e Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 9 Apr 2016 11:10:21 +0200 Subject: [PATCH 057/118] Test only events triggered after seek --- tests/core/test_playback.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 3572800c..34c9d367 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -734,6 +734,7 @@ class EventEmissionTest(BaseTest): self.core.playback.play(tl_tracks[0]) self.trigger_about_to_finish(replay_until='stream_changed') + self.replay_events() listener_mock.reset_mock() self.core.playback.seek(1000) From d87de65f9a27425b24165a31a8653bc93b94ca33 Mon Sep 17 00:00:00 2001 From: Lars Kruse Date: Sat, 28 May 2016 04:57:32 +0200 Subject: [PATCH 058/118] model documentation: update 'composers' and 'performers' type --- mopidy/models/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index f477a323..368739e0 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -192,9 +192,9 @@ class Track(ValidatedImmutableObject): :param album: track album :type album: :class:`Album` :param composers: track composers - :type composers: string + :type composers: list of :class:`Artist` :param performers: track performers - :type performers: string + :type performers: list of :class:`Artist` :param genre: track genre :type genre: string :param track_no: track number in album From 37cd2965521586403b42f25c6643f6111711dbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Chr=C3=A9tien?= Date: Tue, 31 May 2016 15:13:55 +0200 Subject: [PATCH 059/118] mpd: fix protocol for replay_gain_status --- docs/changelog.rst | 3 +++ mopidy/mpd/protocol/playback.py | 2 +- tests/mpd/protocol/test_playback.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 18eaea9f..e47ba9af 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -38,6 +38,9 @@ Bug fix release. where a :confval:`file/media_dirs` path contained non-ASCII characters. (Fixes: :issue:`1345`, PR: :issue:`1493`) +- MPD: Fix MPD protocol for ``replay_gain_status`` command. The actual command + remains unimplemented. (PR: :issue:`1520`) + v2.0.0 (2016-02-15) =================== diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index 7b943930..08e7cded 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -325,7 +325,7 @@ def replay_gain_status(context): Prints replay gain options. Currently, only the variable ``replay_gain_mode`` is returned. """ - return 'off' # TODO + return 'replay_gain_mode: off' # TODO @protocol.commands.add('seek', songpos=protocol.UINT, seconds=protocol.UINT) diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index 9f13fc22..07ece3b0 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -115,7 +115,7 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_replay_gain_status_default(self): self.send_request('replay_gain_status') self.assertInResponse('OK') - self.assertInResponse('off') + self.assertInResponse('replay_gain_mode: off') def test_mixrampdb(self): self.send_request('mixrampdb "10"') From f06e5adfa9f01a9304e08288c8baf0ef652851f0 Mon Sep 17 00:00:00 2001 From: SeppSTA Date: Wed, 1 Jun 2016 13:36:52 +0200 Subject: [PATCH 060/118] http: fix timeout value to seconds --- mopidy/stream/actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 0861b5b0..4a7935fd 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -68,7 +68,7 @@ class StreamLibraryProvider(backend.LibraryProvider): track = tags.convert_tags_to_track(scan_result.tags).replace( uri=uri, length=scan_result.duration) else: - logger.warning('Problem looking up %s: %s', uri) + logger.warning('Problem looking up %s', uri) track = Track(uri=uri) return [track] @@ -142,7 +142,7 @@ def _unwrap_stream(uri, timeout, scanner, requests_session): uri, timeout) return None, None content = http.download( - requests_session, uri, timeout=download_timeout) + requests_session, uri, timeout=download_timeout/1000) if content is None: logger.info( From 18f4c1fa38001bd5ef719d0f0ee4fb7f31613859 Mon Sep 17 00:00:00 2001 From: SeppSTA Date: Wed, 1 Jun 2016 14:05:58 +0200 Subject: [PATCH 061/118] insert missing whitespace --- mopidy/stream/actor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 4a7935fd..1bdd05ca 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -142,7 +142,7 @@ def _unwrap_stream(uri, timeout, scanner, requests_session): uri, timeout) return None, None content = http.download( - requests_session, uri, timeout=download_timeout/1000) + requests_session, uri, timeout=download_timeout / 1000) if content is None: logger.info( From ac92069dd5a9e86e33bcf426b16891f702d1f095 Mon Sep 17 00:00:00 2001 From: ismailof Date: Mon, 6 Jun 2016 21:45:18 +0200 Subject: [PATCH 062/118] Add nextsong/nextsongid to mpd status --- mopidy/mpd/protocol/status.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) mode change 100644 => 100755 mopidy/mpd/protocol/status.py diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py old mode 100644 new mode 100755 index 16e9d013..27ce59f4 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -173,6 +173,7 @@ def status(context): decimal places for millisecond precision. """ tl_track = context.core.playback.get_current_tl_track() + next_tlid = context.core.tracklist.next_tlid() futures = { 'tracklist.length': context.core.tracklist.get_length(), @@ -185,6 +186,8 @@ def status(context): 'playback.state': context.core.playback.get_state(), 'playback.current_tl_track': tl_track, 'tracklist.index': context.core.tracklist.index(tl_track.get()), + 'tracklist.next_tlid': next_tlid, + 'tracklist.next_index': context.core.tracklist.index(next_tlid.get()), 'playback.time_position': context.core.playback.get_time_position(), } pykka.get_all(futures.values()) @@ -199,10 +202,12 @@ def status(context): ('xfade', _status_xfade(futures)), ('state', _status_state(futures)), ] - # TODO: add nextsong and nextsongid if futures['playback.current_tl_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) + if futures['tracklist.next_tlid'].get() is not None: + result.append(('nextsong', _status_nextsongpos(futures))) + result.append(('nextsongid', _status_nextsongid(futures))) if futures['playback.state'].get() in ( PlaybackState.PLAYING, PlaybackState.PAUSED): result.append(('time', _status_time(futures))) @@ -247,6 +252,10 @@ def _status_single(futures): return int(futures['tracklist.single'].get()) +def _status_songpos(futures): + return futures['tracklist.index'].get() + + def _status_songid(futures): current_tl_track = futures['playback.current_tl_track'].get() if current_tl_track is not None: @@ -255,8 +264,12 @@ def _status_songid(futures): return _status_songpos(futures) -def _status_songpos(futures): - return futures['tracklist.index'].get() +def _status_nextsongpos(futures): + return futures['tracklist.next_index'].get() + + +def _status_nextsongid(futures): + return futures['tracklist.next_tlid'].get() def _status_state(futures): From a1c219e25d67af8b94e970296f1a1ff4ee04b431 Mon Sep 17 00:00:00 2001 From: ismailof Date: Mon, 6 Jun 2016 22:59:59 +0200 Subject: [PATCH 063/118] Add nextsong/nextsongid to mpd status --- mopidy/mpd/protocol/status.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index 27ce59f4..d4aeb36f 100755 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -173,7 +173,7 @@ def status(context): decimal places for millisecond precision. """ tl_track = context.core.playback.get_current_tl_track() - next_tlid = context.core.tracklist.next_tlid() + next_tlid = context.core.tracklist.get_next_tlid() futures = { 'tracklist.length': context.core.tracklist.get_length(), @@ -187,7 +187,8 @@ def status(context): 'playback.current_tl_track': tl_track, 'tracklist.index': context.core.tracklist.index(tl_track.get()), 'tracklist.next_tlid': next_tlid, - 'tracklist.next_index': context.core.tracklist.index(next_tlid.get()), + 'tracklist.next_index': context.core.tracklist.index( + tlid=next_tlid.get()), 'playback.time_position': context.core.playback.get_time_position(), } pykka.get_all(futures.values()) From 19818d3f68a57d06d9251ba2371156878ef926f4 Mon Sep 17 00:00:00 2001 From: ismailof Date: Mon, 6 Jun 2016 23:20:13 +0200 Subject: [PATCH 064/118] Add MPD nextsong/nextsongid test case --- tests/mpd/test_status.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) mode change 100644 => 100755 tests/mpd/test_status.py diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py old mode 100644 new mode 100755 index 25b8dd72..9450808c --- a/tests/mpd/test_status.py +++ b/tests/mpd/test_status.py @@ -48,9 +48,9 @@ class StatusHandlerTest(unittest.TestCase): def tearDown(self): # noqa: N802 pykka.ActorRegistry.stop_all() - def set_tracklist(self, track): - self.backend.library.dummy_library = [track] - self.core.tracklist.add(uris=[track.uri]).get() + def set_tracklist(self, tracks): + self.backend.library.dummy_library = tracks + self.core.tracklist.add(uris=[track.uri for track in tracks]).get() def test_stats_method(self): result = status.stats(self.context) @@ -154,22 +154,35 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.set_tracklist(Track(uri='dummy:/a')) - + self.set_tracklist([Track(uri='dummy:/a')]) self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('song', result) self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self): - self.set_tracklist(Track(uri='dummy:/a')) + self.set_tracklist([Track(uri='dummy:/a')]) self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('songid', result) self.assertEqual(int(result['songid']), 1) + def test_status_method_when_playlist_loaded_contains_nextsong(self): + self.set_tracklist([Track(uri='dummy:/a'), Track(uri='dummy:/b')]) + self.core.playback.play().get() + result = dict(status.status(self.context)) + self.assertIn('nextsong', result) + self.assertGreaterEqual(int(result['nextsong']), 0) + + def test_status_method_when_playlist_loaded_contains_nextsongid(self): + self.set_tracklist([Track(uri='dummy:/a'), Track(uri='dummy:/b')]) + self.core.playback.play().get() + result = dict(status.status(self.context)) + self.assertIn('nextsongid', result) + self.assertEqual(int(result['nextsongid']), 2) + def test_status_method_when_playing_contains_time_with_no_length(self): - self.set_tracklist(Track(uri='dummy:/a', length=None)) + self.set_tracklist([Track(uri='dummy:/a', length=None)]) self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -179,7 +192,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): - self.set_tracklist(Track(uri='dummy:/a', length=10000)) + self.set_tracklist([Track(uri='dummy:/a', length=10000)]) self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) @@ -189,7 +202,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.set_tracklist(Track(uri='dummy:/a', length=60000)) + self.set_tracklist([Track(uri='dummy:/a', length=60000)]) self.core.playback.play().get() self.core.playback.pause() self.core.playback.seek(59123) @@ -198,7 +211,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.set_tracklist(Track(uri='dummy:/a', length=10000)) + self.set_tracklist([Track(uri='dummy:/a', length=10000)]) self.core.playback.play().get() self.core.playback.pause() result = dict(status.status(self.context)) @@ -206,7 +219,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): - self.set_tracklist(Track(uri='dummy:/a', bitrate=3200)) + self.set_tracklist([Track(uri='dummy:/a', bitrate=3200)]) self.core.playback.play().get() result = dict(status.status(self.context)) self.assertIn('bitrate', result) From 6787a044fc9679440fb40e4d50fddad89b93346f Mon Sep 17 00:00:00 2001 From: ismailof Date: Mon, 6 Jun 2016 23:20:49 +0200 Subject: [PATCH 065/118] Add MPD nextsong/nextsongid test case --- mopidy/mpd/protocol/status.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index d4aeb36f..3d76d35f 100755 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -253,10 +253,6 @@ def _status_single(futures): return int(futures['tracklist.single'].get()) -def _status_songpos(futures): - return futures['tracklist.index'].get() - - def _status_songid(futures): current_tl_track = futures['playback.current_tl_track'].get() if current_tl_track is not None: @@ -265,14 +261,18 @@ def _status_songid(futures): return _status_songpos(futures) -def _status_nextsongpos(futures): - return futures['tracklist.next_index'].get() +def _status_songpos(futures): + return futures['tracklist.index'].get() def _status_nextsongid(futures): return futures['tracklist.next_tlid'].get() +def _status_nextsongpos(futures): + return futures['tracklist.next_index'].get() + + def _status_state(futures): state = futures['playback.state'].get() if state == PlaybackState.PLAYING: From fc26b7304ce99db643ba09fac9368a23d9e93bdc Mon Sep 17 00:00:00 2001 From: ismailof Date: Mon, 6 Jun 2016 23:22:40 +0200 Subject: [PATCH 066/118] chmod correction --- mopidy/mpd/protocol/status.py | 0 tests/mpd/test_status.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 mopidy/mpd/protocol/status.py mode change 100755 => 100644 tests/mpd/test_status.py diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py old mode 100755 new mode 100644 diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py old mode 100755 new mode 100644 From c3eb9b60c0b320a8cb0e76947287851ceae96e1d Mon Sep 17 00:00:00 2001 From: Tom Parker Date: Fri, 10 Jun 2016 12:41:02 +0100 Subject: [PATCH 067/118] If tags date isn't a valid value for Python, skip the tag --- mopidy/audio/tags.py | 14 ++++++++++---- tests/audio/test_tags.py | 8 ++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/mopidy/audio/tags.py b/mopidy/audio/tags.py index 5ae86468..459bb1e3 100644 --- a/mopidy/audio/tags.py +++ b/mopidy/audio/tags.py @@ -44,10 +44,16 @@ gstreamer-GstTagList.html value = taglist.get_value_index(tag, i) if isinstance(value, GLib.Date): - date = datetime.date( - value.get_year(), value.get_month(), value.get_day()) - result[tag].append(date.isoformat().decode('utf-8')) - if isinstance(value, Gst.DateTime): + try: + date = datetime.date( + value.get_year(), value.get_month(), value.get_day()) + result[tag].append(date.isoformat().decode('utf-8')) + except ValueError: + logger.log( + logging.DEBUG, + 'Ignoring dodgy date value: %d-%d-%d', + value.get_year(), value.get_month(), value.get_day()) + elif isinstance(value, Gst.DateTime): result[tag].append(value.to_iso8601_string().decode('utf-8')) elif isinstance(value, bytes): result[tag].append(value.decode('utf-8', 'replace')) diff --git a/tests/audio/test_tags.py b/tests/audio/test_tags.py index 01475124..d85bcc12 100644 --- a/tests/audio/test_tags.py +++ b/tests/audio/test_tags.py @@ -44,6 +44,14 @@ class TestConvertTaglist(object): assert isinstance(result[Gst.TAG_DATE][0], compat.text_type) assert result[Gst.TAG_DATE][0] == '2014-01-07' + def test_date_tag_bad_value(self): + date = GLib.Date.new_dmy(7, 1, 10000) + taglist = self.make_taglist(Gst.TAG_DATE, [date]) + + result = tags.convert_taglist(taglist) + + assert len(result[Gst.TAG_DATE]) == 0 + def test_date_time_tag(self): taglist = self.make_taglist(Gst.TAG_DATE_TIME, [ Gst.DateTime.new_from_iso8601_string(b'2014-01-07 14:13:12') From ffe32ab7c7cbb64997654077ad366b288e5ee751 Mon Sep 17 00:00:00 2001 From: Tom Parker Date: Mon, 13 Jun 2016 22:03:51 +0100 Subject: [PATCH 068/118] Use logger.debug, not logging.log(logging.DEBUG --- mopidy/audio/tags.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/audio/tags.py b/mopidy/audio/tags.py index 459bb1e3..7fabefd6 100644 --- a/mopidy/audio/tags.py +++ b/mopidy/audio/tags.py @@ -49,8 +49,7 @@ gstreamer-GstTagList.html value.get_year(), value.get_month(), value.get_day()) result[tag].append(date.isoformat().decode('utf-8')) except ValueError: - logger.log( - logging.DEBUG, + logger.debug( 'Ignoring dodgy date value: %d-%d-%d', value.get_year(), value.get_month(), value.get_day()) elif isinstance(value, Gst.DateTime): From 95ac2714ffc3e585abb5acf56e885fe75814bdca Mon Sep 17 00:00:00 2001 From: ismailof Date: Sun, 26 Jun 2016 11:18:34 +0200 Subject: [PATCH 069/118] Add changelog entry --- docs/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) mode change 100644 => 100755 docs/changelog.rst diff --git a/docs/changelog.rst b/docs/changelog.rst old mode 100644 new mode 100755 index e47ba9af..1411d347 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -41,6 +41,8 @@ Bug fix release. - MPD: Fix MPD protocol for ``replay_gain_status`` command. The actual command remains unimplemented. (PR: :issue:`1520`) +- MPD: Add ``nextsong`` and ``nextsongid`` to the response of MPD ``status`` command. + (Fixes: :issue:`1133` :issue:`1516`, PR: :issue:`1523`) v2.0.0 (2016-02-15) =================== From 65498485fea09f6ef947160ad81a4f6d56462e69 Mon Sep 17 00:00:00 2001 From: ismailof Date: Sun, 26 Jun 2016 11:19:08 +0200 Subject: [PATCH 070/118] Add changelog entry --- docs/changelog.rst | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 docs/changelog.rst diff --git a/docs/changelog.rst b/docs/changelog.rst old mode 100755 new mode 100644 From d1449bcb6fe28044a0a2ddf79c746f9dc09f2af0 Mon Sep 17 00:00:00 2001 From: Nantas Nardelli Date: Sun, 24 Jul 2016 20:30:48 +0100 Subject: [PATCH 071/118] Properly get track position before change events In particular, this allows to send the right information when: 1. the track finishes and switches to the next one in the list; 2. user presses next / previous The cases of EOS and stop event were already handled properly. Note: we only have GStreamer's `about-to-finish` event to deal with the end of a track, which usually happens a few seconds before the end of the track. We set the position to the length of the track, which is not overridden unless the user generates a relevant callback. --- mopidy/core/playback.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index da505b22..ab0bee5b 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -251,6 +251,10 @@ class PlaybackController(object): 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. + 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 @@ -394,6 +398,10 @@ class PlaybackController(object): 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() From 500ff70b87b11d167c72071fb889fbf56e0dc359 Mon Sep 17 00:00:00 2001 From: Nantas Nardelli Date: Sun, 24 Jul 2016 21:52:15 +0100 Subject: [PATCH 072/118] Add changelog entry for #1534, and fix #1523 typo --- docs/changelog.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1411d347..067adc46 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -42,7 +42,12 @@ Bug fix release. remains unimplemented. (PR: :issue:`1520`) - MPD: Add ``nextsong`` and ``nextsongid`` to the response of MPD ``status`` command. - (Fixes: :issue:`1133` :issue:`1516`, PR: :issue:`1523`) + (Fixes: :issue:`1133`, :issue:`1516`, PR: :issue:`1523`) + +- Core: Correctly record the last position of a track when switching to another + one. Particularly relevant for `mopidy-scrobbler` users, as before it was + essentially unusable. (Fixes: :issue:`1456`, PR: :issue:`1534`) + v2.0.0 (2016-02-15) =================== From 6e603a183b3b492a43c91d0833afc9e5f3a2f6ab Mon Sep 17 00:00:00 2001 From: Nantas Nardelli Date: Sun, 24 Jul 2016 22:26:59 +0100 Subject: [PATCH 073/118] Add TODO about checking length of track when null --- mopidy/core/playback.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index ab0bee5b..0106abf2 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -253,6 +253,8 @@ class PlaybackController(object): # 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) From c199fb1c814eb9e3998a30c0bc1f6cf620c46755 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 25 Jul 2016 21:09:38 +0200 Subject: [PATCH 074/118] lint: Workaround and fix to account for new version of flake8 --- mopidy/__main__.py | 2 +- mopidy/config/__init__.py | 7 +++++-- tests/models/test_fields.py | 2 +- tox.ini | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 86a0c19c..7963900e 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -5,7 +5,7 @@ import os import signal import sys -from mopidy.internal.gi import Gst # noqa: Import to initialize +from mopidy.internal.gi import Gst # noqa: F401 try: # Make GObject's mainloop the event loop for python-dbus diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 21a6a00b..ec5c9a99 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -9,12 +9,15 @@ import re from mopidy import compat from mopidy.compat import configparser from mopidy.config import keyring -from mopidy.config.schemas import * # noqa -from mopidy.config.types import * # noqa +from mopidy.config.schemas import * +from mopidy.config.types import * from mopidy.internal import path, versioning logger = logging.getLogger(__name__) +# flake8: noqa: +# TODO: Update this to be flake8 compliant + _core_schema = ConfigSchema('core') _core_schema['cache_dir'] = Path() _core_schema['config_dir'] = Path() diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py index bf842fd5..3374c822 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals import unittest -from mopidy.models.fields import * # noqa: F403 +from mopidy.models.fields import Collection, Field, Integer, String def create_instance(field): diff --git a/tox.ini b/tox.ini index da6bcc38..b39fc68b 100644 --- a/tox.ini +++ b/tox.ini @@ -37,7 +37,8 @@ commands = sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html [testenv:flake8] deps = flake8 - flake8-import-order +# TODO: Re-enable once https://github.com/PyCQA/flake8-import-order/issues/79 is released. +# flake8-import-order pep8-naming commands = flake8 --show-source --statistics mopidy tests From 9d4b62db1414abf9096d8820943f96117f5bcd85 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 27 Jul 2016 21:43:41 +0200 Subject: [PATCH 075/118] audio: Update scanner to handle sources that only have dynamic pads Fixes #1479 --- docs/changelog.rst | 2 ++ mopidy/audio/scan.py | 49 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index c61071db..9666cd5c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -16,6 +16,8 @@ Feature release. - MPD: Add ``nextsong`` and ``nextsongid`` to the response of MPD ``status`` command. (Fixes: :issue:`1133`, :issue:`1516`, PR: :issue:`1523`) +- Audio: Update scanner to handle sources such as RTSP. (Fixes: :issue:`1479`) + v2.0.1 (UNRELEASED) =================== diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index f99c4489..121fa7d5 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -78,25 +78,52 @@ def _setup_pipeline(uri, proxy_config=None): if not src: raise exceptions.ScannerError('GStreamer can not open: %s' % uri) - typefind = Gst.ElementFactory.make('typefind') - decodebin = Gst.ElementFactory.make('decodebin') - - pipeline = Gst.ElementFactory.make('pipeline') - for e in (src, typefind, decodebin): - pipeline.add(e) - src.link(typefind) - typefind.link(decodebin) - if proxy_config: utils.setup_proxy(src, proxy_config) signals = utils.Signals() + pipeline = Gst.ElementFactory.make('pipeline') + pipeline.add(src) + + if _has_src_pads(src): + _setup_decodebin(src, src.get_static_pad('src'), pipeline, signals) + elif _has_dynamic_src_pad(src): + signals.connect(src, 'pad-added', _setup_decodebin, pipeline, signals) + else: + raise exceptions.ScannerError('No pads found in source element.') + + return pipeline, signals + + +def _has_src_pads(element): + pads = [] + element.iterate_src_pads().foreach(pads.append) + return bool(pads) + + +def _has_dynamic_src_pad(element): + for template in element.get_pad_template_list(): + if template.direction == Gst.PadDirection.SRC: + if template.presence == Gst.PadPresence.SOMETIMES: + return True + return False + + +def _setup_decodebin(element, pad, pipeline, signals): + typefind = Gst.ElementFactory.make('typefind') + decodebin = Gst.ElementFactory.make('decodebin') + + for element in (typefind, decodebin): + pipeline.add(element) + element.sync_state_with_parent() + + pad.link(typefind.get_static_pad('sink')) + typefind.link(decodebin) + signals.connect(typefind, 'have-type', _have_type, decodebin) signals.connect(decodebin, 'pad-added', _pad_added, pipeline) signals.connect(decodebin, 'autoplug-select', _autoplug_select) - return pipeline, signals - def _have_type(element, probability, caps, decodebin): decodebin.set_property('sink-caps', caps) From 5973c0f6b9e3b63e00698b958287827e50422866 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 12 Aug 2016 21:19:43 +0200 Subject: [PATCH 076/118] docs: Deb packaging is done on Debian infrastructure as part of the pkg-mopidy team --- docs/releasing.rst | 92 +--------------------------------------------- 1 file changed, 2 insertions(+), 90 deletions(-) diff --git a/docs/releasing.rst b/docs/releasing.rst index 5bd4dfeb..e7ef251c 100644 --- a/docs/releasing.rst +++ b/docs/releasing.rst @@ -62,93 +62,5 @@ Creating releases #. Spread the word through the topic on #mopidy on IRC, @mopidy on Twitter, and on the mailing list. -#. Update the Debian package. - - -Updating Debian packages -======================== - -This howto is not intended to learn you all the details, just to give someone -already familiar with Debian packaging an overview of how Mopidy's Debian -packages is maintained. - -#. Install the basic packaging tools:: - - sudo apt-get install build-essential git-buildpackage - -#. Create a Wheezy pbuilder env if running on Ubuntu and this the first time. - See :issue:`561` for details about why this is needed:: - - DIST=wheezy sudo git-pbuilder update --mirror=http://mirror.rackspace.com/debian/ --debootstrapopts --keyring=/usr/share/keyrings/debian-archive-keyring.gpg - -#. Check out the ``debian`` branch of the repo:: - - git checkout -t origin/debian - git pull - -#. Merge the latest release tag into the ``debian`` branch:: - - git merge v0.16.0 - -#. Update the ``debian/changelog`` with a "New upstream release" entry:: - - dch -v 0.16.0-0mopidy1 - git add debian/changelog - git commit -m "debian: New upstream release" - -#. Check if any dependencies in ``debian/control`` or similar needs updating. - -#. Install any Build-Deps listed in ``debian/control``. - -#. Build the package and fix any issues repeatedly until the build succeeds and - the Lintian check at the end of the build is satisfactory:: - - git buildpackage -uc -us - - If you are using the pbuilder make sure this command is:: - - sudo git buildpackage -uc -us --git-ignore-new --git-pbuilder --git-dist=wheezy --git-no-pbuilder-autoconf - -#. Install and test newly built package:: - - sudo debi - - Again for pbuilder use:: - - sudo debi --debs-dir /var/cache/pbuilder/result/ - -#. If everything is OK, build the package a final time to tag the package - version:: - - git buildpackage -uc -us --git-tag - - Pbuilder:: - - sudo git buildpackage -uc -us --git-ignore-new --git-pbuilder --git-dist=wheezy --git-no-pbuilder-autoconf --git-tag - -#. Push the changes you've done to the ``debian`` branch and the new tag:: - - git push - git push --tags - -#. If you're building for multiple architectures, checkout the ``debian`` - branch on the other builders and run:: - - git buildpackage -uc -us - - Modify as above to use the pbuilder as needed. - -#. Copy files to the APT server. Make sure to select the correct part of the - repo, e.g. main, contrib, or non-free:: - - scp ../mopidy*_0.16* bonobo.mopidy.com:/srv/apt.mopidy.com/app/incoming/stable/main - -#. Update the APT repo:: - - ssh bonobo.mopidy.com - /srv/apt.mopidy.com/app/update.sh - -#. Test installation from apt.mopidy.com:: - - sudo apt-get update - sudo apt-get dist-upgrade +#. Notify distribution packagers, including but not limited to: Debian, Arch + Linux, Homebrew. From 633d87bdbdce054cbb6d4afa8cf06af6db42d25d Mon Sep 17 00:00:00 2001 From: Naglis Jonaitis Date: Wed, 17 Aug 2016 21:31:38 +0300 Subject: [PATCH 077/118] docs: Remove link to the Mopidy-LeftAsRain plugin --- docs/ext/backends.rst | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/ext/backends.rst b/docs/ext/backends.rst index 2349006b..9e5f4a9c 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -97,15 +97,6 @@ Extension for playing music and audio from the `Internet Archive `_. -Mopidy-LeftAsRain -================= - -https://github.com/naglis/mopidy-leftasrain - -Extension for playing music from the `leftasrain.com -`_ music blog. - - Mopidy-Local ============ From bee9bd3d2383926a8257ad6e37367bf21b5719db Mon Sep 17 00:00:00 2001 From: Alexander Jaworowski Date: Sun, 21 Aug 2016 21:40:17 +0200 Subject: [PATCH 078/118] alexjaw/fix/1512-inconsistent-playlist-state-with-repeat-and-consume --- mopidy/core/tracklist.py | 7 ++++++- tests/core/test_playback.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 6d7ceeb7..5c14b4fb 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -325,7 +325,12 @@ class TracklistController(object): next_index += 1 if self.get_repeat(): - next_index %= len(self._tl_tracks) + # Fix for bug 1512 + # Return None if consume mode and there is only one track (left) in the list + if self.get_consume() and len(self._tl_tracks) == 1: + return None + else: + next_index %= len(self._tl_tracks) elif next_index >= len(self._tl_tracks): return None diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 34c9d367..9f6baa42 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -430,6 +430,24 @@ class TestConsumeHandling(BaseTest): self.assertNotIn(tl_track, self.core.tracklist.get_tl_tracks()) + def test_next_in_consume_and_repeat_mode_returns_none_on_last_track(self): + # Testing for bug 1512 + self.core.playback.play() + self.core.tracklist.set_consume(True) + self.core.tracklist.set_repeat(True) + self.replay_events() + + # Play through the list + for track in self.core.tracklist.get_tl_tracks(): + self.core.playback.next() + self.replay_events() + + # Try repeat, player state should remain as stopped (all tracks consumed) + self.core.playback.next() + self.replay_events() + + self.assertEqual(self.playback.get_state(), 'stopped') + class TestCurrentAndPendingTlTrack(BaseTest): From 95deb779396586308486631ecc19262bd5b1fe96 Mon Sep 17 00:00:00 2001 From: Alexander Jaworowski Date: Sun, 21 Aug 2016 21:59:10 +0200 Subject: [PATCH 079/118] alexjaw/fix/1512-inconsistent-playlist-state-with-repeat-and-consume --- mopidy/core/tracklist.py | 3 ++- tests/core/test_playback.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 5c14b4fb..8bd31b4f 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -326,7 +326,8 @@ class TracklistController(object): if self.get_repeat(): # Fix for bug 1512 - # Return None if consume mode and there is only one track (left) in the list + # Return None if consume mode and there is only one track (left) + # in the list if self.get_consume() and len(self._tl_tracks) == 1: return None else: diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 9f6baa42..5befa364 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -442,7 +442,7 @@ class TestConsumeHandling(BaseTest): self.core.playback.next() self.replay_events() - # Try repeat, player state should remain as stopped (all tracks consumed) + # Try repeat, player state remain stopped (all tracks consumed) self.core.playback.next() self.replay_events() From af540aee37c98a94188dbb63306d42a4871744c9 Mon Sep 17 00:00:00 2001 From: Don Armstrong Date: Fri, 26 Aug 2016 05:12:45 -0700 Subject: [PATCH 080/118] Do not scan first-level dirs starting with . - Currently the code skips directories with level > 1 starting with ., but does not skip first-level directories starting with .; fix this by matching for files/directories which start with . in addition to those that contain /. --- mopidy/local/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index ead874a0..862d1d0b 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -118,7 +118,7 @@ class ScanCommand(commands.Command): relpath = os.path.relpath(abspath, media_dir) uri = translator.path_to_local_track_uri(relpath) - if b'/.' in relpath: + if b'/.' in relpath or relpath.startswith(b'.'): logger.debug('Skipped %s: Hidden directory/file.', uri) elif relpath.lower().endswith(excluded_file_extensions): logger.debug('Skipped %s: File extension excluded.', uri) From 46dd36d91452d00b4c95cb004b5c8c5f83105449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20L=C3=BCtjen?= Date: Fri, 16 Sep 2016 20:28:10 +0200 Subject: [PATCH 081/118] Optimize _tl_tracks initialization --- mopidy/core/tracklist.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 2700bef5..37930f79 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -666,6 +666,5 @@ class TracklistController(object): self.set_single(state.single) if 'tracklist' in coverage: self._next_tlid = max(state.next_tlid, self._next_tlid) - self._tl_tracks = [] - self._tl_tracks.extend(state.tl_tracks) + self._tl_tracks = list(state.tl_tracks) self._increase_version() From 92767e49f9b51f6cc5d80884c23173668331ed33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20L=C3=BCtjen?= Date: Fri, 16 Sep 2016 21:01:10 +0200 Subject: [PATCH 082/118] Fix typos and formatting --- docs/changelog.rst | 2 +- docs/config.rst | 2 +- mopidy/core/actor.py | 27 +++++++++++++-------------- mopidy/core/history.py | 6 +++--- mopidy/internal/storage.py | 2 +- mopidy/local/json.py | 7 ++++--- tests/core/test_actor.py | 5 +++-- 7 files changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5573da6d..851359d8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,7 +11,7 @@ v2.1.0 (UNRELEASED) Feature release. - Core: Mopidy restores its last state when started. Can be enabled by setting - the config value :confval:`core/restore_state` to `true`. + the config value :confval:`core/restore_state` to ``true``. - MPD: Fix MPD protocol for ``replay_gain_status`` command. The actual command remains unimplemented. (PR: :issue:`1520`) diff --git a/docs/config.rst b/docs/config.rst index df8e2a7d..5c1257d7 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -115,7 +115,7 @@ Core config section When set to ``true``, Mopidy restores its last state when started. The restored state includes the tracklist, playback history, - the playback state, and the mixers volume and mute state. + the playback state, the volume, and mute state. Default is ``false``. diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index fba11d82..5f7743eb 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -141,7 +141,7 @@ class Core( CoreListener.send('stream_title_changed', title=title) def setup(self): - """ Do not call this function. It is for internal use at startup.""" + """Do not call this function. It is for internal use at startup.""" try: coverage = [] if self._config and 'restore_state' in self._config['core']: @@ -154,7 +154,7 @@ class Core( logger.warn('Restore state: Unexpected error: %s', str(e)) def teardown(self): - """ Do not call this function. It is for internal use at shutdown.""" + """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']: @@ -164,8 +164,7 @@ class Core( def _get_data_dir(self): # get or create data director for core - data_dir_path = bytes( - os.path.join(self._config['core']['data_dir'], '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 @@ -174,8 +173,8 @@ class Core( Save current state to disk. """ - file_name = bytes(os.path.join(self._get_data_dir(), b'state.json.gz')) - logger.info('Save state to %s', file_name) + file_name = os.path.join(self._get_data_dir(), b'state.json.gz') + logger.info('Saveing state to %s', file_name) data = {} data['version'] = mopidy.__version__ @@ -185,15 +184,15 @@ class Core( playback=self.playback._save_state(), mixer=self.mixer._save_state()) storage.dump(file_name, data) - logger.debug('Save state done.') + logger.debug('Saveing state done') def _load_state(self, 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): + 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) @@ -202,11 +201,11 @@ class Core( - 'history' restore history :param coverage: amount of data to restore - :type coverage: list of string (see above) + :type coverage: list of strings """ - file_name = bytes(os.path.join(self._get_data_dir(), b'state.json.gz')) - logger.info('Load state from %s', file_name) + 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) @@ -224,7 +223,7 @@ class Core( self.mixer._load_state(core_state.mixer, coverage) # playback after tracklist self.playback._load_state(core_state.playback, coverage) - logger.debug('Load state done.') + logger.debug('Loading state done') class Backends(list): diff --git a/mopidy/core/history.py b/mopidy/core/history.py index 22adb4a9..94ee6e87 100644 --- a/mopidy/core/history.py +++ b/mopidy/core/history.py @@ -64,11 +64,11 @@ class HistoryController(object): count = 1 history_list = [] for timestamp, track in self._history: - history_list.append(HistoryTrack( - timestamp=timestamp, track=track)) + history_list.append( + HistoryTrack(timestamp=timestamp, track=track)) count += 1 if count_max < count: - logger.info('Limiting history to %s tracks.', count_max) + logger.info('Limiting history to %s tracks', count_max) break return HistoryState(history=history_list) diff --git a/mopidy/internal/storage.py b/mopidy/internal/storage.py index 3b7106a0..6da53a00 100644 --- a/mopidy/internal/storage.py +++ b/mopidy/internal/storage.py @@ -23,7 +23,7 @@ def load(path): """ # Todo: raise an exception in case of error? if not os.path.isfile(path): - logger.info('File does not exist: %s.', path) + logger.info('File does not exist: %s', path) return {} try: with gzip.open(path, 'rb') as fp: diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 1a0307e5..2e39b68b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -167,9 +167,10 @@ class JsonLibrary(local.Library): self._tracks.pop(uri, None) def close(self): - internal_storage.dump(self._json_file, - {'version': mopidy.__version__, - 'tracks': self._tracks.values()}) + internal_storage.dump(self._json_file, { + 'version': mopidy.__version__, + 'tracks': self._tracks.values() + }) def clear(self): try: diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index b6669b58..c5da74d1 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -56,7 +56,8 @@ class CoreActorSaveLoadStateTest(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.mkdtemp() - self.state_file = os.path.join(self.temp_dir, 'core', 'state.json.gz') + self.state_file = os.path.join(self.temp_dir, + b'core', b'state.json.gz') config = { 'core': { @@ -66,7 +67,7 @@ class CoreActorSaveLoadStateTest(unittest.TestCase): } } - os.mkdir(os.path.join(self.temp_dir, 'core')) + os.mkdir(os.path.join(self.temp_dir, b'core')) self.mixer = dummy_mixer.create_proxy() self.core = Core( From b0f749092651dcbb181c1019a0e1960027d73982 Mon Sep 17 00:00:00 2001 From: dublok Date: Sat, 17 Sep 2016 18:22:39 +0200 Subject: [PATCH 083/118] Fix typos --- mopidy/core/actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 5f7743eb..03efd6a8 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -174,7 +174,7 @@ class Core( """ file_name = os.path.join(self._get_data_dir(), b'state.json.gz') - logger.info('Saveing state to %s', file_name) + logger.info('Saving state to %s', file_name) data = {} data['version'] = mopidy.__version__ @@ -184,7 +184,7 @@ class Core( playback=self.playback._save_state(), mixer=self.mixer._save_state()) storage.dump(file_name, data) - logger.debug('Saveing state done') + logger.debug('Saving state done') def _load_state(self, coverage): """ From 18a3f6801c8a3fe416dc1eb2b5cdf2eb8f45a26b Mon Sep 17 00:00:00 2001 From: dublok Date: Mon, 19 Sep 2016 20:09:02 +0200 Subject: [PATCH 084/118] Scanner: set date to track and album --- docs/changelog.rst | 3 +++ mopidy/audio/tags.py | 1 + tests/audio/test_tags.py | 6 ++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 851359d8..a93c1884 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,6 +19,9 @@ Feature release. - MPD: Add ``nextsong`` and ``nextsongid`` to the response of MPD ``status`` command. (Fixes: :issue:`1133`, :issue:`1516`, PR: :issue:`1523`) +- Audio: The scanner set the date to :attr:`mopidy.models.Track.date` and + :attr:`mopidy.models.Album.date` + (Fixes: :issue:`1741`) v2.0.1 (2016-08-16) =================== diff --git a/mopidy/audio/tags.py b/mopidy/audio/tags.py index e4d86dc7..1d7ce408 100644 --- a/mopidy/audio/tags.py +++ b/mopidy/audio/tags.py @@ -124,6 +124,7 @@ def convert_tags_to_track(tags): datetime = tags.get(Gst.TAG_DATE_TIME, [None])[0] if datetime is not None: album_kwargs['date'] = datetime.split('T')[0] + track_kwargs['date'] = album_kwargs['date'] # Clear out any empty values we found track_kwargs = {k: v for k, v in track_kwargs.items() if v} diff --git a/tests/audio/test_tags.py b/tests/audio/test_tags.py index d85bcc12..1f1e5f5e 100644 --- a/tests/audio/test_tags.py +++ b/tests/audio/test_tags.py @@ -120,7 +120,7 @@ class TagsToTrackTest(unittest.TestCase): num_tracks=2, num_discs=3, musicbrainz_id='albumid', artists=[albumartist]) - self.track = Track(name='track', + self.track = Track(name='track', date='2006-01-01', genre='genre', track_no=1, disc_no=2, comment='comment', musicbrainz_id='trackid', album=album, bitrate=1000, artists=[artist], @@ -184,7 +184,9 @@ class TagsToTrackTest(unittest.TestCase): def test_missing_track_date(self): del self.tags['date'] self.check( - self.track.replace(album=self.track.album.replace(date=None))) + self.track.replace( + album=self.track.album.replace(date=None) + ).replace(date=None)) def test_multiple_track_date(self): self.tags['date'].append('2030-01-01') From b4896a4bf9a6ac27d414ff5683d9d982ec3aa7f7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 2 Oct 2016 13:48:56 +0200 Subject: [PATCH 085/118] docs: Add PR#1555 to changelog Note that #1555 could also have been a candidate for a point release. --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 851359d8..6403e660 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,9 @@ Feature release. - MPD: Add ``nextsong`` and ``nextsongid`` to the response of MPD ``status`` command. (Fixes: :issue:`1133`, :issue:`1516`, PR: :issue:`1523`) + +- Local: Skip hidden directories directly in ``media_dir``. + (Fixes: :issue:`1559`, PR: :issue:`1555`) v2.0.1 (2016-08-16) From 6b1707d120fb1b039576b2099e6fb266733eab2c Mon Sep 17 00:00:00 2001 From: Paul Connolley Date: Mon, 3 Oct 2016 23:03:17 +0100 Subject: [PATCH 086/118] Docs: Add Mopidy-Jukepi to the list of webclients jukePi has been formally converted in to a Mopidy extension and submitted to PyPi. In the current documentation, it's listed in the 'Other Web Clients' section. This change adds a screenshot and moves it to its own section in the web extensions doc. --- docs/ext/mopidy_jukepi.png | Bin 0 -> 195241 bytes docs/ext/web.rst | 16 +++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 docs/ext/mopidy_jukepi.png diff --git a/docs/ext/mopidy_jukepi.png b/docs/ext/mopidy_jukepi.png new file mode 100644 index 0000000000000000000000000000000000000000..95943e07f90f84072f45b9c2317c23a058bfef88 GIT binary patch literal 195241 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 z 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
I7(86#2|iFO2oq==%oO}G2o@q{ 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