Release v2.1.0

This commit is contained in:
Stein Magnus Jodal 2017-01-02 23:58:02 +01:00
commit 4c1e2960b6
50 changed files with 1337 additions and 155 deletions

3
.github/ISSUE_TEMPLATE.md vendored Normal file
View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@ -0,0 +1,144 @@
from __future__ import absolute_import, unicode_literals
from mopidy.internal import validation
from mopidy.models import Ref, TlTrack, fields
from mopidy.models.immutable import ValidatedImmutableObject
class HistoryTrack(ValidatedImmutableObject):
"""
A history track. Wraps a :class:`Ref` and its timestamp.
:param timestamp: the timestamp
:type timestamp: int
:param track: the track reference
:type track: :class:`Ref`
"""
# The timestamp. Read-only.
timestamp = fields.Integer()
# The track reference. Read-only.
track = fields.Field(type=Ref)
class HistoryState(ValidatedImmutableObject):
"""
State of the history controller.
Internally used for save/load state.
:param history: the track history
:type history: list of :class:`HistoryTrack`
"""
# The tracks. Read-only.
history = fields.Collection(type=HistoryTrack, container=tuple)
class MixerState(ValidatedImmutableObject):
"""
State of the mixer controller.
Internally used for save/load state.
:param volume: the volume
:type volume: int
:param mute: the mute state
:type mute: int
"""
# The volume. Read-only.
volume = fields.Integer(min=0, max=100)
# The mute state. Read-only.
mute = fields.Boolean(default=False)
class PlaybackState(ValidatedImmutableObject):
"""
State of the playback controller.
Internally used for save/load state.
:param tlid: current track tlid
:type tlid: int
:param time_position: play position
:type time_position: int
:param state: playback state
:type state: :class:`validation.PLAYBACK_STATES`
"""
# The tlid of current playing track. Read-only.
tlid = fields.Integer(min=1)
# The playback position. Read-only.
time_position = fields.Integer(min=0)
# The playback state. Read-only.
state = fields.Field(choices=validation.PLAYBACK_STATES)
class TracklistState(ValidatedImmutableObject):
"""
State of the tracklist controller.
Internally used for save/load state.
:param repeat: the repeat mode
:type repeat: bool
:param consume: the consume mode
:type consume: bool
:param random: the random mode
:type random: bool
:param single: the single mode
:type single: bool
:param next_tlid: the id for the next added track
:type next_tlid: int
:param tl_tracks: the list of tracks
:type tl_tracks: list of :class:`TlTrack`
"""
# The repeat mode. Read-only.
repeat = fields.Boolean()
# The consume mode. Read-only.
consume = fields.Boolean()
# The random mode. Read-only.
random = fields.Boolean()
# The single mode. Read-only.
single = fields.Boolean()
# The id of the track to play. Read-only.
next_tlid = fields.Integer(min=0)
# The list of tracks. Read-only.
tl_tracks = fields.Collection(type=TlTrack, container=tuple)
class CoreState(ValidatedImmutableObject):
"""
State of all Core controller.
Internally used for save/load state.
:param history: State of the history controller
:type history: :class:`HistorState`
:param mixer: State of the mixer controller
:type mixer: :class:`MixerState`
:param playback: State of the playback controller
:type playback: :class:`PlaybackState`
:param tracklist: State of the tracklist controller
:type tracklist: :class:`TracklistState`
"""
# State of the history controller.
history = fields.Field(type=HistoryState)
# State of the mixer controller.
mixer = fields.Field(type=MixerState)
# State of the playback controller.
playback = fields.Field(type=PlaybackState)
# State of the tracklist controller.
tracklist = fields.Field(type=TracklistState)

View File

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

View File

