From a5a9178b060cd0be4eaeb0f898f2ec996a3e2397 Mon Sep 17 00:00:00 2001 From: Jens Luetjen Date: Sat, 2 Jan 2016 15:28:41 +0100 Subject: [PATCH] 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)