Merge branch 'develop' into fix/1479-cant-play-rtsp

This commit is contained in:
Thomas Adamcik 2016-10-24 23:07:34 +02:00
commit 3d477e1eb9
39 changed files with 1229 additions and 238 deletions

View File

@ -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

View File

@ -53,7 +53,6 @@ in core see :class:`~mopidy.core.CoreListener`.
.. automethod:: get_version
Tracklist controller
====================

View File

@ -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.

View File

@ -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()

View File

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

View File

@ -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
============

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

View File

@ -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`.

View File

@ -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.

View File

@ -1,3 +1,4 @@
Sphinx >= 1.0
sphinx_rtd_theme
pygraphviz
Pykka >= 1.1
sphinx_rtd_theme

View File

@ -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.

View File

@ -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'

View File

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

View File

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

View File

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

View File

@ -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()

View File

@ -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

View File

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

View File

@ -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]

View File

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

View File

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

View File

@ -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
View 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)

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

View File

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

View File

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

View File

@ -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.

View File

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

View File

@ -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

View File

@ -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')

View File

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

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

View File

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

View File

@ -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),

View File

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

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

View File

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

View File

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