diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..4fd5e866 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,3 @@ +Please use https://discuss.mopidy.com/ for support questions. + +GitHub Issues should only be used for confirmed problems with Mopidy and well-defined feature requests. diff --git a/.travis.yml b/.travis.yml index f46d5ae2..73b3e20c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,8 +8,7 @@ python: env: - TOX_ENV=py27 - - TOX_ENV=py27-tornado23 - - TOX_ENV=py27-tornado31 + - TOX_ENV=py27-tornado32 - TOX_ENV=docs - TOX_ENV=flake8 diff --git a/docs/api/core.rst b/docs/api/core.rst index aaa692d2..abc046bd 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -53,7 +53,6 @@ in core see :class:`~mopidy.core.CoreListener`. .. automethod:: get_version - Tracklist controller ==================== diff --git a/docs/authors.rst b/docs/authors.rst index f4f93d56..29d570e3 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -4,7 +4,7 @@ Authors ******* -Mopidy is copyright 2009-2016 Stein Magnus Jodal and contributors. Mopidy is +Mopidy is copyright 2009-2017 Stein Magnus Jodal and contributors. Mopidy is licensed under the `Apache License, Version 2.0 `_. diff --git a/docs/changelog.rst b/docs/changelog.rst index 97ac8676..e925db11 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,58 @@ Changelog This changelog is used to track all major changes to Mopidy. +v2.1.0 (2017-01-02) +=================== + +Mopidy 2.1.0, a feature release, is finally out! + +Since the release of 2.0.0, it has been quiet times in Mopidy circles. This is +mainly caused by core developers moving from the enterprise to startups or into +positions with more responsibility, and getting more kids. Of course, this has +greatly decreased the amount of spare time available for open source work. But +fear not, Mopidy is not dead. We've returned from year long periods with close +to no activity before, and will hopefully do so again. + +Despite all, we've closed or merged approximately 18 issues and pull requests +through about 170 commits since the release of v2.0.1 back in August. + +The major new feature in Mopidy 2.1 is support for restoring playback state and +the current playlist after a restart. This feature was contributed by `Jens +Lütjen `_. + +- Dependencies: Drop support for Tornado < 3.2. Though strictly a breaking + change, this shouldn't have any effect on what systems we support, as Tornado + 3.2 or newer is available from the distros that include GStreamer >= 1.2.3, + which we already require. + +- Core: Mopidy restores its last state when started. Can be enabled by setting + the config value :confval:`core/restore_state` to ``true``. + +- Audio: Update scanner to handle sources such as RTSP. (Fixes: :issue:`1479`) + +- Audio: The scanner set the date to :attr:`mopidy.models.Track.date` and + :attr:`mopidy.models.Album.date` + (Fixes: :issue:`1741`) + +- File: Add new config value :confval:`file/excluded_file_extensions`. + +- Local: Skip hidden directories directly in ``media_dir``. + (Fixes: :issue:`1559`, PR: :issue:`1555`) + +- 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`) + +- MPD: Fix inconsistent playlist state after playlist is emptied with repeat + and consume mode turned on. (Fixes: :issue:`1512`, PR: :issue:`1549`) + +- Audio: Improve handling of duration in scanning. VBR tracks should fail less + frequently and MMS works again. (Fixes: :issue:`1553`, PR :issue:`1575`, + :issue:`1576`, :issue:`1577`) + + v2.0.1 (2016-08-16) =================== diff --git a/docs/conf.py b/docs/conf.py index cb04a671..d504989c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -73,14 +73,14 @@ source_suffix = '.rst' master_doc = 'index' project = 'Mopidy' -copyright = '2009-2016, Stein Magnus Jodal and contributors' +copyright = '2009-2017, Stein Magnus Jodal and contributors' from mopidy.internal.versioning import get_version release = get_version() version = '.'.join(release.split('.')[:2]) # To make the build reproducible, avoid using today's date in the manpages -today = '2016' +today = '2017' exclude_trees = ['_build'] diff --git a/docs/config.rst b/docs/config.rst index b0d2e52e..5c1257d7 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -111,6 +111,13 @@ Core config section 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 + + When set to ``true``, Mopidy restores its last state when started. + The restored state includes the tracklist, playback history, + the playback state, the volume, and mute state. + + Default is ``false``. .. _audio-config: diff --git a/docs/ext/file.rst b/docs/ext/file.rst index 2331626c..661bf869 100644 --- a/docs/ext/file.rst +++ b/docs/ext/file.rst @@ -27,18 +27,24 @@ See :ref:`config` for general help on configuring Mopidy. .. confval:: file/media_dirs A list of directories to be browsable. - Optionally the path can be followed by ``|`` and a name that will be shown for that path. + Optionally the path can be followed by ``|`` and a name that will be shown + for that path. .. confval:: file/show_dotfiles Whether to show hidden files and directories that start with a dot. Default is false. +.. confval:: file/excluded_file_extensions + + File extensions to exclude when scanning the media directory. Values + should be separated by either comma or newline. + .. confval:: file/follow_symlinks Whether to follow symbolic links found in :confval:`file/media_dirs`. - Directories and files that are outside the configured directories will not be shown. - Default is false. + Directories and files that are outside the configured directories will not + be shown. Default is false. .. confval:: file/metadata_timeout diff --git a/docs/requirements.txt b/docs/requirements.txt index f0cc5e6c..ddc1d265 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,6 @@ Sphinx >= 1.0 pygraphviz Pykka >= 1.1 +# Require newer requests than what Travis/Debian has to work around linkcheck crash +requests > 2.4.3 sphinx_rtd_theme diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 184f5991..0d01e44e 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -14,4 +14,4 @@ if not (2, 7) <= sys.version_info < (3,): warnings.filterwarnings('ignore', 'could not open display') -__version__ = '2.0.1' +__version__ = '2.1.0' diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index f99c4489..72c4bee6 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -2,11 +2,12 @@ from __future__ import ( absolute_import, division, print_function, unicode_literals) import collections +import logging import time from mopidy import exceptions from mopidy.audio import tags as tags_lib, utils -from mopidy.internal import encoding +from mopidy.internal import encoding, log from mopidy.internal.gi import Gst, GstPbutils # GST_ELEMENT_FACTORY_LIST: @@ -23,6 +24,12 @@ _SELECT_EXPOSE = 1 _Result = collections.namedtuple( 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable')) +logger = logging.getLogger(__name__) + + +def _trace(*args, **kwargs): + logger.log(log.TRACE_LOG_LEVEL, *args, **kwargs) + # TODO: replace with a scan(uri, timeout=1000, proxy_config=None)? class Scanner(object): @@ -78,25 +85,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) @@ -173,59 +207,68 @@ def _process(pipeline, timeout_ms): timeout = timeout_ms start = int(time.time() * 1000) while timeout > 0: - message = bus.timed_pop_filtered(timeout * Gst.MSECOND, types) - - if message is None: + msg = bus.timed_pop_filtered(timeout * Gst.MSECOND, types) + if msg is None: break - elif message.type == Gst.MessageType.ELEMENT: - if GstPbutils.is_missing_plugin_message(message): - missing_message = message - elif message.type == Gst.MessageType.APPLICATION: - if message.get_structure().get_name() == 'have-type': - mime = message.get_structure().get_value('caps').get_name() + + if logger.isEnabledFor(log.TRACE_LOG_LEVEL) and msg.get_structure(): + debug_text = msg.get_structure().to_string() + if len(debug_text) > 77: + debug_text = debug_text[:77] + '...' + _trace('element %s: %s', msg.src.get_name(), debug_text) + + if msg.type == Gst.MessageType.ELEMENT: + if GstPbutils.is_missing_plugin_message(msg): + missing_message = msg + elif msg.type == Gst.MessageType.APPLICATION: + if msg.get_structure().get_name() == 'have-type': + mime = msg.get_structure().get_value('caps').get_name() if mime and ( mime.startswith('text/') or mime == 'application/xml'): return tags, mime, have_audio, duration - elif message.get_structure().get_name() == 'have-audio': + elif msg.get_structure().get_name() == 'have-audio': have_audio = True - elif message.type == Gst.MessageType.ERROR: - error = encoding.locale_decode(message.parse_error()[0]) + elif msg.type == Gst.MessageType.ERROR: + error = encoding.locale_decode(msg.parse_error()[0]) if missing_message and not mime: caps = missing_message.get_structure().get_value('detail') mime = caps.get_structure(0).get_name() return tags, mime, have_audio, duration raise exceptions.ScannerError(error) - elif message.type == Gst.MessageType.EOS: + elif msg.type == Gst.MessageType.EOS: return tags, mime, have_audio, duration - elif message.type == Gst.MessageType.ASYNC_DONE: + elif msg.type == Gst.MessageType.ASYNC_DONE: success, duration = _query_duration(pipeline) if tags and success: return tags, mime, have_audio, duration + # Don't try workaround for non-seekable sources such as mmssrc: + if not _query_seekable(pipeline): + return tags, mime, have_audio, duration + # Workaround for upstream bug which causes tags/duration to arrive # after pre-roll. We get around this by starting to play the track # and then waiting for a duration change. # https://bugzilla.gnome.org/show_bug.cgi?id=763553 + logger.debug('Using workaround for duration missing before play.') result = pipeline.set_state(Gst.State.PLAYING) if result == Gst.StateChangeReturn.FAILURE: return tags, mime, have_audio, duration - elif message.type == Gst.MessageType.DURATION_CHANGED: - # duration will be read after ASYNC_DONE received; for now - # just give it a non-None value to flag that we have a duration: - duration = 0 - elif message.type == Gst.MessageType.TAG: - taglist = message.parse_tag() + elif msg.type == Gst.MessageType.DURATION_CHANGED and tags: + # VBR formats sometimes seem to not have a duration by the time we + # go back to paused. So just try to get it right away. + success, duration = _query_duration(pipeline) + pipeline.set_state(Gst.State.PAUSED) + if success: + return tags, mime, have_audio, duration + elif msg.type == Gst.MessageType.TAG: + taglist = msg.parse_tag() # Note that this will only keep the last tag. tags.update(tags_lib.convert_taglist(taglist)) timeout = timeout_ms - (int(time.time() * 1000) - start) - # workaround for https://bugzilla.gnome.org/show_bug.cgi?id=763553: - # if we got what we want then stop playing (and wait for ASYNC_DONE) - if tags and duration is not None: - pipeline.set_state(Gst.State.PAUSED) - raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) @@ -235,6 +278,9 @@ if __name__ == '__main__': from mopidy.internal import path + logging.basicConfig(format='%(asctime)-15s %(levelname)s %(message)s', + level=log.TRACE_LOG_LEVEL) + scanner = Scanner(5000) for uri in sys.argv[1:]: if not Gst.uri_is_valid(uri): @@ -245,6 +291,9 @@ if __name__ == '__main__': print('%-20s %s' % (key, getattr(result, key))) print('tags') for tag, value in result.tags.items(): - print('%-20s %s' % (tag, value)) + line = '%-20s %s' % (tag, value) + if len(line) > 77: + line = line[:77] + '...' + print(line) except exceptions.ScannerError as error: print('%s: %s' % (uri, error)) 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/mopidy/commands.py b/mopidy/commands.py index 50590172..fef2d5f8 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -295,6 +295,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: @@ -321,7 +322,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: @@ -397,8 +398,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( @@ -415,8 +418,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/config/__init__.py b/mopidy/config/__init__.py index ec5c9a99..2743625e 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -24,6 +24,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'] = Boolean(optional=True) _logging_schema = ConfigSchema('logging') _logging_schema['color'] = Boolean() diff --git a/mopidy/config/default.conf b/mopidy/config/default.conf index c747703b..7b99d86a 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 = false [logging] color = true diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 93cb814e..03efd6a8 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -3,9 +3,12 @@ from __future__ import absolute_import, unicode_literals import collections import itertools import logging +import os import pykka +import mopidy + from mopidy import audio, backend, mixer from mopidy.audio import PlaybackState from mopidy.core.history import HistoryController @@ -15,8 +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 path, storage, validation, versioning from mopidy.internal.deprecation import deprecated_property +from mopidy.internal.models import CoreState logger = logging.getLogger(__name__) @@ -136,6 +140,91 @@ class Core( self.playback._stream_title = title 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']: + if self._config['core']['restore_state']: + coverage = ['tracklist', 'mode', 'play-last', 'mixer', + 'history'] + if len(coverage): + self._load_state(coverage) + except Exception as e: + 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']: + if self._config['core']['restore_state']: + self._save_state() + except Exception as e: + logger.warn('Unexpected error while saving state: %s', str(e)) + + def _get_data_dir(self): + # get or create data director for 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 + + def _save_state(self): + """ + Save current state to disk. + """ + + file_name = os.path.join(self._get_data_dir(), b'state.json.gz') + logger.info('Saving state to %s', file_name) + + data = {} + data['version'] = mopidy.__version__ + data['state'] = CoreState( + 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('Saving state done') + + def _load_state(self, coverage): + """ + Restore state from disk. + + 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) + - 'play-last' restore play state ('tracklist' also required) + - 'mixer' set mixer volume and mute state + - 'history' restore history + + :param coverage: amount of data to restore + :type coverage: list of strings + """ + + 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) + + 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'] + validation.check_instance(core_state, CoreState) + 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.playback._load_state(core_state.playback, coverage) + logger.debug('Loading state done') + class Backends(list): diff --git a/mopidy/core/history.py b/mopidy/core/history.py index ae697e8e..94ee6e87 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__) @@ -57,3 +57,21 @@ class HistoryController(object): :rtype: list of (timestamp, :class:`mopidy.models.Ref`) tuples """ return copy.copy(self._history) + + def _save_state(self): + # 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('Limiting history to %s tracks', count_max) + break + return HistoryState(history=history_list) + + def _load_state(self, state, coverage): + 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 649ff270..8707c096 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.internal.models import MixerState logger = logging.getLogger(__name__) @@ -99,3 +100,13 @@ class MixerController(object): return result return False + + def _save_state(self): + return MixerState(volume=self.get_volume(), + mute=self.get_mute()) + + def _load_state(self, state, coverage): + 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 0106abf2..6abcc837 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__) @@ -30,6 +29,9 @@ class PlaybackController(object): self._last_position = None self._previous = False + self._start_at_position = None + self._start_paused = False + if self._audio: self._audio.set_about_to_finish_callback( self._on_about_to_finish_callback) @@ -226,6 +228,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_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 else: self._seek(self._pending_position) @@ -233,6 +242,9 @@ class PlaybackController(object): if self._pending_position is not None: self._trigger_seeked(self._pending_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. @@ -596,3 +608,17 @@ class PlaybackController(object): # TODO: Trigger this from audio events? logger.debug('Triggering seeked event') listener.CoreListener.send('seeked', time_position=time_position) + + def _save_state(self): + return models.PlaybackState( + tlid=self.get_current_tlid(), + time_position=self.get_time_position(), + state=self.get_state()) + + def _load_state(self, state, coverage): + 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) diff --git a/mopidy/core/tracklist.py b/mopidy/core/tracklist.py index 6d7ceeb7..c0d88ebb 100644 --- a/mopidy/core/tracklist.py +++ b/mopidy/core/tracklist.py @@ -6,6 +6,7 @@ import random from mopidy import exceptions from mopidy.core import listener from mopidy.internal import deprecation, validation +from mopidy.internal.models import TracklistState from mopidy.models import TlTrack, Track logger = logging.getLogger(__name__) @@ -325,7 +326,10 @@ class TracklistController(object): next_index += 1 if self.get_repeat(): - next_index %= len(self._tl_tracks) + 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 @@ -646,3 +650,24 @@ class TracklistController(object): def _trigger_options_changed(self): logger.debug('Triggering options changed event') listener.CoreListener.send('options_changed') + + def _save_state(self): + return TracklistState( + tl_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 _load_state(self, state, coverage): + if state: + if 'mode' in coverage: + self.set_consume(state.consume) + self.set_random(state.random) + self.set_repeat(state.repeat) + self.set_single(state.single) + if 'tracklist' in coverage: + self._next_tlid = max(state.next_tlid, self._next_tlid) + self._tl_tracks = list(state.tl_tracks) + self._increase_version() diff --git a/mopidy/file/__init__.py b/mopidy/file/__init__.py index ea4dea12..771a65a4 100644 --- a/mopidy/file/__init__.py +++ b/mopidy/file/__init__.py @@ -22,6 +22,7 @@ class Extension(ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['media_dirs'] = config.List(optional=True) + schema['excluded_file_extensions'] = config.List(optional=True) schema['show_dotfiles'] = config.Boolean(optional=True) schema['follow_symlinks'] = config.Boolean(optional=True) schema['metadata_timeout'] = config.Integer(optional=True) diff --git a/mopidy/file/ext.conf b/mopidy/file/ext.conf index 486619a1..5470a8ec 100644 --- a/mopidy/file/ext.conf +++ b/mopidy/file/ext.conf @@ -4,5 +4,8 @@ media_dirs = $XDG_MUSIC_DIR|Music ~/|Home show_dotfiles = false +excluded_file_extensions = + .jpg + .jpeg follow_symlinks = false metadata_timeout = 1000 diff --git a/mopidy/file/library.py b/mopidy/file/library.py index 10182a38..6d426b85 100644 --- a/mopidy/file/library.py +++ b/mopidy/file/library.py @@ -34,8 +34,12 @@ class FileLibraryProvider(backend.LibraryProvider): def __init__(self, backend, config): super(FileLibraryProvider, self).__init__(backend) self._media_dirs = list(self._get_media_dirs(config)) - self._follow_symlinks = config['file']['follow_symlinks'] self._show_dotfiles = config['file']['show_dotfiles'] + self._excluded_file_extensions = tuple( + bytes(file_ext.lower()) + for file_ext in config['file']['excluded_file_extensions']) + self._follow_symlinks = config['file']['follow_symlinks'] + self._scanner = scan.Scanner( timeout=config['file']['metadata_timeout']) @@ -60,6 +64,10 @@ class FileLibraryProvider(backend.LibraryProvider): if not self._show_dotfiles and dir_entry.startswith(b'.'): continue + if (self._excluded_file_extensions and + dir_entry.endswith(self._excluded_file_extensions)): + continue + if os.path.islink(child_path) and not self._follow_symlinks: logger.debug('Ignoring symlink: %s', uri) continue diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index a752a4f0..479cb3a0 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -89,16 +89,12 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): @classmethod def broadcast(cls, msg): - if hasattr(tornado.ioloop.IOLoop, 'current'): - loop = tornado.ioloop.IOLoop.current() - else: - loop = tornado.ioloop.IOLoop.instance() # Fallback for pre 3.0 + loop = tornado.ioloop.IOLoop.current() # This can be called from outside the Tornado ioloop, so we need to # safely cross the thread boundary by adding a callback to the loop. for client in cls.clients: # One callback per client to keep time we hold up the loop short - # NOTE: Pre 3.0 does not support *args or **kwargs... loop.add_callback(functools.partial(_send_broadcast, client, msg)) def initialize(self, core): @@ -139,10 +135,7 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): except Exception as e: error_msg = encoding.locale_decode(e) logger.error('WebSocket request error: %s', error_msg) - if self.ws_connection: - # Tornado 3.2+ checks if self.ws_connection is None before - # using it, but not older versions. - self.close() + self.close() def check_origin(self, origin): # Allow cross-origin WebSocket connections, like Tornado before 4.0 diff --git a/mopidy/internal/models.py b/mopidy/internal/models.py new file mode 100644 index 00000000..6ff17b5b --- /dev/null +++ b/mopidy/internal/models.py @@ -0,0 +1,144 @@ +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 + + +class HistoryTrack(ValidatedImmutableObject): + """ + A history track. Wraps a :class:`Ref` and its 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 save/load 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 save/load state. + + :param volume: the volume + :type volume: int + :param mute: the mute state + :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): + """ + State of the playback controller. + Internally used for save/load state. + + :param tlid: current track tlid + :type tlid: int + :param time_position: play position + :type time_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. + time_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 save/load 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 id for the next added track + :type next_tlid: int + :param tl_tracks: the list of tracks + :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 id of the track to play. 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 save/load 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/internal/path.py b/mopidy/internal/path.py index 1c10736f..307d733c 100644 --- a/mopidy/internal/path.py +++ b/mopidy/internal/path.py @@ -236,4 +236,5 @@ class Mtime(object): def undo_fake(self): self.fake = None + mtime = Mtime() diff --git a/mopidy/internal/storage.py b/mopidy/internal/storage.py new file mode 100644 index 00000000..6da53a00 --- /dev/null +++ b/mopidy/internal/storage.py @@ -0,0 +1,60 @@ +from __future__ import absolute_import, unicode_literals + +import gzip +import json +import logging +import os +import tempfile + +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: bytes + :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 {} + 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 dump(path, data): + """ + Serialize data to file. + + :param path: full path to export file + :type path: bytes + :param data: dictionary containing data to save + :type data: dict + """ + 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) 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) diff --git a/mopidy/local/json.py b/mopidy/local/json.py index 8e8b5b1e..2e39b68b 100644 --- a/mopidy/local/json.py +++ b/mopidy/local/json.py @@ -1,60 +1,22 @@ 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 storage as internal_storage +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 +90,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 = 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'): self._browse_cache = _BrowseCache(sorted(self._tracks.keys())) return len(self._tracks) @@ -195,7 +167,10 @@ class JsonLibrary(local.Library): self._tracks.pop(uri, None) def close(self): - write_library(self._json_file, {'tracks': self._tracks.values()}) + internal_storage.dump(self._json_file, { + 'version': mopidy.__version__, + 'tracks': self._tracks.values() + }) def clear(self): try: diff --git a/mopidy/local/storage.py b/mopidy/local/storage.py index aaa74fba..e1771530 100644 --- a/mopidy/local/storage.py +++ b/mopidy/local/storage.py @@ -9,5 +9,5 @@ logger = logging.getLogger(__name__) def check_dirs_and_files(config): if not os.path.isdir(config['local']['media_dir']): logger.warning( - 'Local media dir %s does not exist.' % - config['local']['media_dir']) + 'Local media dir %s does not exist or we lack permissions to the ' + 'directory or one of its parents' % config['local']['media_dir']) 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) 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 diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py index c5800eba..af04687a 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -138,6 +138,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/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 5002a8f7..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,8 +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) + if model_name in immutable._models: + cls = immutable._models[model_name] + return cls(**dct) return dct 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/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index 16e9d013..3d76d35f 100644 --- 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.get_next_tlid() futures = { 'tracklist.length': context.core.tracklist.get_length(), @@ -185,6 +186,9 @@ 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( + tlid=next_tlid.get()), 'playback.time_position': context.core.playback.get_time_position(), } pykka.get_all(futures.values()) @@ -199,10 +203,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))) @@ -259,6 +265,14 @@ 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: diff --git a/setup.py b/setup.py index a353a932..d41c943d 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ setup( 'Pykka >= 1.1', 'requests >= 2.0', 'setuptools', - 'tornado >= 2.3', + 'tornado >= 3.2', ], extras_require={'http': []}, entry_points={ diff --git a/tests/audio/test_tags.py b/tests/audio/test_tags.py index d85bcc12..d4bed7c5 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], @@ -183,8 +183,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.check(self.track.replace( + album=self.track.album.replace(date=None), + date=None)) def test_multiple_track_date(self): self.tags['date'].append('2030-01-01') diff --git a/tests/core/test_actor.py b/tests/core/test_actor.py index 8f062fa2..c5da74d1 100644 --- a/tests/core/test_actor.py +++ b/tests/core/test_actor.py @@ -1,13 +1,20 @@ from __future__ import absolute_import, unicode_literals +import os +import shutil +import tempfile import unittest import mock import pykka +import mopidy + from mopidy.core import Core -from mopidy.internal import versioning +from mopidy.internal import models, storage, versioning +from mopidy.models import Track +from tests import dummy_mixer class CoreActorTest(unittest.TestCase): @@ -43,3 +50,106 @@ class CoreActorTest(unittest.TestCase): def test_version(self): self.assertEqual(self.core.version, versioning.get_version()) + + +class CoreActorSaveLoadStateTest(unittest.TestCase): + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.state_file = os.path.join(self.temp_dir, + b'core', b'state.json.gz') + + config = { + 'core': { + 'max_tracklist_length': 10000, + 'restore_state': True, + 'data_dir': self.temp_dir, + } + } + + os.mkdir(os.path.join(self.temp_dir, b'core')) + + 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() + + 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): + data = {} + storage.dump(self.state_file, data) + assert os.path.isfile(self.state_file) + + self.core.setup() + + assert not os.path.isfile(self.state_file) diff --git a/tests/core/test_history.py b/tests/core/test_history.py index 7f034cad..57cc58ee 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, Track +from mopidy.internal.models import HistoryState, HistoryTrack +from mopidy.models import Artist, Ref, Track class PlaybackHistoryTest(unittest.TestCase): @@ -46,3 +47,60 @@ class PlaybackHistoryTest(unittest.TestCase): self.assertIn(track.name, ref.name) for artist in track.artists: self.assertIn(artist.name, ref.name) + + +class CoreHistorySaveLoadStateTest(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_save(self): + self.history._add_track(self.tracks[2]) + self.history._add_track(self.tracks[1]) + + value = self.history._save_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_load(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._load_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_load_invalid_type(self): + with self.assertRaises(TypeError): + self.history._load_state(11, None) + + 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 45241fec..996b7c23 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.internal.models import MixerState from tests import dummy_mixer @@ -154,3 +155,68 @@ 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 CoreMixerSaveLoadStateTest(unittest.TestCase): + + def setUp(self): # noqa: N802 + self.mixer = dummy_mixer.create_proxy() + self.core = core.Core(mixer=self.mixer, backends=[]) + + def test_save_mute(self): + volume = 32 + mute = False + target = MixerState(volume=volume, mute=mute) + self.core.mixer.set_volume(volume) + self.core.mixer.set_mute(mute) + value = self.core.mixer._save_state() + self.assertEqual(target, value) + + def test_save_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._save_state() + self.assertEqual(target, value) + + def test_load(self): + self.core.mixer.set_volume(11) + volume = 45 + target = MixerState(volume=volume) + coverage = ['mixer'] + self.core.mixer._load_state(target, coverage) + self.assertEqual(volume, self.core.mixer.get_volume()) + + def test_load_not_covered(self): + self.core.mixer.set_volume(21) + self.core.mixer.set_mute(True) + target = MixerState(volume=56, mute=False) + coverage = ['other'] + self.core.mixer._load_state(target, coverage) + self.assertEqual(21, self.core.mixer.get_volume()) + self.assertEqual(True, self.core.mixer.get_mute()) + + def test_load_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._load_state(target, coverage) + self.assertEqual(True, self.core.mixer.get_mute()) + + def test_load_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._load_state(target, coverage) + self.assertEqual(False, self.core.mixer.get_mute()) + + def test_load_invalid_type(self): + with self.assertRaises(TypeError): + self.core.mixer._load_state(11, None) + + 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 34c9d367..6d320b4e 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -8,6 +8,7 @@ import pykka from mopidy import backend, core from mopidy.internal import deprecation +from mopidy.internal.models import PlaybackState from mopidy.models import Track from tests import dummy_audio @@ -430,6 +431,21 @@ 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): + self.core.playback.play() + self.core.tracklist.set_consume(True) + self.core.tracklist.set_repeat(True) + self.replay_events() + + for track in self.core.tracklist.get_tl_tracks(): + self.core.playback.next() + self.replay_events() + + self.core.playback.next() + self.replay_events() + + self.assertEqual(self.playback.get_state(), 'stopped') + class TestCurrentAndPendingTlTrack(BaseTest): @@ -1132,6 +1148,62 @@ class TestBug1177Regression(unittest.TestCase): b.playback.change_track.assert_called_once_with(track2) +class TestCorePlaybackSaveLoadState(BaseTest): + + def test_save(self): + tl_tracks = self.core.tracklist.get_tl_tracks() + + self.core.playback.play(tl_tracks[1]) + self.replay_events() + + state = PlaybackState( + time_position=0, state='playing', tlid=tl_tracks[1].tlid) + value = self.core.playback._save_state() + + self.assertEqual(state, value) + + def test_load(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( + time_position=0, state='playing', tlid=tl_tracks[2].tlid) + coverage = ['play-last'] + self.core.playback._load_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_load_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( + time_position=0, state='playing', tlid=tl_tracks[2].tlid) + coverage = ['other'] + self.core.playback._load_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_load_invalid_type(self): + with self.assertRaises(TypeError): + self.core.playback._load_state(11, None) + + def test_load_none(self): + self.core.playback._load_state(None, None) + + class TestBug1352Regression(BaseTest): tracks = [ Track(uri='dummy:a', length=40000), diff --git a/tests/core/test_tracklist.py b/tests/core/test_tracklist.py index 24edb2e7..120ae1f0 100644 --- a/tests/core/test_tracklist.py +++ b/tests/core/test_tracklist.py @@ -6,6 +6,7 @@ import mock from mopidy import backend, core from mopidy.internal import deprecation +from mopidy.internal.models import TracklistState from mopidy.models import TlTrack, Track @@ -177,3 +178,119 @@ 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 TracklistSaveLoadStateTest(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_save(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, + tl_tracks=tl_tracks) + value = self.core.tracklist._save_state() + self.assertEqual(target, value) + + def test_load(self): + old_version = self.core.tracklist.get_version() + target = TracklistState(consume=False, + repeat=True, + single=True, + random=False, + next_tlid=12, + tl_tracks=self.tl_tracks) + coverage = ['mode', 'tracklist'] + 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()) + 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()) + self.assertGreater(self.core.tracklist.get_version(), old_version) + + # 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_load_mode_only(self): + old_version = self.core.tracklist.get_version() + target = TracklistState(consume=False, + repeat=True, + single=True, + random=False, + next_tlid=12, + tl_tracks=self.tl_tracks) + coverage = ['mode'] + 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()) + 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()) + self.assertEqual(self.core.tracklist.get_version(), old_version) + + def test_load_tracklist_only(self): + old_version = self.core.tracklist.get_version() + target = TracklistState(consume=False, + repeat=True, + single=True, + random=False, + next_tlid=12, + tl_tracks=self.tl_tracks) + coverage = ['tracklist'] + 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()) + 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()) + self.assertGreater(self.core.tracklist.get_version(), old_version) + + def test_load_invalid_type(self): + with self.assertRaises(TypeError): + self.core.tracklist._load_state(11, None) + + def test_load_none(self): + self.core.tracklist._load_state(None, None) diff --git a/tests/internal/test_models.py b/tests/internal/test_models.py new file mode 100644 index 00000000..eaa638cb --- /dev/null +++ b/tests/internal/test_models.py @@ -0,0 +1,218 @@ +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_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) + deserialized = json.loads(serialized, object_hook=model_json_decoder) + self.assertEqual(result, deserialized) + + +class PlaybackStateTest(unittest.TestCase): + + def test_position(self): + time_position = 123456 + result = PlaybackState(time_position=time_position) + self.assertEqual(result.time_position, time_position) + with self.assertRaises(AttributeError): + result.time_position = None + + def test_position_invalid(self): + time_position = -1 + with self.assertRaises(ValueError): + PlaybackState(time_position=time_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_fields.py b/tests/models/test_fields.py index a4788e4d..0a69f564 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -4,7 +4,8 @@ from __future__ import absolute_import, unicode_literals import unittest -from mopidy.models.fields import Collection, Field, Identifier, Integer, String +from mopidy.models.fields import (Boolean, Collection, Field, Identifier, + Integer, String) def create_instance(field): @@ -211,6 +212,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..35e77aef 100644 --- a/tests/models/test_models.py +++ b/tests/models/test_models.py @@ -4,8 +4,8 @@ import json import unittest from mopidy.models import ( - Album, Artist, Image, ModelJSONEncoder, Playlist, Ref, SearchResult, - TlTrack, Track, model_json_decoder) + Album, Artist, Image, ModelJSONEncoder, Playlist, + Ref, SearchResult, TlTrack, Track, model_json_decoder) class InheritanceTest(unittest.TestCase): 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"') diff --git a/tests/mpd/test_status.py b/tests/mpd/test_status.py index 25b8dd72..9450808c 100644 --- 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) diff --git a/tox.ini b/tox.ini index da6bcc38..d5bed67d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py27-tornado23, py27-tornado31, docs, flake8 +envlist = py27, py27-tornado32, docs, flake8 [testenv] sitepackages = true @@ -17,17 +17,11 @@ deps = pytest-xdist responses -[testenv:py27-tornado23] +[testenv:py27-tornado32] commands = py.test tests/http deps = {[testenv]deps} - tornado==2.3 - -[testenv:py27-tornado31] -commands = py.test tests/http -deps = - {[testenv]deps} - tornado==3.1.1 + tornado==3.2.2 [testenv:docs] deps = -r{toxinidir}/docs/requirements.txt