Release v2.1.0
This commit is contained in:
commit
4c1e2960b6
3
.github/ISSUE_TEMPLATE.md
vendored
Normal file
3
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Please use https://discuss.mopidy.com/ for support questions.
|
||||||
|
|
||||||
|
GitHub Issues should only be used for confirmed problems with Mopidy and well-defined feature requests.
|
||||||
@ -8,8 +8,7 @@ python:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
- TOX_ENV=py27
|
- TOX_ENV=py27
|
||||||
- TOX_ENV=py27-tornado23
|
- TOX_ENV=py27-tornado32
|
||||||
- TOX_ENV=py27-tornado31
|
|
||||||
- TOX_ENV=docs
|
- TOX_ENV=docs
|
||||||
- TOX_ENV=flake8
|
- TOX_ENV=flake8
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,6 @@ in core see :class:`~mopidy.core.CoreListener`.
|
|||||||
|
|
||||||
.. automethod:: get_version
|
.. automethod:: get_version
|
||||||
|
|
||||||
|
|
||||||
Tracklist controller
|
Tracklist controller
|
||||||
====================
|
====================
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
Authors
|
Authors
|
||||||
*******
|
*******
|
||||||
|
|
||||||
Mopidy is copyright 2009-2016 Stein Magnus Jodal and contributors. Mopidy is
|
Mopidy is copyright 2009-2017 Stein Magnus Jodal and contributors. Mopidy is
|
||||||
licensed under the `Apache License, Version 2.0
|
licensed under the `Apache License, Version 2.0
|
||||||
<http://www.apache.org/licenses/LICENSE-2.0>`_.
|
<http://www.apache.org/licenses/LICENSE-2.0>`_.
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,58 @@ Changelog
|
|||||||
This changelog is used to track all major changes to Mopidy.
|
This changelog is used to track all major changes to Mopidy.
|
||||||
|
|
||||||
|
|
||||||
|
v2.1.0 (2017-01-02)
|
||||||
|
===================
|
||||||
|
|
||||||
|
Mopidy 2.1.0, a feature release, is finally out!
|
||||||
|
|
||||||
|
Since the release of 2.0.0, it has been quiet times in Mopidy circles. This is
|
||||||
|
mainly caused by core developers moving from the enterprise to startups or into
|
||||||
|
positions with more responsibility, and getting more kids. Of course, this has
|
||||||
|
greatly decreased the amount of spare time available for open source work. But
|
||||||
|
fear not, Mopidy is not dead. We've returned from year long periods with close
|
||||||
|
to no activity before, and will hopefully do so again.
|
||||||
|
|
||||||
|
Despite all, we've closed or merged approximately 18 issues and pull requests
|
||||||
|
through about 170 commits since the release of v2.0.1 back in August.
|
||||||
|
|
||||||
|
The major new feature in Mopidy 2.1 is support for restoring playback state and
|
||||||
|
the current playlist after a restart. This feature was contributed by `Jens
|
||||||
|
Lütjen <https://github.com/dublok>`_.
|
||||||
|
|
||||||
|
- Dependencies: Drop support for Tornado < 3.2. Though strictly a breaking
|
||||||
|
change, this shouldn't have any effect on what systems we support, as Tornado
|
||||||
|
3.2 or newer is available from the distros that include GStreamer >= 1.2.3,
|
||||||
|
which we already require.
|
||||||
|
|
||||||
|
- Core: Mopidy restores its last state when started. Can be enabled by setting
|
||||||
|
the config value :confval:`core/restore_state` to ``true``.
|
||||||
|
|
||||||
|
- Audio: Update scanner to handle sources such as RTSP. (Fixes: :issue:`1479`)
|
||||||
|
|
||||||
|
- Audio: The scanner set the date to :attr:`mopidy.models.Track.date` and
|
||||||
|
:attr:`mopidy.models.Album.date`
|
||||||
|
(Fixes: :issue:`1741`)
|
||||||
|
|
||||||
|
- File: Add new config value :confval:`file/excluded_file_extensions`.
|
||||||
|
|
||||||
|
- Local: Skip hidden directories directly in ``media_dir``.
|
||||||
|
(Fixes: :issue:`1559`, PR: :issue:`1555`)
|
||||||
|
|
||||||
|
- MPD: Fix MPD protocol for ``replay_gain_status`` command. The actual command
|
||||||
|
remains unimplemented. (PR: :issue:`1520`)
|
||||||
|
|
||||||
|
- MPD: Add ``nextsong`` and ``nextsongid`` to the response of MPD ``status``
|
||||||
|
command. (Fixes: :issue:`1133`, :issue:`1516`, PR: :issue:`1523`)
|
||||||
|
|
||||||
|
- MPD: Fix inconsistent playlist state after playlist is emptied with repeat
|
||||||
|
and consume mode turned on. (Fixes: :issue:`1512`, PR: :issue:`1549`)
|
||||||
|
|
||||||
|
- Audio: Improve handling of duration in scanning. VBR tracks should fail less
|
||||||
|
frequently and MMS works again. (Fixes: :issue:`1553`, PR :issue:`1575`,
|
||||||
|
:issue:`1576`, :issue:`1577`)
|
||||||
|
|
||||||
|
|
||||||
v2.0.1 (2016-08-16)
|
v2.0.1 (2016-08-16)
|
||||||
===================
|
===================
|
||||||
|
|
||||||
|
|||||||
@ -73,14 +73,14 @@ source_suffix = '.rst'
|
|||||||
master_doc = 'index'
|
master_doc = 'index'
|
||||||
|
|
||||||
project = 'Mopidy'
|
project = 'Mopidy'
|
||||||
copyright = '2009-2016, Stein Magnus Jodal and contributors'
|
copyright = '2009-2017, Stein Magnus Jodal and contributors'
|
||||||
|
|
||||||
from mopidy.internal.versioning import get_version
|
from mopidy.internal.versioning import get_version
|
||||||
release = get_version()
|
release = get_version()
|
||||||
version = '.'.join(release.split('.')[:2])
|
version = '.'.join(release.split('.')[:2])
|
||||||
|
|
||||||
# To make the build reproducible, avoid using today's date in the manpages
|
# To make the build reproducible, avoid using today's date in the manpages
|
||||||
today = '2016'
|
today = '2017'
|
||||||
|
|
||||||
exclude_trees = ['_build']
|
exclude_trees = ['_build']
|
||||||
|
|
||||||
|
|||||||
@ -111,6 +111,13 @@ Core config section
|
|||||||
The original MPD server only supports 10000 tracks in the tracklist. Some
|
The original MPD server only supports 10000 tracks in the tracklist. Some
|
||||||
MPD clients will crash if this limit is exceeded.
|
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:
|
.. _audio-config:
|
||||||
|
|
||||||
|
|||||||
@ -27,18 +27,24 @@ See :ref:`config` for general help on configuring Mopidy.
|
|||||||
.. confval:: file/media_dirs
|
.. confval:: file/media_dirs
|
||||||
|
|
||||||
A list of directories to be browsable.
|
A list of directories to be browsable.
|
||||||
Optionally the path can be followed by ``|`` and a name that will be shown for that path.
|
Optionally the path can be followed by ``|`` and a name that will be shown
|
||||||
|
for that path.
|
||||||
|
|
||||||
.. confval:: file/show_dotfiles
|
.. confval:: file/show_dotfiles
|
||||||
|
|
||||||
Whether to show hidden files and directories that start with a dot.
|
Whether to show hidden files and directories that start with a dot.
|
||||||
Default is false.
|
Default is false.
|
||||||
|
|
||||||
|
.. confval:: file/excluded_file_extensions
|
||||||
|
|
||||||
|
File extensions to exclude when scanning the media directory. Values
|
||||||
|
should be separated by either comma or newline.
|
||||||
|
|
||||||
.. confval:: file/follow_symlinks
|
.. confval:: file/follow_symlinks
|
||||||
|
|
||||||
Whether to follow symbolic links found in :confval:`file/media_dirs`.
|
Whether to follow symbolic links found in :confval:`file/media_dirs`.
|
||||||
Directories and files that are outside the configured directories will not be shown.
|
Directories and files that are outside the configured directories will not
|
||||||
Default is false.
|
be shown. Default is false.
|
||||||
|
|
||||||
.. confval:: file/metadata_timeout
|
.. confval:: file/metadata_timeout
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
Sphinx >= 1.0
|
Sphinx >= 1.0
|
||||||
pygraphviz
|
pygraphviz
|
||||||
Pykka >= 1.1
|
Pykka >= 1.1
|
||||||
|
# Require newer requests than what Travis/Debian has to work around linkcheck crash
|
||||||
|
requests > 2.4.3
|
||||||
sphinx_rtd_theme
|
sphinx_rtd_theme
|
||||||
|
|||||||
@ -14,4 +14,4 @@ if not (2, 7) <= sys.version_info < (3,):
|
|||||||
warnings.filterwarnings('ignore', 'could not open display')
|
warnings.filterwarnings('ignore', 'could not open display')
|
||||||
|
|
||||||
|
|
||||||
__version__ = '2.0.1'
|
__version__ = '2.1.0'
|
||||||
|
|||||||
@ -2,11 +2,12 @@ from __future__ import (
|
|||||||
absolute_import, division, print_function, unicode_literals)
|
absolute_import, division, print_function, unicode_literals)
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from mopidy import exceptions
|
from mopidy import exceptions
|
||||||
from mopidy.audio import tags as tags_lib, utils
|
from mopidy.audio import tags as tags_lib, utils
|
||||||
from mopidy.internal import encoding
|
from mopidy.internal import encoding, log
|
||||||
from mopidy.internal.gi import Gst, GstPbutils
|
from mopidy.internal.gi import Gst, GstPbutils
|
||||||
|
|
||||||
# GST_ELEMENT_FACTORY_LIST:
|
# GST_ELEMENT_FACTORY_LIST:
|
||||||
@ -23,6 +24,12 @@ _SELECT_EXPOSE = 1
|
|||||||
_Result = collections.namedtuple(
|
_Result = collections.namedtuple(
|
||||||
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable'))
|
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable'))
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _trace(*args, **kwargs):
|
||||||
|
logger.log(log.TRACE_LOG_LEVEL, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)?
|
# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)?
|
||||||
class Scanner(object):
|
class Scanner(object):
|
||||||
@ -78,25 +85,52 @@ def _setup_pipeline(uri, proxy_config=None):
|
|||||||
if not src:
|
if not src:
|
||||||
raise exceptions.ScannerError('GStreamer can not open: %s' % uri)
|
raise exceptions.ScannerError('GStreamer can not open: %s' % uri)
|
||||||
|
|
||||||
typefind = Gst.ElementFactory.make('typefind')
|
|
||||||
decodebin = Gst.ElementFactory.make('decodebin')
|
|
||||||
|
|
||||||
pipeline = Gst.ElementFactory.make('pipeline')
|
|
||||||
for e in (src, typefind, decodebin):
|
|
||||||
pipeline.add(e)
|
|
||||||
src.link(typefind)
|
|
||||||
typefind.link(decodebin)
|
|
||||||
|
|
||||||
if proxy_config:
|
if proxy_config:
|
||||||
utils.setup_proxy(src, proxy_config)
|
utils.setup_proxy(src, proxy_config)
|
||||||
|
|
||||||
signals = utils.Signals()
|
signals = utils.Signals()
|
||||||
|
pipeline = Gst.ElementFactory.make('pipeline')
|
||||||
|
pipeline.add(src)
|
||||||
|
|
||||||
|
if _has_src_pads(src):
|
||||||
|
_setup_decodebin(src, src.get_static_pad('src'), pipeline, signals)
|
||||||
|
elif _has_dynamic_src_pad(src):
|
||||||
|
signals.connect(src, 'pad-added', _setup_decodebin, pipeline, signals)
|
||||||
|
else:
|
||||||
|
raise exceptions.ScannerError('No pads found in source element.')
|
||||||
|
|
||||||
|
return pipeline, signals
|
||||||
|
|
||||||
|
|
||||||
|
def _has_src_pads(element):
|
||||||
|
pads = []
|
||||||
|
element.iterate_src_pads().foreach(pads.append)
|
||||||
|
return bool(pads)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_dynamic_src_pad(element):
|
||||||
|
for template in element.get_pad_template_list():
|
||||||
|
if template.direction == Gst.PadDirection.SRC:
|
||||||
|
if template.presence == Gst.PadPresence.SOMETIMES:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_decodebin(element, pad, pipeline, signals):
|
||||||
|
typefind = Gst.ElementFactory.make('typefind')
|
||||||
|
decodebin = Gst.ElementFactory.make('decodebin')
|
||||||
|
|
||||||
|
for element in (typefind, decodebin):
|
||||||
|
pipeline.add(element)
|
||||||
|
element.sync_state_with_parent()
|
||||||
|
|
||||||
|
pad.link(typefind.get_static_pad('sink'))
|
||||||
|
typefind.link(decodebin)
|
||||||
|
|
||||||
signals.connect(typefind, 'have-type', _have_type, decodebin)
|
signals.connect(typefind, 'have-type', _have_type, decodebin)
|
||||||
signals.connect(decodebin, 'pad-added', _pad_added, pipeline)
|
signals.connect(decodebin, 'pad-added', _pad_added, pipeline)
|
||||||
signals.connect(decodebin, 'autoplug-select', _autoplug_select)
|
signals.connect(decodebin, 'autoplug-select', _autoplug_select)
|
||||||
|
|
||||||
return pipeline, signals
|
|
||||||
|
|
||||||
|
|
||||||
def _have_type(element, probability, caps, decodebin):
|
def _have_type(element, probability, caps, decodebin):
|
||||||
decodebin.set_property('sink-caps', caps)
|
decodebin.set_property('sink-caps', caps)
|
||||||
@ -173,59 +207,68 @@ def _process(pipeline, timeout_ms):
|
|||||||
timeout = timeout_ms
|
timeout = timeout_ms
|
||||||
start = int(time.time() * 1000)
|
start = int(time.time() * 1000)
|
||||||
while timeout > 0:
|
while timeout > 0:
|
||||||
message = bus.timed_pop_filtered(timeout * Gst.MSECOND, types)
|
msg = bus.timed_pop_filtered(timeout * Gst.MSECOND, types)
|
||||||
|
if msg is None:
|
||||||
if message is None:
|
|
||||||
break
|
break
|
||||||
elif message.type == Gst.MessageType.ELEMENT:
|
|
||||||
if GstPbutils.is_missing_plugin_message(message):
|
if logger.isEnabledFor(log.TRACE_LOG_LEVEL) and msg.get_structure():
|
||||||
missing_message = message
|
debug_text = msg.get_structure().to_string()
|
||||||
elif message.type == Gst.MessageType.APPLICATION:
|
if len(debug_text) > 77:
|
||||||
if message.get_structure().get_name() == 'have-type':
|
debug_text = debug_text[:77] + '...'
|
||||||
mime = message.get_structure().get_value('caps').get_name()
|
_trace('element %s: %s', msg.src.get_name(), debug_text)
|
||||||
|
|
||||||
|
if msg.type == Gst.MessageType.ELEMENT:
|
||||||
|
if GstPbutils.is_missing_plugin_message(msg):
|
||||||
|
missing_message = msg
|
||||||
|
elif msg.type == Gst.MessageType.APPLICATION:
|
||||||
|
if msg.get_structure().get_name() == 'have-type':
|
||||||
|
mime = msg.get_structure().get_value('caps').get_name()
|
||||||
if mime and (
|
if mime and (
|
||||||
mime.startswith('text/') or mime == 'application/xml'):
|
mime.startswith('text/') or mime == 'application/xml'):
|
||||||
return tags, mime, have_audio, duration
|
return tags, mime, have_audio, duration
|
||||||
elif message.get_structure().get_name() == 'have-audio':
|
elif msg.get_structure().get_name() == 'have-audio':
|
||||||
have_audio = True
|
have_audio = True
|
||||||
elif message.type == Gst.MessageType.ERROR:
|
elif msg.type == Gst.MessageType.ERROR:
|
||||||
error = encoding.locale_decode(message.parse_error()[0])
|
error = encoding.locale_decode(msg.parse_error()[0])
|
||||||
if missing_message and not mime:
|
if missing_message and not mime:
|
||||||
caps = missing_message.get_structure().get_value('detail')
|
caps = missing_message.get_structure().get_value('detail')
|
||||||
mime = caps.get_structure(0).get_name()
|
mime = caps.get_structure(0).get_name()
|
||||||
return tags, mime, have_audio, duration
|
return tags, mime, have_audio, duration
|
||||||
raise exceptions.ScannerError(error)
|
raise exceptions.ScannerError(error)
|
||||||
elif message.type == Gst.MessageType.EOS:
|
elif msg.type == Gst.MessageType.EOS:
|
||||||
return tags, mime, have_audio, duration
|
return tags, mime, have_audio, duration
|
||||||
elif message.type == Gst.MessageType.ASYNC_DONE:
|
elif msg.type == Gst.MessageType.ASYNC_DONE:
|
||||||
success, duration = _query_duration(pipeline)
|
success, duration = _query_duration(pipeline)
|
||||||
if tags and success:
|
if tags and success:
|
||||||
return tags, mime, have_audio, duration
|
return tags, mime, have_audio, duration
|
||||||
|
|
||||||
|
# Don't try workaround for non-seekable sources such as mmssrc:
|
||||||
|
if not _query_seekable(pipeline):
|
||||||
|
return tags, mime, have_audio, duration
|
||||||
|
|
||||||
# Workaround for upstream bug which causes tags/duration to arrive
|
# Workaround for upstream bug which causes tags/duration to arrive
|
||||||
# after pre-roll. We get around this by starting to play the track
|
# after pre-roll. We get around this by starting to play the track
|
||||||
# and then waiting for a duration change.
|
# and then waiting for a duration change.
|
||||||
# https://bugzilla.gnome.org/show_bug.cgi?id=763553
|
# https://bugzilla.gnome.org/show_bug.cgi?id=763553
|
||||||
|
logger.debug('Using workaround for duration missing before play.')
|
||||||
result = pipeline.set_state(Gst.State.PLAYING)
|
result = pipeline.set_state(Gst.State.PLAYING)
|
||||||
if result == Gst.StateChangeReturn.FAILURE:
|
if result == Gst.StateChangeReturn.FAILURE:
|
||||||
return tags, mime, have_audio, duration
|
return tags, mime, have_audio, duration
|
||||||
|
|
||||||
elif message.type == Gst.MessageType.DURATION_CHANGED:
|
elif msg.type == Gst.MessageType.DURATION_CHANGED and tags:
|
||||||
# duration will be read after ASYNC_DONE received; for now
|
# VBR formats sometimes seem to not have a duration by the time we
|
||||||
# just give it a non-None value to flag that we have a duration:
|
# go back to paused. So just try to get it right away.
|
||||||
duration = 0
|
success, duration = _query_duration(pipeline)
|
||||||
elif message.type == Gst.MessageType.TAG:
|
pipeline.set_state(Gst.State.PAUSED)
|
||||||
taglist = message.parse_tag()
|
if success:
|
||||||
|
return tags, mime, have_audio, duration
|
||||||
|
elif msg.type == Gst.MessageType.TAG:
|
||||||
|
taglist = msg.parse_tag()
|
||||||
# Note that this will only keep the last tag.
|
# Note that this will only keep the last tag.
|
||||||
tags.update(tags_lib.convert_taglist(taglist))
|
tags.update(tags_lib.convert_taglist(taglist))
|
||||||
|
|
||||||
timeout = timeout_ms - (int(time.time() * 1000) - start)
|
timeout = timeout_ms - (int(time.time() * 1000) - start)
|
||||||
|
|
||||||
# workaround for https://bugzilla.gnome.org/show_bug.cgi?id=763553:
|
|
||||||
# if we got what we want then stop playing (and wait for ASYNC_DONE)
|
|
||||||
if tags and duration is not None:
|
|
||||||
pipeline.set_state(Gst.State.PAUSED)
|
|
||||||
|
|
||||||
raise exceptions.ScannerError('Timeout after %dms' % timeout_ms)
|
raise exceptions.ScannerError('Timeout after %dms' % timeout_ms)
|
||||||
|
|
||||||
|
|
||||||
@ -235,6 +278,9 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
from mopidy.internal import path
|
from mopidy.internal import path
|
||||||
|
|
||||||
|
logging.basicConfig(format='%(asctime)-15s %(levelname)s %(message)s',
|
||||||
|
level=log.TRACE_LOG_LEVEL)
|
||||||
|
|
||||||
scanner = Scanner(5000)
|
scanner = Scanner(5000)
|
||||||
for uri in sys.argv[1:]:
|
for uri in sys.argv[1:]:
|
||||||
if not Gst.uri_is_valid(uri):
|
if not Gst.uri_is_valid(uri):
|
||||||
@ -245,6 +291,9 @@ if __name__ == '__main__':
|
|||||||
print('%-20s %s' % (key, getattr(result, key)))
|
print('%-20s %s' % (key, getattr(result, key)))
|
||||||
print('tags')
|
print('tags')
|
||||||
for tag, value in result.tags.items():
|
for tag, value in result.tags.items():
|
||||||
print('%-20s %s' % (tag, value))
|
line = '%-20s %s' % (tag, value)
|
||||||
|
if len(line) > 77:
|
||||||
|
line = line[:77] + '...'
|
||||||
|
print(line)
|
||||||
except exceptions.ScannerError as error:
|
except exceptions.ScannerError as error:
|
||||||
print('%s: %s' % (uri, error))
|
print('%s: %s' % (uri, error))
|
||||||
|
|||||||
@ -124,6 +124,7 @@ def convert_tags_to_track(tags):
|
|||||||
datetime = tags.get(Gst.TAG_DATE_TIME, [None])[0]
|
datetime = tags.get(Gst.TAG_DATE_TIME, [None])[0]
|
||||||
if datetime is not None:
|
if datetime is not None:
|
||||||
album_kwargs['date'] = datetime.split('T')[0]
|
album_kwargs['date'] = datetime.split('T')[0]
|
||||||
|
track_kwargs['date'] = album_kwargs['date']
|
||||||
|
|
||||||
# Clear out any empty values we found
|
# Clear out any empty values we found
|
||||||
track_kwargs = {k: v for k, v in track_kwargs.items() if v}
|
track_kwargs = {k: v for k, v in track_kwargs.items() if v}
|
||||||
|
|||||||
@ -295,6 +295,7 @@ class RootCommand(Command):
|
|||||||
mixer_class = self.get_mixer_class(config, args.registry['mixer'])
|
mixer_class = self.get_mixer_class(config, args.registry['mixer'])
|
||||||
backend_classes = args.registry['backend']
|
backend_classes = args.registry['backend']
|
||||||
frontend_classes = args.registry['frontend']
|
frontend_classes = args.registry['frontend']
|
||||||
|
core = None
|
||||||
|
|
||||||
exit_status_code = 0
|
exit_status_code = 0
|
||||||
try:
|
try:
|
||||||
@ -321,7 +322,7 @@ class RootCommand(Command):
|
|||||||
finally:
|
finally:
|
||||||
loop.quit()
|
loop.quit()
|
||||||
self.stop_frontends(frontend_classes)
|
self.stop_frontends(frontend_classes)
|
||||||
self.stop_core()
|
self.stop_core(core)
|
||||||
self.stop_backends(backend_classes)
|
self.stop_backends(backend_classes)
|
||||||
self.stop_audio()
|
self.stop_audio()
|
||||||
if mixer_class is not None:
|
if mixer_class is not None:
|
||||||
@ -397,8 +398,10 @@ class RootCommand(Command):
|
|||||||
|
|
||||||
def start_core(self, config, mixer, backends, audio):
|
def start_core(self, config, mixer, backends, audio):
|
||||||
logger.info('Starting Mopidy core')
|
logger.info('Starting Mopidy core')
|
||||||
return Core.start(
|
core = Core.start(
|
||||||
config=config, mixer=mixer, backends=backends, audio=audio).proxy()
|
config=config, mixer=mixer, backends=backends, audio=audio).proxy()
|
||||||
|
core.setup().get()
|
||||||
|
return core
|
||||||
|
|
||||||
def start_frontends(self, config, frontend_classes, core):
|
def start_frontends(self, config, frontend_classes, core):
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -415,8 +418,10 @@ class RootCommand(Command):
|
|||||||
for frontend_class in frontend_classes:
|
for frontend_class in frontend_classes:
|
||||||
process.stop_actors_by_class(frontend_class)
|
process.stop_actors_by_class(frontend_class)
|
||||||
|
|
||||||
def stop_core(self):
|
def stop_core(self, core):
|
||||||
logger.info('Stopping Mopidy core')
|
logger.info('Stopping Mopidy core')
|
||||||
|
if core:
|
||||||
|
core.teardown().get()
|
||||||
process.stop_actors_by_class(Core)
|
process.stop_actors_by_class(Core)
|
||||||
|
|
||||||
def stop_backends(self, backend_classes):
|
def stop_backends(self, backend_classes):
|
||||||
|
|||||||
@ -24,6 +24,7 @@ _core_schema['config_dir'] = Path()
|
|||||||
_core_schema['data_dir'] = Path()
|
_core_schema['data_dir'] = Path()
|
||||||
# MPD supports at most 10k tracks, some clients segfault when this is exceeded.
|
# 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['max_tracklist_length'] = Integer(minimum=1, maximum=10000)
|
||||||
|
_core_schema['restore_state'] = Boolean(optional=True)
|
||||||
|
|
||||||
_logging_schema = ConfigSchema('logging')
|
_logging_schema = ConfigSchema('logging')
|
||||||
_logging_schema['color'] = Boolean()
|
_logging_schema['color'] = Boolean()
|
||||||
|
|||||||
@ -3,6 +3,7 @@ cache_dir = $XDG_CACHE_DIR/mopidy
|
|||||||
config_dir = $XDG_CONFIG_DIR/mopidy
|
config_dir = $XDG_CONFIG_DIR/mopidy
|
||||||
data_dir = $XDG_DATA_DIR/mopidy
|
data_dir = $XDG_DATA_DIR/mopidy
|
||||||
max_tracklist_length = 10000
|
max_tracklist_length = 10000
|
||||||
|
restore_state = false
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
color = true
|
color = true
|
||||||
|
|||||||
@ -3,9 +3,12 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
import collections
|
import collections
|
||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import pykka
|
import pykka
|
||||||
|
|
||||||
|
import mopidy
|
||||||
|
|
||||||
from mopidy import audio, backend, mixer
|
from mopidy import audio, backend, mixer
|
||||||
from mopidy.audio import PlaybackState
|
from mopidy.audio import PlaybackState
|
||||||
from mopidy.core.history import HistoryController
|
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.playback import PlaybackController
|
||||||
from mopidy.core.playlists import PlaylistsController
|
from mopidy.core.playlists import PlaylistsController
|
||||||
from mopidy.core.tracklist import TracklistController
|
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.deprecation import deprecated_property
|
||||||
|
from mopidy.internal.models import CoreState
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -136,6 +140,91 @@ class Core(
|
|||||||
self.playback._stream_title = title
|
self.playback._stream_title = title
|
||||||
CoreListener.send('stream_title_changed', 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):
|
class Backends(list):
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from mopidy import models
|
from mopidy import models
|
||||||
|
from mopidy.internal.models import HistoryState, HistoryTrack
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -57,3 +57,21 @@ class HistoryController(object):
|
|||||||
:rtype: list of (timestamp, :class:`mopidy.models.Ref`) tuples
|
:rtype: list of (timestamp, :class:`mopidy.models.Ref`) tuples
|
||||||
"""
|
"""
|
||||||
return copy.copy(self._history)
|
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 import exceptions
|
||||||
from mopidy.internal import validation
|
from mopidy.internal import validation
|
||||||
|
from mopidy.internal.models import MixerState
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -99,3 +100,13 @@ class MixerController(object):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
return False
|
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
|
import logging
|
||||||
|
|
||||||
from mopidy import models
|
|
||||||
from mopidy.audio import PlaybackState
|
from mopidy.audio import PlaybackState
|
||||||
from mopidy.compat import urllib
|
from mopidy.compat import urllib
|
||||||
from mopidy.core import listener
|
from mopidy.core import listener
|
||||||
from mopidy.internal import deprecation, validation
|
from mopidy.internal import deprecation, models, validation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -30,6 +29,9 @@ class PlaybackController(object):
|
|||||||
self._last_position = None
|
self._last_position = None
|
||||||
self._previous = False
|
self._previous = False
|
||||||
|
|
||||||
|
self._start_at_position = None
|
||||||
|
self._start_paused = False
|
||||||
|
|
||||||
if self._audio:
|
if self._audio:
|
||||||
self._audio.set_about_to_finish_callback(
|
self._audio.set_about_to_finish_callback(
|
||||||
self._on_about_to_finish_callback)
|
self._on_about_to_finish_callback)
|
||||||
@ -226,6 +228,13 @@ class PlaybackController(object):
|
|||||||
if self._pending_position is None:
|
if self._pending_position is None:
|
||||||
self.set_state(PlaybackState.PLAYING)
|
self.set_state(PlaybackState.PLAYING)
|
||||||
self._trigger_track_playback_started()
|
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:
|
else:
|
||||||
self._seek(self._pending_position)
|
self._seek(self._pending_position)
|
||||||
|
|
||||||
@ -233,6 +242,9 @@ class PlaybackController(object):
|
|||||||
if self._pending_position is not None:
|
if self._pending_position is not None:
|
||||||
self._trigger_seeked(self._pending_position)
|
self._trigger_seeked(self._pending_position)
|
||||||
self._pending_position = None
|
self._pending_position = None
|
||||||
|
if self._start_paused:
|
||||||
|
self._start_paused = False
|
||||||
|
self.pause()
|
||||||
|
|
||||||
def _on_about_to_finish_callback(self):
|
def _on_about_to_finish_callback(self):
|
||||||
"""Callback that performs a blocking actor call to the real callback.
|
"""Callback that performs a blocking actor call to the real callback.
|
||||||
@ -596,3 +608,17 @@ class PlaybackController(object):
|
|||||||
# TODO: Trigger this from audio events?
|
# TODO: Trigger this from audio events?
|
||||||
logger.debug('Triggering seeked event')
|
logger.debug('Triggering seeked event')
|
||||||
listener.CoreListener.send('seeked', time_position=time_position)
|
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 import exceptions
|
||||||
from mopidy.core import listener
|
from mopidy.core import listener
|
||||||
from mopidy.internal import deprecation, validation
|
from mopidy.internal import deprecation, validation
|
||||||
|
from mopidy.internal.models import TracklistState
|
||||||
from mopidy.models import TlTrack, Track
|
from mopidy.models import TlTrack, Track
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -325,7 +326,10 @@ class TracklistController(object):
|
|||||||
next_index += 1
|
next_index += 1
|
||||||
|
|
||||||
if self.get_repeat():
|
if self.get_repeat():
|
||||||
next_index %= len(self._tl_tracks)
|
if self.get_consume() and len(self._tl_tracks) == 1:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
next_index %= len(self._tl_tracks)
|
||||||
elif next_index >= len(self._tl_tracks):
|
elif next_index >= len(self._tl_tracks):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -646,3 +650,24 @@ class TracklistController(object):
|
|||||||
def _trigger_options_changed(self):
|
def _trigger_options_changed(self):
|
||||||
logger.debug('Triggering options changed event')
|
logger.debug('Triggering options changed event')
|
||||||
listener.CoreListener.send('options_changed')
|
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()
|
||||||
|
|||||||
@ -22,6 +22,7 @@ class Extension(ext.Extension):
|
|||||||
def get_config_schema(self):
|
def get_config_schema(self):
|
||||||
schema = super(Extension, self).get_config_schema()
|
schema = super(Extension, self).get_config_schema()
|
||||||
schema['media_dirs'] = config.List(optional=True)
|
schema['media_dirs'] = config.List(optional=True)
|
||||||
|
schema['excluded_file_extensions'] = config.List(optional=True)
|
||||||
schema['show_dotfiles'] = config.Boolean(optional=True)
|
schema['show_dotfiles'] = config.Boolean(optional=True)
|
||||||
schema['follow_symlinks'] = config.Boolean(optional=True)
|
schema['follow_symlinks'] = config.Boolean(optional=True)
|
||||||
schema['metadata_timeout'] = config.Integer(optional=True)
|
schema['metadata_timeout'] = config.Integer(optional=True)
|
||||||
|
|||||||
@ -4,5 +4,8 @@ media_dirs =
|
|||||||
$XDG_MUSIC_DIR|Music
|
$XDG_MUSIC_DIR|Music
|
||||||
~/|Home
|
~/|Home
|
||||||
show_dotfiles = false
|
show_dotfiles = false
|
||||||
|
excluded_file_extensions =
|
||||||
|
.jpg
|
||||||
|
.jpeg
|
||||||
follow_symlinks = false
|
follow_symlinks = false
|
||||||
metadata_timeout = 1000
|
metadata_timeout = 1000
|
||||||
|
|||||||
@ -34,8 +34,12 @@ class FileLibraryProvider(backend.LibraryProvider):
|
|||||||
def __init__(self, backend, config):
|
def __init__(self, backend, config):
|
||||||
super(FileLibraryProvider, self).__init__(backend)
|
super(FileLibraryProvider, self).__init__(backend)
|
||||||
self._media_dirs = list(self._get_media_dirs(config))
|
self._media_dirs = list(self._get_media_dirs(config))
|
||||||
self._follow_symlinks = config['file']['follow_symlinks']
|
|
||||||
self._show_dotfiles = config['file']['show_dotfiles']
|
self._show_dotfiles = config['file']['show_dotfiles']
|
||||||
|
self._excluded_file_extensions = tuple(
|
||||||
|
bytes(file_ext.lower())
|
||||||
|
for file_ext in config['file']['excluded_file_extensions'])
|
||||||
|
self._follow_symlinks = config['file']['follow_symlinks']
|
||||||
|
|
||||||
self._scanner = scan.Scanner(
|
self._scanner = scan.Scanner(
|
||||||
timeout=config['file']['metadata_timeout'])
|
timeout=config['file']['metadata_timeout'])
|
||||||
|
|
||||||
@ -60,6 +64,10 @@ class FileLibraryProvider(backend.LibraryProvider):
|
|||||||
if not self._show_dotfiles and dir_entry.startswith(b'.'):
|
if not self._show_dotfiles and dir_entry.startswith(b'.'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if (self._excluded_file_extensions and
|
||||||
|
dir_entry.endswith(self._excluded_file_extensions)):
|
||||||
|
continue
|
||||||
|
|
||||||
if os.path.islink(child_path) and not self._follow_symlinks:
|
if os.path.islink(child_path) and not self._follow_symlinks:
|
||||||
logger.debug('Ignoring symlink: %s', uri)
|
logger.debug('Ignoring symlink: %s', uri)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -89,16 +89,12 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def broadcast(cls, msg):
|
def broadcast(cls, msg):
|
||||||
if hasattr(tornado.ioloop.IOLoop, 'current'):
|
loop = tornado.ioloop.IOLoop.current()
|
||||||
loop = tornado.ioloop.IOLoop.current()
|
|
||||||
else:
|
|
||||||
loop = tornado.ioloop.IOLoop.instance() # Fallback for pre 3.0
|
|
||||||
|
|
||||||
# This can be called from outside the Tornado ioloop, so we need to
|
# This can be called from outside the Tornado ioloop, so we need to
|
||||||
# safely cross the thread boundary by adding a callback to the loop.
|
# safely cross the thread boundary by adding a callback to the loop.
|
||||||
for client in cls.clients:
|
for client in cls.clients:
|
||||||
# One callback per client to keep time we hold up the loop short
|
# One callback per client to keep time we hold up the loop short
|
||||||
# NOTE: Pre 3.0 does not support *args or **kwargs...
|
|
||||||
loop.add_callback(functools.partial(_send_broadcast, client, msg))
|
loop.add_callback(functools.partial(_send_broadcast, client, msg))
|
||||||
|
|
||||||
def initialize(self, core):
|
def initialize(self, core):
|
||||||
@ -139,10 +135,7 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = encoding.locale_decode(e)
|
error_msg = encoding.locale_decode(e)
|
||||||
logger.error('WebSocket request error: %s', error_msg)
|
logger.error('WebSocket request error: %s', error_msg)
|
||||||
if self.ws_connection:
|
self.close()
|
||||||
# Tornado 3.2+ checks if self.ws_connection is None before
|
|
||||||
# using it, but not older versions.
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def check_origin(self, origin):
|
def check_origin(self, origin):
|
||||||
# Allow cross-origin WebSocket connections, like Tornado before 4.0
|
# Allow cross-origin WebSocket connections, like Tornado before 4.0
|
||||||
|
|||||||
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)
|
||||||
@ -236,4 +236,5 @@ class Mtime(object):
|
|||||||
def undo_fake(self):
|
def undo_fake(self):
|
||||||
self.fake = None
|
self.fake = None
|
||||||
|
|
||||||
|
|
||||||
mtime = Mtime()
|
mtime = Mtime()
|
||||||
|
|||||||
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)
|
relpath = os.path.relpath(abspath, media_dir)
|
||||||
uri = translator.path_to_local_track_uri(relpath)
|
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)
|
logger.debug('Skipped %s: Hidden directory/file.', uri)
|
||||||
elif relpath.lower().endswith(excluded_file_extensions):
|
elif relpath.lower().endswith(excluded_file_extensions):
|
||||||
logger.debug('Skipped %s: File extension excluded.', uri)
|
logger.debug('Skipped %s: File extension excluded.', uri)
|
||||||
|
|||||||
@ -1,60 +1,22 @@
|
|||||||
from __future__ import absolute_import, absolute_import, unicode_literals
|
from __future__ import absolute_import, absolute_import, unicode_literals
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import gzip
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import mopidy
|
import mopidy
|
||||||
|
|
||||||
from mopidy import compat, local, models
|
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
|
from mopidy.local import search, storage, translator
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class _BrowseCache(object):
|
||||||
encoding = sys.getfilesystemencoding()
|
encoding = sys.getfilesystemencoding()
|
||||||
splitpath_re = re.compile(r'([^/]+)')
|
splitpath_re = re.compile(r'([^/]+)')
|
||||||
@ -128,8 +90,18 @@ class JsonLibrary(local.Library):
|
|||||||
def load(self):
|
def load(self):
|
||||||
logger.debug('Loading library: %s', self._json_file)
|
logger.debug('Loading library: %s', self._json_file)
|
||||||
with timer.time_logger('Loading tracks'):
|
with timer.time_logger('Loading tracks'):
|
||||||
library = load_library(self._json_file)
|
if not os.path.isfile(self._json_file):
|
||||||
self._tracks = dict((t.uri, t) for t in library.get('tracks', []))
|
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'):
|
with timer.time_logger('Building browse cache'):
|
||||||
self._browse_cache = _BrowseCache(sorted(self._tracks.keys()))
|
self._browse_cache = _BrowseCache(sorted(self._tracks.keys()))
|
||||||
return len(self._tracks)
|
return len(self._tracks)
|
||||||
@ -195,7 +167,10 @@ class JsonLibrary(local.Library):
|
|||||||
self._tracks.pop(uri, None)
|
self._tracks.pop(uri, None)
|
||||||
|
|
||||||
def close(self):
|
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):
|
def clear(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -9,5 +9,5 @@ logger = logging.getLogger(__name__)
|
|||||||
def check_dirs_and_files(config):
|
def check_dirs_and_files(config):
|
||||||
if not os.path.isdir(config['local']['media_dir']):
|
if not os.path.isdir(config['local']['media_dir']):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'Local media dir %s does not exist.' %
|
'Local media dir %s does not exist or we lack permissions to the '
|
||||||
config['local']['media_dir'])
|
'directory or one of its parents' % config['local']['media_dir'])
|
||||||
|
|||||||
@ -46,7 +46,7 @@ def path_to_local_track_uri(relpath):
|
|||||||
|
|
||||||
|
|
||||||
def path_to_local_directory_uri(relpath):
|
def path_to_local_directory_uri(relpath):
|
||||||
"""Convert path relative to :confval:`local/media_dir` directory URI."""
|
"""Convert path relative to :confval:`local/media_dir` to directory URI."""
|
||||||
if isinstance(relpath, compat.text_type):
|
if isinstance(relpath, compat.text_type):
|
||||||
relpath = relpath.encode('utf-8')
|
relpath = relpath.encode('utf-8')
|
||||||
return 'local:directory:%s' % urllib.quote(relpath)
|
return 'local:directory:%s' % urllib.quote(relpath)
|
||||||
|
|||||||
@ -192,9 +192,9 @@ class Track(ValidatedImmutableObject):
|
|||||||
:param album: track album
|
:param album: track album
|
||||||
:type album: :class:`Album`
|
:type album: :class:`Album`
|
||||||
:param composers: track composers
|
:param composers: track composers
|
||||||
:type composers: string
|
:type composers: list of :class:`Artist`
|
||||||
:param performers: track performers
|
:param performers: track performers
|
||||||
:type performers: string
|
:type performers: list of :class:`Artist`
|
||||||
:param genre: track genre
|
:param genre: track genre
|
||||||
:type genre: string
|
:type genre: string
|
||||||
:param track_no: track number in album
|
:param track_no: track number in album
|
||||||
|
|||||||
@ -138,6 +138,17 @@ class Integer(Field):
|
|||||||
return value
|
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 Collection(Field):
|
||||||
"""
|
"""
|
||||||
:class:`Field` for storing collections of a given type.
|
:class:`Field` for storing collections of a given type.
|
||||||
|
|||||||
@ -8,6 +8,10 @@ from mopidy.internal import deprecation
|
|||||||
from mopidy.models.fields import Field
|
from mopidy.models.fields import Field
|
||||||
|
|
||||||
|
|
||||||
|
# Registered models for automatic deserialization
|
||||||
|
_models = {}
|
||||||
|
|
||||||
|
|
||||||
class ImmutableObject(object):
|
class ImmutableObject(object):
|
||||||
"""
|
"""
|
||||||
Superclass for immutable objects whose fields can only be modified via the
|
Superclass for immutable objects whose fields can only be modified via the
|
||||||
@ -150,9 +154,14 @@ class _ValidatedImmutableObjectMeta(type):
|
|||||||
attrs['_instances'] = weakref.WeakValueDictionary()
|
attrs['_instances'] = weakref.WeakValueDictionary()
|
||||||
attrs['__slots__'] = list(attrs.get('__slots__', [])) + fields.values()
|
attrs['__slots__'] = list(attrs.get('__slots__', [])) + fields.values()
|
||||||
|
|
||||||
return super(_ValidatedImmutableObjectMeta, cls).__new__(
|
clsc = super(_ValidatedImmutableObjectMeta, cls).__new__(
|
||||||
cls, name, bases, attrs)
|
cls, name, bases, attrs)
|
||||||
|
|
||||||
|
if clsc.__name__ != 'ValidatedImmutableObject':
|
||||||
|
_models[clsc.__name__] = clsc
|
||||||
|
|
||||||
|
return clsc
|
||||||
|
|
||||||
def __call__(cls, *args, **kwargs): # noqa: N805
|
def __call__(cls, *args, **kwargs): # noqa: N805
|
||||||
instance = super(_ValidatedImmutableObjectMeta, cls).__call__(
|
instance = super(_ValidatedImmutableObjectMeta, cls).__call__(
|
||||||
*args, **kwargs)
|
*args, **kwargs)
|
||||||
|
|||||||
@ -4,8 +4,6 @@ import json
|
|||||||
|
|
||||||
from mopidy.models import immutable
|
from mopidy.models import immutable
|
||||||
|
|
||||||
_MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist']
|
|
||||||
|
|
||||||
|
|
||||||
class ModelJSONEncoder(json.JSONEncoder):
|
class ModelJSONEncoder(json.JSONEncoder):
|
||||||
|
|
||||||
@ -40,8 +38,8 @@ def model_json_decoder(dct):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
if '__model__' in dct:
|
if '__model__' in dct:
|
||||||
from mopidy import models
|
|
||||||
model_name = dct.pop('__model__')
|
model_name = dct.pop('__model__')
|
||||||
if model_name in _MODELS:
|
if model_name in immutable._models:
|
||||||
return getattr(models, model_name)(**dct)
|
cls = immutable._models[model_name]
|
||||||
|
return cls(**dct)
|
||||||
return dct
|
return dct
|
||||||
|
|||||||
@ -325,7 +325,7 @@ def replay_gain_status(context):
|
|||||||
Prints replay gain options. Currently, only the variable
|
Prints replay gain options. Currently, only the variable
|
||||||
``replay_gain_mode`` is returned.
|
``replay_gain_mode`` is returned.
|
||||||
"""
|
"""
|
||||||
return 'off' # TODO
|
return 'replay_gain_mode: off' # TODO
|
||||||
|
|
||||||
|
|
||||||
@protocol.commands.add('seek', songpos=protocol.UINT, seconds=protocol.UINT)
|
@protocol.commands.add('seek', songpos=protocol.UINT, seconds=protocol.UINT)
|
||||||
|
|||||||
@ -173,6 +173,7 @@ def status(context):
|
|||||||
decimal places for millisecond precision.
|
decimal places for millisecond precision.
|
||||||
"""
|
"""
|
||||||
tl_track = context.core.playback.get_current_tl_track()
|
tl_track = context.core.playback.get_current_tl_track()
|
||||||
|
next_tlid = context.core.tracklist.get_next_tlid()
|
||||||
|
|
||||||
futures = {
|
futures = {
|
||||||
'tracklist.length': context.core.tracklist.get_length(),
|
'tracklist.length': context.core.tracklist.get_length(),
|
||||||
@ -185,6 +186,9 @@ def status(context):
|
|||||||
'playback.state': context.core.playback.get_state(),
|
'playback.state': context.core.playback.get_state(),
|
||||||
'playback.current_tl_track': tl_track,
|
'playback.current_tl_track': tl_track,
|
||||||
'tracklist.index': context.core.tracklist.index(tl_track.get()),
|
'tracklist.index': context.core.tracklist.index(tl_track.get()),
|
||||||
|
'tracklist.next_tlid': next_tlid,
|
||||||
|
'tracklist.next_index': context.core.tracklist.index(
|
||||||
|
tlid=next_tlid.get()),
|
||||||
'playback.time_position': context.core.playback.get_time_position(),
|
'playback.time_position': context.core.playback.get_time_position(),
|
||||||
}
|
}
|
||||||
pykka.get_all(futures.values())
|
pykka.get_all(futures.values())
|
||||||
@ -199,10 +203,12 @@ def status(context):
|
|||||||
('xfade', _status_xfade(futures)),
|
('xfade', _status_xfade(futures)),
|
||||||
('state', _status_state(futures)),
|
('state', _status_state(futures)),
|
||||||
]
|
]
|
||||||
# TODO: add nextsong and nextsongid
|
|
||||||
if futures['playback.current_tl_track'].get() is not None:
|
if futures['playback.current_tl_track'].get() is not None:
|
||||||
result.append(('song', _status_songpos(futures)))
|
result.append(('song', _status_songpos(futures)))
|
||||||
result.append(('songid', _status_songid(futures)))
|
result.append(('songid', _status_songid(futures)))
|
||||||
|
if futures['tracklist.next_tlid'].get() is not None:
|
||||||
|
result.append(('nextsong', _status_nextsongpos(futures)))
|
||||||
|
result.append(('nextsongid', _status_nextsongid(futures)))
|
||||||
if futures['playback.state'].get() in (
|
if futures['playback.state'].get() in (
|
||||||
PlaybackState.PLAYING, PlaybackState.PAUSED):
|
PlaybackState.PLAYING, PlaybackState.PAUSED):
|
||||||
result.append(('time', _status_time(futures)))
|
result.append(('time', _status_time(futures)))
|
||||||
@ -259,6 +265,14 @@ def _status_songpos(futures):
|
|||||||
return futures['tracklist.index'].get()
|
return futures['tracklist.index'].get()
|
||||||
|
|
||||||
|
|
||||||
|
def _status_nextsongid(futures):
|
||||||
|
return futures['tracklist.next_tlid'].get()
|
||||||
|
|
||||||
|
|
||||||
|
def _status_nextsongpos(futures):
|
||||||
|
return futures['tracklist.next_index'].get()
|
||||||
|
|
||||||
|
|
||||||
def _status_state(futures):
|
def _status_state(futures):
|
||||||
state = futures['playback.state'].get()
|
state = futures['playback.state'].get()
|
||||||
if state == PlaybackState.PLAYING:
|
if state == PlaybackState.PLAYING:
|
||||||
|
|||||||
2
setup.py
2
setup.py
@ -27,7 +27,7 @@ setup(
|
|||||||
'Pykka >= 1.1',
|
'Pykka >= 1.1',
|
||||||
'requests >= 2.0',
|
'requests >= 2.0',
|
||||||
'setuptools',
|
'setuptools',
|
||||||
'tornado >= 2.3',
|
'tornado >= 3.2',
|
||||||
],
|
],
|
||||||
extras_require={'http': []},
|
extras_require={'http': []},
|
||||||
entry_points={
|
entry_points={
|
||||||
|
|||||||
@ -120,7 +120,7 @@ class TagsToTrackTest(unittest.TestCase):
|
|||||||
num_tracks=2, num_discs=3,
|
num_tracks=2, num_discs=3,
|
||||||
musicbrainz_id='albumid', artists=[albumartist])
|
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,
|
genre='genre', track_no=1, disc_no=2,
|
||||||
comment='comment', musicbrainz_id='trackid',
|
comment='comment', musicbrainz_id='trackid',
|
||||||
album=album, bitrate=1000, artists=[artist],
|
album=album, bitrate=1000, artists=[artist],
|
||||||
@ -183,8 +183,9 @@ class TagsToTrackTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_missing_track_date(self):
|
def test_missing_track_date(self):
|
||||||
del self.tags['date']
|
del self.tags['date']
|
||||||
self.check(
|
self.check(self.track.replace(
|
||||||
self.track.replace(album=self.track.album.replace(date=None)))
|
album=self.track.album.replace(date=None),
|
||||||
|
date=None))
|
||||||
|
|
||||||
def test_multiple_track_date(self):
|
def test_multiple_track_date(self):
|
||||||
self.tags['date'].append('2030-01-01')
|
self.tags['date'].append('2030-01-01')
|
||||||
|
|||||||
@ -1,13 +1,20 @@
|
|||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import mock
|
import mock
|
||||||
|
|
||||||
import pykka
|
import pykka
|
||||||
|
|
||||||
|
import mopidy
|
||||||
|
|
||||||
from mopidy.core import Core
|
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):
|
class CoreActorTest(unittest.TestCase):
|
||||||
@ -43,3 +50,106 @@ class CoreActorTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_version(self):
|
def test_version(self):
|
||||||
self.assertEqual(self.core.version, versioning.get_version())
|
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 import compat
|
||||||
from mopidy.core import HistoryController
|
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):
|
class PlaybackHistoryTest(unittest.TestCase):
|
||||||
@ -46,3 +47,60 @@ class PlaybackHistoryTest(unittest.TestCase):
|
|||||||
self.assertIn(track.name, ref.name)
|
self.assertIn(track.name, ref.name)
|
||||||
for artist in track.artists:
|
for artist in track.artists:
|
||||||
self.assertIn(artist.name, ref.name)
|
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
|
import pykka
|
||||||
|
|
||||||
from mopidy import core, mixer
|
from mopidy import core, mixer
|
||||||
|
from mopidy.internal.models import MixerState
|
||||||
from tests import dummy_mixer
|
from tests import dummy_mixer
|
||||||
|
|
||||||
|
|
||||||
@ -154,3 +155,68 @@ class SetMuteBadBackendTest(MockBackendCoreMixerBase):
|
|||||||
def test_backend_returns_wrong_type(self):
|
def test_backend_returns_wrong_type(self):
|
||||||
self.mixer.set_mute.return_value.get.return_value = 'done'
|
self.mixer.set_mute.return_value.get.return_value = 'done'
|
||||||
self.assertFalse(self.core.mixer.set_mute(True))
|
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 import backend, core
|
||||||
from mopidy.internal import deprecation
|
from mopidy.internal import deprecation
|
||||||
|
from mopidy.internal.models import PlaybackState
|
||||||
from mopidy.models import Track
|
from mopidy.models import Track
|
||||||
|
|
||||||
from tests import dummy_audio
|
from tests import dummy_audio
|
||||||
@ -430,6 +431,21 @@ class TestConsumeHandling(BaseTest):
|
|||||||
|
|
||||||
self.assertNotIn(tl_track, self.core.tracklist.get_tl_tracks())
|
self.assertNotIn(tl_track, self.core.tracklist.get_tl_tracks())
|
||||||
|
|
||||||
|
def test_next_in_consume_and_repeat_mode_returns_none_on_last_track(self):
|
||||||
|
self.core.playback.play()
|
||||||
|
self.core.tracklist.set_consume(True)
|
||||||
|
self.core.tracklist.set_repeat(True)
|
||||||
|
self.replay_events()
|
||||||
|
|
||||||
|
for track in self.core.tracklist.get_tl_tracks():
|
||||||
|
self.core.playback.next()
|
||||||
|
self.replay_events()
|
||||||
|
|
||||||
|
self.core.playback.next()
|
||||||
|
self.replay_events()
|
||||||
|
|
||||||
|
self.assertEqual(self.playback.get_state(), 'stopped')
|
||||||
|
|
||||||
|
|
||||||
class TestCurrentAndPendingTlTrack(BaseTest):
|
class TestCurrentAndPendingTlTrack(BaseTest):
|
||||||
|
|
||||||
@ -1132,6 +1148,62 @@ class TestBug1177Regression(unittest.TestCase):
|
|||||||
b.playback.change_track.assert_called_once_with(track2)
|
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):
|
class TestBug1352Regression(BaseTest):
|
||||||
tracks = [
|
tracks = [
|
||||||
Track(uri='dummy:a', length=40000),
|
Track(uri='dummy:a', length=40000),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import mock
|
|||||||
|
|
||||||
from mopidy import backend, core
|
from mopidy import backend, core
|
||||||
from mopidy.internal import deprecation
|
from mopidy.internal import deprecation
|
||||||
|
from mopidy.internal.models import TracklistState
|
||||||
from mopidy.models import TlTrack, Track
|
from mopidy.models import TlTrack, Track
|
||||||
|
|
||||||
|
|
||||||
@ -177,3 +178,119 @@ class TracklistIndexTest(unittest.TestCase):
|
|||||||
self.assertEqual(0, self.core.tracklist.index())
|
self.assertEqual(0, self.core.tracklist.index())
|
||||||
self.assertEqual(1, self.core.tracklist.index())
|
self.assertEqual(1, self.core.tracklist.index())
|
||||||
self.assertEqual(2, 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)
|
||||||
@ -4,7 +4,8 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from mopidy.models.fields import Collection, Field, Identifier, Integer, String
|
from mopidy.models.fields import (Boolean, Collection, Field, Identifier,
|
||||||
|
Integer, String)
|
||||||
|
|
||||||
|
|
||||||
def create_instance(field):
|
def create_instance(field):
|
||||||
@ -211,6 +212,27 @@ class IntegerTest(unittest.TestCase):
|
|||||||
instance.attr = 11
|
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):
|
class CollectionTest(unittest.TestCase):
|
||||||
def test_container_instance_is_default(self):
|
def test_container_instance_is_default(self):
|
||||||
instance = create_instance(Collection(type=int, container=frozenset))
|
instance = create_instance(Collection(type=int, container=frozenset))
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import json
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from mopidy.models import (
|
from mopidy.models import (
|
||||||
Album, Artist, Image, ModelJSONEncoder, Playlist, Ref, SearchResult,
|
Album, Artist, Image, ModelJSONEncoder, Playlist,
|
||||||
TlTrack, Track, model_json_decoder)
|
Ref, SearchResult, TlTrack, Track, model_json_decoder)
|
||||||
|
|
||||||
|
|
||||||
class InheritanceTest(unittest.TestCase):
|
class InheritanceTest(unittest.TestCase):
|
||||||
|
|||||||
@ -115,7 +115,7 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase):
|
|||||||
def test_replay_gain_status_default(self):
|
def test_replay_gain_status_default(self):
|
||||||
self.send_request('replay_gain_status')
|
self.send_request('replay_gain_status')
|
||||||
self.assertInResponse('OK')
|
self.assertInResponse('OK')
|
||||||
self.assertInResponse('off')
|
self.assertInResponse('replay_gain_mode: off')
|
||||||
|
|
||||||
def test_mixrampdb(self):
|
def test_mixrampdb(self):
|
||||||
self.send_request('mixrampdb "10"')
|
self.send_request('mixrampdb "10"')
|
||||||
|
|||||||
@ -48,9 +48,9 @@ class StatusHandlerTest(unittest.TestCase):
|
|||||||
def tearDown(self): # noqa: N802
|
def tearDown(self): # noqa: N802
|
||||||
pykka.ActorRegistry.stop_all()
|
pykka.ActorRegistry.stop_all()
|
||||||
|
|
||||||
def set_tracklist(self, track):
|
def set_tracklist(self, tracks):
|
||||||
self.backend.library.dummy_library = [track]
|
self.backend.library.dummy_library = tracks
|
||||||
self.core.tracklist.add(uris=[track.uri]).get()
|
self.core.tracklist.add(uris=[track.uri for track in tracks]).get()
|
||||||
|
|
||||||
def test_stats_method(self):
|
def test_stats_method(self):
|
||||||
result = status.stats(self.context)
|
result = status.stats(self.context)
|
||||||
@ -154,22 +154,35 @@ class StatusHandlerTest(unittest.TestCase):
|
|||||||
self.assertEqual(result['state'], 'pause')
|
self.assertEqual(result['state'], 'pause')
|
||||||
|
|
||||||
def test_status_method_when_playlist_loaded_contains_song(self):
|
def test_status_method_when_playlist_loaded_contains_song(self):
|
||||||
self.set_tracklist(Track(uri='dummy:/a'))
|
self.set_tracklist([Track(uri='dummy:/a')])
|
||||||
|
|
||||||
self.core.playback.play().get()
|
self.core.playback.play().get()
|
||||||
result = dict(status.status(self.context))
|
result = dict(status.status(self.context))
|
||||||
self.assertIn('song', result)
|
self.assertIn('song', result)
|
||||||
self.assertGreaterEqual(int(result['song']), 0)
|
self.assertGreaterEqual(int(result['song']), 0)
|
||||||
|
|
||||||
def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self):
|
def test_status_method_when_playlist_loaded_contains_tlid_as_songid(self):
|
||||||
self.set_tracklist(Track(uri='dummy:/a'))
|
self.set_tracklist([Track(uri='dummy:/a')])
|
||||||
self.core.playback.play().get()
|
self.core.playback.play().get()
|
||||||
result = dict(status.status(self.context))
|
result = dict(status.status(self.context))
|
||||||
self.assertIn('songid', result)
|
self.assertIn('songid', result)
|
||||||
self.assertEqual(int(result['songid']), 1)
|
self.assertEqual(int(result['songid']), 1)
|
||||||
|
|
||||||
|
def test_status_method_when_playlist_loaded_contains_nextsong(self):
|
||||||
|
self.set_tracklist([Track(uri='dummy:/a'), Track(uri='dummy:/b')])
|
||||||
|
self.core.playback.play().get()
|
||||||
|
result = dict(status.status(self.context))
|
||||||
|
self.assertIn('nextsong', result)
|
||||||
|
self.assertGreaterEqual(int(result['nextsong']), 0)
|
||||||
|
|
||||||
|
def test_status_method_when_playlist_loaded_contains_nextsongid(self):
|
||||||
|
self.set_tracklist([Track(uri='dummy:/a'), Track(uri='dummy:/b')])
|
||||||
|
self.core.playback.play().get()
|
||||||
|
result = dict(status.status(self.context))
|
||||||
|
self.assertIn('nextsongid', result)
|
||||||
|
self.assertEqual(int(result['nextsongid']), 2)
|
||||||
|
|
||||||
def test_status_method_when_playing_contains_time_with_no_length(self):
|
def test_status_method_when_playing_contains_time_with_no_length(self):
|
||||||
self.set_tracklist(Track(uri='dummy:/a', length=None))
|
self.set_tracklist([Track(uri='dummy:/a', length=None)])
|
||||||
self.core.playback.play().get()
|
self.core.playback.play().get()
|
||||||
result = dict(status.status(self.context))
|
result = dict(status.status(self.context))
|
||||||
self.assertIn('time', result)
|
self.assertIn('time', result)
|
||||||
@ -179,7 +192,7 @@ class StatusHandlerTest(unittest.TestCase):
|
|||||||
self.assertLessEqual(position, total)
|
self.assertLessEqual(position, total)
|
||||||
|
|
||||||
def test_status_method_when_playing_contains_time_with_length(self):
|
def test_status_method_when_playing_contains_time_with_length(self):
|
||||||
self.set_tracklist(Track(uri='dummy:/a', length=10000))
|
self.set_tracklist([Track(uri='dummy:/a', length=10000)])
|
||||||
self.core.playback.play()
|
self.core.playback.play()
|
||||||
result = dict(status.status(self.context))
|
result = dict(status.status(self.context))
|
||||||
self.assertIn('time', result)
|
self.assertIn('time', result)
|
||||||
@ -189,7 +202,7 @@ class StatusHandlerTest(unittest.TestCase):
|
|||||||
self.assertLessEqual(position, total)
|
self.assertLessEqual(position, total)
|
||||||
|
|
||||||
def test_status_method_when_playing_contains_elapsed(self):
|
def test_status_method_when_playing_contains_elapsed(self):
|
||||||
self.set_tracklist(Track(uri='dummy:/a', length=60000))
|
self.set_tracklist([Track(uri='dummy:/a', length=60000)])
|
||||||
self.core.playback.play().get()
|
self.core.playback.play().get()
|
||||||
self.core.playback.pause()
|
self.core.playback.pause()
|
||||||
self.core.playback.seek(59123)
|
self.core.playback.seek(59123)
|
||||||
@ -198,7 +211,7 @@ class StatusHandlerTest(unittest.TestCase):
|
|||||||
self.assertEqual(result['elapsed'], '59.123')
|
self.assertEqual(result['elapsed'], '59.123')
|
||||||
|
|
||||||
def test_status_method_when_starting_playing_contains_elapsed_zero(self):
|
def test_status_method_when_starting_playing_contains_elapsed_zero(self):
|
||||||
self.set_tracklist(Track(uri='dummy:/a', length=10000))
|
self.set_tracklist([Track(uri='dummy:/a', length=10000)])
|
||||||
self.core.playback.play().get()
|
self.core.playback.play().get()
|
||||||
self.core.playback.pause()
|
self.core.playback.pause()
|
||||||
result = dict(status.status(self.context))
|
result = dict(status.status(self.context))
|
||||||
@ -206,7 +219,7 @@ class StatusHandlerTest(unittest.TestCase):
|
|||||||
self.assertEqual(result['elapsed'], '0.000')
|
self.assertEqual(result['elapsed'], '0.000')
|
||||||
|
|
||||||
def test_status_method_when_playing_contains_bitrate(self):
|
def test_status_method_when_playing_contains_bitrate(self):
|
||||||
self.set_tracklist(Track(uri='dummy:/a', bitrate=3200))
|
self.set_tracklist([Track(uri='dummy:/a', bitrate=3200)])
|
||||||
self.core.playback.play().get()
|
self.core.playback.play().get()
|
||||||
result = dict(status.status(self.context))
|
result = dict(status.status(self.context))
|
||||||
self.assertIn('bitrate', result)
|
self.assertIn('bitrate', result)
|
||||||
|
|||||||
12
tox.ini
12
tox.ini
@ -1,5 +1,5 @@
|
|||||||
[tox]
|
[tox]
|
||||||
envlist = py27, py27-tornado23, py27-tornado31, docs, flake8
|
envlist = py27, py27-tornado32, docs, flake8
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
sitepackages = true
|
sitepackages = true
|
||||||
@ -17,17 +17,11 @@ deps =
|
|||||||
pytest-xdist
|
pytest-xdist
|
||||||
responses
|
responses
|
||||||
|
|
||||||
[testenv:py27-tornado23]
|
[testenv:py27-tornado32]
|
||||||
commands = py.test tests/http
|
commands = py.test tests/http
|
||||||
deps =
|
deps =
|
||||||
{[testenv]deps}
|
{[testenv]deps}
|
||||||
tornado==2.3
|
tornado==3.2.2
|
||||||
|
|
||||||
[testenv:py27-tornado31]
|
|
||||||
commands = py.test tests/http
|
|
||||||
deps =
|
|
||||||
{[testenv]deps}
|
|
||||||
tornado==3.1.1
|
|
||||||
|
|
||||||
[testenv:docs]
|
[testenv:docs]
|
||||||
deps = -r{toxinidir}/docs/requirements.txt
|
deps = -r{toxinidir}/docs/requirements.txt
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user