Merge branch 'develop' into fix/1479-cant-play-rtsp
This commit is contained in:
commit
3d477e1eb9
@ -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
|
||||
|
||||
@ -53,7 +53,6 @@ in core see :class:`~mopidy.core.CoreListener`.
|
||||
|
||||
.. automethod:: get_version
|
||||
|
||||
|
||||
Tracklist controller
|
||||
====================
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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
|
||||
<http://grooveshark.im/>`_.
|
||||
|
||||
|
||||
Mopidy-GMusic
|
||||
=============
|
||||
@ -97,15 +89,6 @@ Extension for playing music and audio from the `Internet Archive
|
||||
<https://archive.org/>`_.
|
||||
|
||||
|
||||
Mopidy-LeftAsRain
|
||||
=================
|
||||
|
||||
https://github.com/naglis/mopidy-leftasrain
|
||||
|
||||
Extension for playing music from the `leftasrain.com
|
||||
<http://leftasrain.com/>`_ music blog.
|
||||
|
||||
|
||||
Mopidy-Local
|
||||
============
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
BIN
docs/ext/mopidy_jukepi.png
Normal file
BIN
docs/ext/mopidy_jukepi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
@ -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 <https://github.com/samcreate/Apollo-Player>`_
|
||||
- `JukePi <https://github.com/meantimeit/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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
Sphinx >= 1.0
|
||||
sphinx_rtd_theme
|
||||
pygraphviz
|
||||
Pykka >= 1.1
|
||||
sphinx_rtd_theme
|
||||
|
||||
@ -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 <https://www.globalsign.com/>`_ provides Mopidy with a free SSL
|
||||
certificate for mopidy.com, which we use to secure access to all our web sites.
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
144
mopidy/internal/models.py
Normal file
144
mopidy/internal/models.py
Normal file
@ -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)
|
||||
60
mopidy/internal/storage.py
Normal file
60
mopidy/internal/storage.py
Normal file
@ -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)
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
218
tests/internal/test_models.py
Normal file
218
tests/internal/test_models.py
Normal file
@ -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)
|
||||
@ -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))
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user