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):