@ -0,0 +1,60 @@
from __future__ import absolute_import, unicode_literals
import gzip
import json
import logging
import os
import tempfile
from mopidy import models
from mopidy.internal import encoding
logger = logging.getLogger(__name__)
def load(path):
"""
Deserialize data from file.
:param path: full path to import file
:type path: bytes
:return: deserialized data
:rtype: dict
"""
# Todo: raise an exception in case of error?
if not os.path.isfile(path):
logger.info('File does not exist: %s', path)
return {}
try:
with gzip.open(path, 'rb') as fp:
return json.load(fp, object_hook=models.model_json_decoder)
except (IOError, ValueError) as error:
logger.warning(
'Loading JSON failed: %s',
encoding.locale_decode(error))
return {}
def dump(path, data):
"""
Serialize data to file.
:param path: full path to export file
:type path: bytes
:param data: dictionary containing data to save
:type data: dict
"""
directory, basename = os.path.split(path)
# TODO: cleanup directory/basename.* files.
tmp = tempfile.NamedTemporaryFile(
prefix=basename + '.', dir=directory, delete=False)
try:
with gzip.GzipFile(fileobj=tmp, mode='wb') as fp:
json.dump(data, fp, cls=models.ModelJSONEncoder,
indent=2, separators=(',', ': '))
os.rename(tmp.name, path)
finally:
if os.path.exists(tmp.name):
os.remove(tmp.name)

View File

