diff --git a/README.rst b/README.rst index 88072eb5..6720e9e8 100644 --- a/README.rst +++ b/README.rst @@ -61,10 +61,6 @@ To get started with Mopidy, check out :target: https://pypi.python.org/pypi/Mopidy/ :alt: Latest PyPI version -.. image:: https://img.shields.io/pypi/dm/Mopidy.svg?style=flat - :target: https://pypi.python.org/pypi/Mopidy/ - :alt: Number of PyPI downloads - .. image:: https://img.shields.io/travis/mopidy/mopidy/develop.svg?style=flat :target: https://travis-ci.org/mopidy/mopidy :alt: Travis CI build status 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/changelog.rst b/docs/changelog.rst index 9666cd5c..063153a8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,16 +10,25 @@ 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``. + - 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`) +- Local: Skip hidden directories directly in ``media_dir``. + (Fixes: :issue:`1559`, PR: :issue:`1555`) + - 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`) -v2.0.1 (UNRELEASED) +v2.0.1 (2016-08-16) =================== Bug fix release. @@ -34,11 +43,11 @@ Bug fix release. - Audio: Update scan logic to workaround GStreamer issue where tags and duration might only be available after we start playing. - (Fixes: :issue:`935`, :issue:`1453`, :issue:`1474` and :issue:`1480`, PR: + (Fixes: :issue:`935`, :issue:`1453`, :issue:`1474`, :issue:`1480`, PR: :issue:`1487`) - Audio: Better handling of seek when position does not match the expected - pending position. (Fixes: :issue:`1462`, PR: :issue:`1496`) + pending position. (Fixes: :issue:`1462`, :issue:`1505`, PR: :issue:`1496`) - Audio: Handle bad date tags from audio, thanks to Mario Lang and Tom Parker who fixed this in parallel. (Fixes: :issue:`1506`, PR: :issue:`1525`, @@ -47,15 +56,23 @@ Bug fix release. - Audio: Make sure scanner handles streams without a duration. (Fixes: :issue:`1526`) -- Audio: Ensure audio tags are never `None`. (Fixes: :issue:`1449`) +- Audio: Ensure audio tags are never ``None``. (Fixes: :issue:`1449`) + +- Audio: Update :meth:`mopidy.audio.Audio.set_metadata` to postpone sending + tags if there is a pending track change. (Fixes: :issue:`1357`, PR: + :issue:`1538`) - Core: Avoid endless loop if all tracks in the tracklist are unplayable and consume mode is off. (Fixes: :issue:`1221`, :issue:`1454`, PR: :issue:`1455`) - Core: Correctly record the last position of a track when switching to another - one. Particularly relevant for `mopidy-scrobbler` users, as before it was + one. Particularly relevant for Mopidy-Scrobbler users, as before it was essentially unusable. (Fixes: :issue:`1456`, PR: :issue:`1534`) +- Models: Fix encoding error if :class:`~mopidy.models.fields.Identifier` + fields, like the ``musicbrainz_id`` model fields, contained non-ASCII Unicode + data. (Fixes: :issue:`1508`, PR: :issue:`1546`) + - File: Ensure path comparison is done between bytestrings only. Fixes crash where a :confval:`file/media_dirs` path contained non-ASCII characters. (Fixes: :issue:`1345`, PR: :issue:`1493`) @@ -63,6 +80,11 @@ Bug fix release. - Stream: Fix milliseconds vs seconds mistake in timeout handling. (Fixes: :issue:`1521`, PR: :issue:`1522`) +- Docs: Fix the rendering of :class:`mopidy.core.Core` and + :class:`mopidy.audio.Audio` docs. This should also contribute towards making + the Mopidy Debian package build bit-by-bit reproducible. (Fixes: + :issue:`1500`) + v2.0.0 (2016-02-15) =================== @@ -402,7 +424,7 @@ Bug fix release. proceed startup. (Fixes: :issue:`1248`, PR: :issue:`1254`) - Stream: Fix bug in new playlist parser. A non-ASCII char in an urilist - comment would cause a crash while parsing due to comparision of a non-ASCII + comment would cause a crash while parsing due to comparison of a non-ASCII bytestring with a Unicode string. (Fixes: :issue:`1265`) - File: Adjust log levels when failing to expand ``$XDG_MUSIC_DIR`` into a real @@ -2105,10 +2127,10 @@ A release with a number of small and medium fixes, with no specific focus. - Converted from the optparse to the argparse library for handling command line options. -- :option:`mopidy --show-config` will now take into consideration any +- ``mopidy --show-config`` will now take into consideration any :option:`mopidy --option` arguments appearing later on the command line. This helps you see the effective configuration for runs with the same - :option:`mopidy --options` arguments. + ``mopidy --options`` arguments. **Audio** @@ -2182,7 +2204,7 @@ v0.14.1 (2013-04-28) ==================== This release addresses an issue in v0.14.0 where the new -:option:`mopidy-convert-config` tool and the new :option:`mopidy --option` +``mopidy-convert-config`` tool and the new :option:`mopidy --option` command line option was broken because some string operations inadvertently converted some byte strings to unicode. @@ -2212,7 +2234,7 @@ one new. As part of this change we have cleaned up the naming of our config values. - To ease migration we've made a tool named :option:`mopidy-convert-config` for + To ease migration we've made a tool named ``mopidy-convert-config`` for automatically converting the old ``settings.py`` to a new ``mopidy.conf`` file. This tool takes care of all the renamed config values as well. See ``mopidy-convert-config`` for details on how to use it. @@ -2245,11 +2267,11 @@ one new. **Command line options** -- The command option :option:`mopidy --list-settings` is now named - :option:`mopidy --show-config`. +- The command option ``mopidy --list-settings`` is now named + ``mopidy --show-config``. -- The command option :option:`mopidy --list-deps` is now named - :option:`mopidy --show-deps`. +- The command option ``mopidy --list-deps`` is now named + ``mopidy --show-deps``. - What configuration files to use can now be specified through the command option :option:`mopidy --config`, multiple files can be specified using colon @@ -2259,8 +2281,8 @@ one new. :option:`mopidy --option`. For example: ``mopidy --option spotify/enabled=false``. -- The GStreamer command line options, :option:`mopidy --gst-*` and - :option:`mopidy --help-gst` are no longer supported. To set GStreamer debug +- The GStreamer command line options, ``mopidy --gst-*`` and + ``mopidy --help-gst`` are no longer supported. To set GStreamer debug flags, you can use environment variables such as :envvar:`GST_DEBUG`. Refer to GStreamer's documentation for details. @@ -2309,7 +2331,7 @@ already have. **Core** - Removed the :attr:`mopidy.settings.DEBUG_THREAD` setting and the - :option:`--debug-thread` command line option. Sending SIGUSR1 to + ``mopidy --debug-thread`` command line option. Sending SIGUSR1 to the Mopidy process will now always make it log tracebacks for all alive threads. @@ -2590,9 +2612,8 @@ We've added an HTTP frontend for those wanting to build web clients for Mopidy! - Make ``mopidy-scan`` ignore invalid dates, e.g. dates in years outside the range 1-9999. -- Make ``mopidy-scan`` accept :option:`-q`/:option:`--quiet` and - :option:`-v`/:option:`--verbose` options to control the amount of logging - output when scanning. +- Make ``mopidy-scan`` accept ``-q``/``--quiet`` and ``-v``/``--verbose`` + options to control the amount of logging output when scanning. - The scanner can now handle files with other encodings than UTF-8. Rebuild your tag cache with ``mopidy-scan`` to include tracks that may have been @@ -2717,7 +2738,7 @@ long time been our most requested feature. Finally, it's here! **Developer support** - Added optional background thread for debugging deadlocks. When the feature is - enabled via the ``--debug-thread`` option or + enabled via the ``mopidy --debug-thread`` option or :attr:`mopidy.settings.DEBUG_THREAD` setting a ``SIGUSR1`` signal will dump the traceback for all running threads. @@ -2929,9 +2950,9 @@ resolved a bunch of related issues. known setting, and suggests to the user what we think the setting should have been. -- Added :option:`--list-deps` option to the ``mopidy`` command that lists - required and optional dependencies, their current versions, and some other - information useful for debugging. (Fixes: :issue:`74`) +- Added ``mopidy --list-deps`` option that lists required and optional + dependencies, their current versions, and some other information useful for + debugging. (Fixes: :issue:`74`) - Added ``tools/debug-proxy.py`` to tee client requests to two backends and diff responses. Intended as a developer tool for checking for MPD protocol @@ -3211,12 +3232,12 @@ Please note that 0.5.0 requires some updated dependencies, as listed under - Command line usage: - - Support passing options to GStreamer. See :option:`--help-gst` for a list + - Support passing options to GStreamer. See ``mopidy --help-gst`` for a list of available options. (Fixes: :issue:`95`) - - Improve :option:`--list-settings` output. (Fixes: :issue:`91`) + - Improve ``mopidy --list-settings`` output. (Fixes: :issue:`91`) - - Added :option:`--interactive` for reading missing local settings from + - Added ``mopidy --interactive`` for reading missing local settings from ``stdin``. (Fixes: :issue:`96`) - Improve shutdown procedure at CTRL+C. Add signal handler for ``SIGTERM``, @@ -3359,8 +3380,8 @@ loading from Mopidy 0.3.0 is still present. - Settings: - - Fix crash on ``--list-settings`` on clean installation. Thanks to Martins - Grunskis for the bug report and patch. (Fixes: :issue:`63`) + - Fix crash on ``mopidy --list-settings`` on clean installation. Thanks to + Martins Grunskis for the bug report and patch. (Fixes: :issue:`63`) - Packaging: @@ -3588,11 +3609,11 @@ to Valentin David. - Simplify the default log format, :attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. From a user's point of view: Less noise, more information. - - Rename the :option:`--dump` command line option to - :option:`--save-debug-log`. + - Rename the ``mopidy --dump`` command line option to + :option:`mopidy --save-debug-log`. - Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to - :attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose` - too. + :attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for + :option:`mopidy --verbose` too. - Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to :attr:`mopidy.settings.DEBUG_LOG_FILENAME`. @@ -3658,7 +3679,7 @@ fixing the OS X issues for a future release. You can track the progress at - Exit early if not Python >= 2.6, < 3. - Validate settings at startup and print useful error messages if the settings has not been updated or anything is misspelled. -- Add command line option :option:`--list-settings` to print the currently +- Add command line option ``mopidy --list-settings`` to print the currently active settings. - Include Sphinx scripts for building docs, pylintrc, tests and test data in the packages created by ``setup.py`` for i.e. PyPI. @@ -3840,7 +3861,7 @@ the established pace of at least a release per month. - Improvements to MPD protocol handling, making Mopidy work much better with a group of clients, including ncmpc, MPoD, and Theremin. -- New command line flag :option:`--dump` for dumping debug log to ``dump.log`` +- New command line flag ``mopidy --dump`` for dumping debug log to ``dump.log`` in the current directory. - New setting :attr:`mopidy.settings.MIXER_ALSA_CONTROL` for forcing what ALSA control :class:`mopidy.mixers.alsa.AlsaMixer` should use. diff --git a/docs/conf.py b/docs/conf.py index 208822a2..cb04a671 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,7 +40,6 @@ MOCK_MODULES = [ 'dbus.mainloop.glib', 'dbus.service', 'mopidy.internal.gi', - 'pykka', ] for mod_name in MOCK_MODULES: sys.modules[mod_name] = Mock() 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/backends.rst b/docs/ext/backends.rst index 2349006b..165b7642 100644 --- a/docs/ext/backends.rst +++ b/docs/ext/backends.rst @@ -70,14 +70,6 @@ Mopidy-File Bundled with Mopidy. See :ref:`ext-file`. -Mopidy-Grooveshark -================== - -https://github.com/camilonova/mopidy-grooveshark - -Provides a backend for playing music from `Grooveshark -`_. - Mopidy-GMusic ============= @@ -97,15 +89,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 ============ diff --git a/docs/ext/file.rst b/docs/ext/file.rst index d31f53fd..2331626c 100644 --- a/docs/ext/file.rst +++ b/docs/ext/file.rst @@ -36,7 +36,7 @@ See :ref:`config` for general help on configuring Mopidy. .. confval:: file/follow_symlinks - Whether to follow symbolic links found in :confval:`files/media_dir`. + 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. diff --git a/docs/ext/mopidy_jukepi.png b/docs/ext/mopidy_jukepi.png new file mode 100644 index 00000000..95943e07 Binary files /dev/null and b/docs/ext/mopidy_jukepi.png differ diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 4c2b6c6c..cfc19ff0 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -204,15 +204,29 @@ Bootstrap by Wojciech Wnętrzak. To use, just visit http://mopster.cowbell-labs.com/. +Mopidy-Jukepi +============= + +https://github.com/meantimeit/jukepi + +A Mopidy web client built with Backbone by connrs. + +.. image:: /ext/mopidy_jukepi.png + :width: 1260 + :height: 961 + +To install, run:: + + pip install Mopidy-Jukepi + Other web clients ================= -There's also some other web clients for Mopidy that use the :ref:`http-api`, -but isn't installable using ``pip``: +There are also some other web clients for Mopidy that use the :ref:`http-api` +but are not installable using ``pip``: - `Apollo Player `_ -- `JukePi `_ -In addition, there's several web based MPD clients, which doesn't use the +In addition, there are several web based MPD clients, which doesn't use the :ref:`ext-http` frontend at all, but connect to Mopidy through our :ref:`ext-mpd` frontend. For a list of those, see :ref:`mpd-web-clients`. diff --git a/docs/releasing.rst b/docs/releasing.rst index 8d489146..e7ef251c 100644 --- a/docs/releasing.rst +++ b/docs/releasing.rst @@ -13,8 +13,7 @@ Creating releases #. Update changelog and commit it. -#. Bump the version number in ``mopidy/__init__.py``. Remember to update the - test case in ``tests/test_version.py``. +#. Bump the version number in ``mopidy/__init__.py``. #. Merge the release branch (``develop`` in the example) into master:: @@ -63,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. diff --git a/docs/requirements.txt b/docs/requirements.txt index 62c7e3e5..f0cc5e6c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ Sphinx >= 1.0 -sphinx_rtd_theme pygraphviz +Pykka >= 1.1 +sphinx_rtd_theme diff --git a/docs/sponsors.rst b/docs/sponsors.rst index 2d8b7f4e..2528247b 100644 --- a/docs/sponsors.rst +++ b/docs/sponsors.rst @@ -31,10 +31,3 @@ accelerate requests to all Mopidy services, including: - https://dl.mopidy.com/pimusicbox/, which is used to distribute Pi Musicbox images. - - -GlobalSign -========== - -`GlobalSign `_ provides Mopidy with a free SSL -certificate for mopidy.com, which we use to secure access to all our web sites. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 4a6370e8..184f5991 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.0' +__version__ = '2.0.1' diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index 61a8e008..6020bc1b 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -374,6 +374,10 @@ class _Handler(object): logger.debug('Audio event: tags_changed(tags=%r)', tags.keys()) AudioListener.send('tags_changed', tags=tags.keys()) + if self._audio._pending_metadata: + self._audio._playbin.send_event(self._audio._pending_metadata) + self._audio._pending_metadata = None + def on_segment(self, segment): gst_logger.debug( 'Got SEGMENT pad event: ' @@ -412,6 +416,7 @@ class Audio(pykka.ThreadingActor): self._tags = {} self._pending_uri = None self._pending_tags = None + self._pending_metadata = None self._playbin = None self._outputs = None @@ -804,8 +809,10 @@ class Audio(pykka.ThreadingActor): 'Sending TAG event for track %r: %r', track.uri, taglist.to_string()) event = Gst.Event.new_tag(taglist) - # TODO: check if we get this back on our own bus? - self._playbin.send_event(event) + if self._pending_uri: + self._pending_metadata = event + else: + self._playbin.send_event(event) def get_current_tags(self): """ diff --git a/mopidy/audio/tags.py b/mopidy/audio/tags.py index 7fabefd6..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} @@ -136,12 +137,11 @@ def convert_tags_to_track(tags): return Track(**track_kwargs) -def _artists( - tags, artist_name, artist_id=None, artist_sortname=None): - +def _artists(tags, artist_name, artist_id=None, artist_sortname=None): # Name missing, don't set artist if not tags.get(artist_name): return None + # One artist name and either id or sortname, include all available fields if len(tags[artist_name]) == 1 and \ (artist_id in tags or artist_sortname in tags): 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..37930f79 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__) @@ -646,3 +647,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/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/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/models/fields.py b/mopidy/models/fields.py index c686b447..af04687a 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -88,14 +88,17 @@ class Date(String): class Identifier(String): """ - :class:`Field` for storing ASCII values such as GUIDs or other identifiers. + :class:`Field` for storing values such as GUIDs or other identifiers. Values will be interned. :param default: default value for field """ def validate(self, value): - return compat.intern(str(super(Identifier, self).validate(value))) + value = super(Identifier, self).validate(value) + if isinstance(value, compat.text_type): + value = value.encode('utf-8') + return compat.intern(value) class URI(Identifier): @@ -135,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/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..958e0aaf 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 @@ -1132,6 +1133,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 3374c822..0a69f564 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -1,8 +1,11 @@ +# encoding: utf-8 + from __future__ import absolute_import, unicode_literals import unittest -from mopidy.models.fields import Collection, Field, Integer, String +from mopidy.models.fields import (Boolean, Collection, Field, Identifier, + Integer, String) def create_instance(field): @@ -126,6 +129,42 @@ class StringTest(unittest.TestCase): self.assertEqual('', instance.attr) +class IdentifierTest(unittest.TestCase): + def test_default_handling(self): + instance = create_instance(Identifier(default='abc')) + self.assertEqual('abc', instance.attr) + + def test_native_str_allowed(self): + instance = create_instance(Identifier()) + instance.attr = str('abc') + self.assertEqual('abc', instance.attr) + + def test_bytes_allowed(self): + instance = create_instance(Identifier()) + instance.attr = b'abc' + self.assertEqual(b'abc', instance.attr) + + def test_unicode_allowed(self): + instance = create_instance(Identifier()) + instance.attr = u'abc' + self.assertEqual(u'abc', instance.attr) + + def test_unicode_with_nonascii_allowed(self): + instance = create_instance(Identifier()) + instance.attr = u'æøå' + self.assertEqual(u'æøå'.encode('utf-8'), instance.attr) + + def test_other_disallowed(self): + instance = create_instance(Identifier()) + with self.assertRaises(TypeError): + instance.attr = 1234 + + def test_empty_string(self): + instance = create_instance(Identifier()) + instance.attr = '' + self.assertEqual('', instance.attr) + + class IntegerTest(unittest.TestCase): def test_default_handling(self): instance = create_instance(Integer(default=1234)) @@ -173,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):