@ -118,7 +118,7 @@ class ScanCommand(commands.Command):
relpath = os.path.relpath(abspath, media_dir) 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,218 @@
from __future__ import absolute_import, unicode_literals
import json
import unittest
from mopidy.internal.models import (
HistoryState, HistoryTrack, MixerState, PlaybackState, TracklistState)
from mopidy.models import (
ModelJSONEncoder, Ref, TlTrack, Track, model_json_decoder)
class HistoryTrackTest(unittest.TestCase):
def test_track(self):
track = Ref.track()
result = HistoryTrack(track=track)
self.assertEqual(result.track, track)
with self.assertRaises(AttributeError):
result.track = None
def test_timestamp(self):
timestamp = 1234
result = HistoryTrack(timestamp=timestamp)
self.assertEqual(result.timestamp, timestamp)
with self.assertRaises(AttributeError):
result.timestamp = None
def test_to_json_and_back(self):
result = HistoryTrack(track=Ref.track(), timestamp=1234)
serialized = json.dumps(result, cls=ModelJSONEncoder)
deserialized = json.loads(serialized, object_hook=model_json_decoder)
self.assertEqual(result, deserialized)
class HistoryStateTest(unittest.TestCase):
def test_history_list(self):
history = (HistoryTrack(),
HistoryTrack())
result = HistoryState(history=history)
self.assertEqual(result.history, history)
with self.assertRaises(AttributeError):
result.history = None
def test_history_string_fail(self):
history = 'not_a_valid_history'
with self.assertRaises(TypeError):
HistoryState(history=history)
def test_to_json_and_back(self):
result = HistoryState(history=(HistoryTrack(), HistoryTrack()))
serialized = json.dumps(result, cls=ModelJSONEncoder)
deserialized = json.loads(serialized, object_hook=model_json_decoder)
self.assertEqual(result, deserialized)
class MixerStateTest(unittest.TestCase):
def test_volume(self):
volume = 37
result = MixerState(volume=volume)
self.assertEqual(result.volume, volume)
with self.assertRaises(AttributeError):
result.volume = None
def test_volume_invalid(self):
volume = 105
with self.assertRaises(ValueError):
MixerState(volume=volume)
def test_mute_false(self):
mute = False
result = MixerState(mute=mute)
self.assertEqual(result.mute, mute)
with self.assertRaises(AttributeError):
result.mute = None
def test_mute_true(self):
mute = True
result = MixerState(mute=mute)
self.assertEqual(result.mute, mute)
with self.assertRaises(AttributeError):
result.mute = False
def test_mute_default(self):
result = MixerState()
self.assertEqual(result.mute, False)
def test_to_json_and_back(self):
result = MixerState(volume=77)
serialized = json.dumps(result, cls=ModelJSONEncoder)
deserialized = json.loads(serialized, object_hook=model_json_decoder)
self.assertEqual(result, deserialized)
class PlaybackStateTest(unittest.TestCase):
def test_position(self):
time_position = 123456
result = PlaybackState(time_position=time_position)
self.assertEqual(result.time_position, time_position)
with self.assertRaises(AttributeError):
result.time_position = None
def test_position_invalid(self):
time_position = -1
with self.assertRaises(ValueError):
PlaybackState(time_position=time_position)
def test_tl_track(self):
tlid = 42
result = PlaybackState(tlid=tlid)
self.assertEqual(result.tlid, tlid)
with self.assertRaises(AttributeError):
result.tlid = None
def test_tl_track_none(self):
tlid = None
result = PlaybackState(tlid=tlid)
self.assertEqual(result.tlid, tlid)
with self.assertRaises(AttributeError):
result.tl_track = None
def test_tl_track_invalid(self):
tl_track = Track()
with self.assertRaises(TypeError):
PlaybackState(tlid=tl_track)
def test_state(self):
state = 'playing'
result = PlaybackState(state=state)
self.assertEqual(result.state, state)
with self.assertRaises(AttributeError):
result.state = None
def test_state_invalid(self):
state = 'not_a_state'
with self.assertRaises(TypeError):
PlaybackState(state=state)
def test_to_json_and_back(self):
result = PlaybackState(state='playing', tlid=4321)
serialized = json.dumps(result, cls=ModelJSONEncoder)
deserialized = json.loads(serialized, object_hook=model_json_decoder)
self.assertEqual(result, deserialized)
class TracklistStateTest(unittest.TestCase):
def test_repeat_true(self):
repeat = True
result = TracklistState(repeat=repeat)
self.assertEqual(result.repeat, repeat)
with self.assertRaises(AttributeError):
result.repeat = None
def test_repeat_false(self):
repeat = False
result = TracklistState(repeat=repeat)
self.assertEqual(result.repeat, repeat)
with self.assertRaises(AttributeError):
result.repeat = None
def test_repeat_invalid(self):
repeat = 33
with self.assertRaises(TypeError):
TracklistState(repeat=repeat)
def test_consume_true(self):
val = True
result = TracklistState(consume=val)
self.assertEqual(result.consume, val)
with self.assertRaises(AttributeError):
result.repeat = None
def test_random_true(self):
val = True
result = TracklistState(random=val)
self.assertEqual(result.random, val)
with self.assertRaises(AttributeError):
result.random = None
def test_single_true(self):
val = True
result = TracklistState(single=val)
self.assertEqual(result.single, val)
with self.assertRaises(AttributeError):
result.single = None
def test_next_tlid(self):
val = 654
result = TracklistState(next_tlid=val)
self.assertEqual(result.next_tlid, val)
with self.assertRaises(AttributeError):
result.next_tlid = None
def test_next_tlid_invalid(self):
val = -1
with self.assertRaises(ValueError):
TracklistState(next_tlid=val)
def test_tracks(self):
tracks = (TlTrack(), TlTrack())
result = TracklistState(tl_tracks=tracks)
self.assertEqual(result.tl_tracks, tracks)
with self.assertRaises(AttributeError):
result.tl_tracks = None
def test_tracks_invalid(self):
tracks = (Track(), Track())
with self.assertRaises(TypeError):
TracklistState(tl_tracks=tracks)
def test_to_json_and_back(self):
result = TracklistState(tl_tracks=(TlTrack(), TlTrack()), next_tlid=4)
serialized = json.dumps(result, cls=ModelJSONEncoder)
deserialized = json.loads(serialized, object_hook=model_json_decoder)
self.assertEqual(result, deserialized)

View File

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

View File

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

View File

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

View File

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

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