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:
|
||||
- TOX_ENV=py27
|
||||
- TOX_ENV=py27-tornado23
|
||||
- TOX_ENV=py27-tornado31
|
||||
- TOX_ENV=py27-tornado32
|
||||
- TOX_ENV=docs
|
||||
- TOX_ENV=flake8
|
||||
|
||||
|
||||
@ -53,7 +53,6 @@ in core see :class:`~mopidy.core.CoreListener`.
|
||||
|
||||
.. automethod:: get_version
|
||||
|
||||
|
||||
Tracklist controller
|
||||
====================
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
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
|
||||
<http://www.apache.org/licenses/LICENSE-2.0>`_.
|
||||
|
||||
|
||||
@ -5,6 +5,58 @@ Changelog
|
||||
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)
|
||||
===================
|
||||
|
||||
|
||||
@ -73,14 +73,14 @@ source_suffix = '.rst'
|
||||
master_doc = 'index'
|
||||
|
||||
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
|
||||
release = get_version()
|
||||
version = '.'.join(release.split('.')[:2])
|
||||
|
||||
# To make the build reproducible, avoid using today's date in the manpages
|
||||
today = '2016'
|
||||
today = '2017'
|
||||
|
||||
exclude_trees = ['_build']
|
||||
|
||||
|
||||
@ -111,6 +111,13 @@ Core config section
|
||||
The original MPD server only supports 10000 tracks in the tracklist. Some
|
||||
MPD clients will crash if this limit is exceeded.
|
||||
|
||||
.. confval:: core/restore_state
|
||||
|
||||
When set to ``true``, Mopidy restores its last state when started.
|
||||
The restored state includes the tracklist, playback history,
|
||||
the playback state, the volume, and mute state.
|
||||
|
||||
Default is ``false``.
|
||||
|
||||
.. _audio-config:
|
||||
|
||||
|
||||
@ -27,18 +27,24 @@ See :ref:`config` for general help on configuring Mopidy.
|
||||
.. confval:: file/media_dirs
|
||||
|
||||
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
|
||||
|
||||
Whether to show hidden files and directories that start with a dot.
|
||||
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
|
||||
|
||||
Whether to follow symbolic links found in :confval:`file/media_dirs`.
|
||||
Directories and files that are outside the configured directories will not be shown.
|
||||
Default is false.
|
||||
Directories and files that are outside the configured directories will not
|
||||
be shown. Default is false.
|
||||
|
||||
.. confval:: file/metadata_timeout
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
Sphinx >= 1.0
|
||||
pygraphviz
|
||||
Pykka >= 1.1
|
||||
# Require newer requests than what Travis/Debian has to work around linkcheck crash
|
||||
requests > 2.4.3
|
||||
sphinx_rtd_theme
|
||||
|
||||
@ -14,4 +14,4 @@ if not (2, 7) <= sys.version_info < (3,):
|
||||
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)
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import time
|
||||
|
||||
from mopidy import exceptions
|
||||
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
|
||||
|
||||
# GST_ELEMENT_FACTORY_LIST:
|
||||
@ -23,6 +24,12 @@ _SELECT_EXPOSE = 1
|
||||
_Result = collections.namedtuple(
|
||||
'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)?
|
||||
class Scanner(object):
|
||||
@ -78,25 +85,52 @@ def _setup_pipeline(uri, proxy_config=None):
|
||||
if not src:
|
||||
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:
|
||||
utils.setup_proxy(src, proxy_config)
|
||||
|
||||
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(decodebin, 'pad-added', _pad_added, pipeline)
|
||||
signals.connect(decodebin, 'autoplug-select', _autoplug_select)
|
||||
|
||||
return pipeline, signals
|
||||
|
||||
|
||||
def _have_type(element, probability, caps, decodebin):
|
||||
decodebin.set_property('sink-caps', caps)
|
||||
@ -173,59 +207,68 @@ def _process(pipeline, timeout_ms):
|
||||
timeout = timeout_ms
|
||||
start = int(time.time() * 1000)
|
||||
while timeout > 0:
|
||||
message = bus.timed_pop_filtered(timeout * Gst.MSECOND, types)
|
||||
|
||||
if message is None:
|
||||
msg = bus.timed_pop_filtered(timeout * Gst.MSECOND, types)
|
||||
if msg is None:
|
||||
break
|
||||
elif message.type == Gst.MessageType.ELEMENT:
|
||||
if GstPbutils.is_missing_plugin_message(message):
|
||||
missing_message = message
|
||||
elif message.type == Gst.MessageType.APPLICATION:
|
||||
if message.get_structure().get_name() == 'have-type':
|
||||
mime = message.get_structure().get_value('caps').get_name()
|
||||
|
||||
if logger.isEnabledFor(log.TRACE_LOG_LEVEL) and msg.get_structure():
|
||||
debug_text = msg.get_structure().to_string()
|
||||
if len(debug_text) > 77:
|
||||
debug_text = debug_text[:77] + '...'
|
||||
_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 (
|
||||
mime.startswith('text/') or mime == 'application/xml'):
|
||||
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
|
||||
elif message.type == Gst.MessageType.ERROR:
|
||||
error = encoding.locale_decode(message.parse_error()[0])
|
||||
elif msg.type == Gst.MessageType.ERROR:
|
||||
error = encoding.locale_decode(msg.parse_error()[0])
|
||||
if missing_message and not mime:
|
||||
caps = missing_message.get_structure().get_value('detail')
|
||||
mime = caps.get_structure(0).get_name()
|
||||
return tags, mime, have_audio, duration
|
||||
raise exceptions.ScannerError(error)
|
||||
elif message.type == Gst.MessageType.EOS:
|
||||
elif msg.type == Gst.MessageType.EOS:
|
||||
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)
|
||||
if tags and success:
|
||||
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
|
||||
# after pre-roll. We get around this by starting to play the track
|
||||
# and then waiting for a duration change.
|
||||
# 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)
|
||||
if result == Gst.StateChangeReturn.FAILURE:
|
||||
return tags, mime, have_audio, duration
|
||||
|
||||
elif message.type == Gst.MessageType.DURATION_CHANGED:
|
||||
# duration will be read after ASYNC_DONE received; for now
|
||||
# just give it a non-None value to flag that we have a duration:
|
||||
duration = 0
|
||||
elif message.type == Gst.MessageType.TAG:
|
||||
taglist = message.parse_tag()
|
||||
elif msg.type == Gst.MessageType.DURATION_CHANGED and tags:
|
||||
# VBR formats sometimes seem to not have a duration by the time we
|
||||
# go back to paused. So just try to get it right away.
|
||||
success, duration = _query_duration(pipeline)
|
||||
pipeline.set_state(Gst.State.PAUSED)
|
||||
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.
|
||||
tags.update(tags_lib.convert_taglist(taglist))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -235,6 +278,9 @@ if __name__ == '__main__':
|
||||
|
||||
from mopidy.internal import path
|
||||
|
||||
logging.basicConfig(format='%(asctime)-15s %(levelname)s %(message)s',
|
||||
level=log.TRACE_LOG_LEVEL)
|
||||
|
||||
scanner = Scanner(5000)
|
||||
for uri in sys.argv[1:]:
|
||||
if not Gst.uri_is_valid(uri):
|
||||
@ -245,6 +291,9 @@ if __name__ == '__main__':
|
||||
print('%-20s %s' % (key, getattr(result, key)))
|
||||
print('tags')
|
||||
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:
|
||||
print('%s: %s' % (uri, error))
|
||||
|
||||
@ -124,6 +124,7 @@ def convert_tags_to_track(tags):
|
||||
datetime = tags.get(Gst.TAG_DATE_TIME, [None])[0]
|
||||
if datetime is not None:
|
||||
album_kwargs['date'] = datetime.split('T')[0]
|
||||
track_kwargs['date'] = album_kwargs['date']
|
||||
|
||||
# Clear out any empty values we found
|
||||
track_kwargs = {k: v for k, v in track_kwargs.items() if v}
|
||||
|
||||
@ -295,6 +295,7 @@ class RootCommand(Command):
|
||||
mixer_class = self.get_mixer_class(config, args.registry['mixer'])
|
||||
backend_classes = args.registry['backend']
|
||||
frontend_classes = args.registry['frontend']
|
||||
core = None
|
||||
|
||||
exit_status_code = 0
|
||||
try:
|
||||
@ -321,7 +322,7 @@ class RootCommand(Command):
|
||||
finally:
|
||||
loop.quit()
|
||||
self.stop_frontends(frontend_classes)
|
||||
self.stop_core()
|
||||
self.stop_core(core)
|
||||
self.stop_backends(backend_classes)
|
||||
self.stop_audio()
|
||||
if mixer_class is not None:
|
||||
@ -397,8 +398,10 @@ class RootCommand(Command):
|
||||
|
||||
def start_core(self, config, mixer, backends, audio):
|
||||
logger.info('Starting Mopidy core')
|
||||
return Core.start(
|
||||
core = Core.start(
|
||||
config=config, mixer=mixer, backends=backends, audio=audio).proxy()
|
||||
core.setup().get()
|
||||
return core
|
||||
|
||||
def start_frontends(self, config, frontend_classes, core):
|
||||
logger.info(
|
||||
@ -415,8 +418,10 @@ class RootCommand(Command):
|
||||
for frontend_class in frontend_classes:
|
||||
process.stop_actors_by_class(frontend_class)
|
||||
|
||||
def stop_core(self):
|
||||
def stop_core(self, core):
|
||||
logger.info('Stopping Mopidy core')
|
||||
if core:
|
||||
core.teardown().get()
|
||||
process.stop_actors_by_class(Core)
|
||||
|
||||
def stop_backends(self, backend_classes):
|
||||
|
||||
@ -24,6 +24,7 @@ _core_schema['config_dir'] = Path()
|
||||
_core_schema['data_dir'] = Path()
|
||||
# MPD supports at most 10k tracks, some clients segfault when this is exceeded.
|
||||
_core_schema['max_tracklist_length'] = Integer(minimum=1, maximum=10000)
|
||||
_core_schema['restore_state'] = Boolean(optional=True)
|
||||
|
||||
_logging_schema = ConfigSchema('logging')
|
||||
_logging_schema['color'] = Boolean()
|
||||
|
||||
@ -3,6 +3,7 @@ cache_dir = $XDG_CACHE_DIR/mopidy
|
||||
config_dir = $XDG_CONFIG_DIR/mopidy
|
||||
data_dir = $XDG_DATA_DIR/mopidy
|
||||
max_tracklist_length = 10000
|
||||
restore_state = false
|
||||
|
||||
[logging]
|
||||
color = true
|
||||
|
||||
@ -3,9 +3,12 @@ from __future__ import absolute_import, unicode_literals
|
||||
import collections
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pykka
|
||||
|
||||
import mopidy
|
||||
|
||||
from mopidy import audio, backend, mixer
|
||||
from mopidy.audio import PlaybackState
|
||||
from mopidy.core.history import HistoryController
|
||||
@ -15,8 +18,9 @@ from mopidy.core.mixer import MixerController
|
||||
from mopidy.core.playback import PlaybackController
|
||||
from mopidy.core.playlists import PlaylistsController
|
||||
from mopidy.core.tracklist import TracklistController
|
||||
from mopidy.internal import versioning
|
||||
from mopidy.internal import path, storage, validation, versioning
|
||||
from mopidy.internal.deprecation import deprecated_property
|
||||
from mopidy.internal.models import CoreState
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -136,6 +140,91 @@ class Core(
|
||||
self.playback._stream_title = title
|
||||
CoreListener.send('stream_title_changed', title=title)
|
||||
|
||||
def setup(self):
|
||||
"""Do not call this function. It is for internal use at startup."""
|
||||
try:
|
||||
coverage = []
|
||||
if self._config and 'restore_state' in self._config['core']:
|
||||
if self._config['core']['restore_state']:
|
||||
coverage = ['tracklist', 'mode', 'play-last', 'mixer',
|
||||
'history']
|
||||
if len(coverage):
|
||||
self._load_state(coverage)
|
||||
except Exception as e:
|
||||
logger.warn('Restore state: Unexpected error: %s', str(e))
|
||||
|
||||
def teardown(self):
|
||||
"""Do not call this function. It is for internal use at shutdown."""
|
||||
try:
|
||||
if self._config and 'restore_state' in self._config['core']:
|
||||
if self._config['core']['restore_state']:
|
||||
self._save_state()
|
||||
except Exception as e:
|
||||
logger.warn('Unexpected error while saving state: %s', str(e))
|
||||
|
||||
def _get_data_dir(self):
|
||||
# get or create data director for core
|
||||
data_dir_path = os.path.join(self._config['core']['data_dir'], b'core')
|
||||
path.get_or_create_dir(data_dir_path)
|
||||
return data_dir_path
|
||||
|
||||
def _save_state(self):
|
||||
"""
|
||||
Save current state to disk.
|
||||
"""
|
||||
|
||||
file_name = os.path.join(self._get_data_dir(), b'state.json.gz')
|
||||
logger.info('Saving state to %s', file_name)
|
||||
|
||||
data = {}
|
||||
data['version'] = mopidy.__version__
|
||||
data['state'] = CoreState(
|
||||
tracklist=self.tracklist._save_state(),
|
||||
history=self.history._save_state(),
|
||||
playback=self.playback._save_state(),
|
||||
mixer=self.mixer._save_state())
|
||||
storage.dump(file_name, data)
|
||||
logger.debug('Saving state done')
|
||||
|
||||
def _load_state(self, coverage):
|
||||
"""
|
||||
Restore state from disk.
|
||||
|
||||
Load state from disk and restore it. Parameter ``coverage``
|
||||
limits the amount of data to restore. Possible
|
||||
values for ``coverage`` (list of one or more of):
|
||||
|
||||
- 'tracklist' fill the tracklist
|
||||
- 'mode' set tracklist properties (consume, random, repeat, single)
|
||||
- 'play-last' restore play state ('tracklist' also required)
|
||||
- 'mixer' set mixer volume and mute state
|
||||
- 'history' restore history
|
||||
|
||||
:param coverage: amount of data to restore
|
||||
:type coverage: list of strings
|
||||
"""
|
||||
|
||||
file_name = os.path.join(self._get_data_dir(), b'state.json.gz')
|
||||
logger.info('Loading state from %s', file_name)
|
||||
|
||||
data = storage.load(file_name)
|
||||
|
||||
try:
|
||||
# Try only once. If something goes wrong, the next start is clean.
|
||||
os.remove(file_name)
|
||||
except OSError:
|
||||
logger.info('Failed to delete %s', file_name)
|
||||
|
||||
if 'state' in data:
|
||||
core_state = data['state']
|
||||
validation.check_instance(core_state, CoreState)
|
||||
self.history._load_state(core_state.history, coverage)
|
||||
self.tracklist._load_state(core_state.tracklist, coverage)
|
||||
self.mixer._load_state(core_state.mixer, coverage)
|
||||
# playback after tracklist
|
||||
self.playback._load_state(core_state.playback, coverage)
|
||||
logger.debug('Loading state done')
|
||||
|
||||
|
||||
class Backends(list):
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import logging
|
||||
import time
|
||||
|
||||
from mopidy import models
|
||||
|
||||
from mopidy.internal.models import HistoryState, HistoryTrack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -57,3 +57,21 @@ class HistoryController(object):
|
||||
:rtype: list of (timestamp, :class:`mopidy.models.Ref`) tuples
|
||||
"""
|
||||
return copy.copy(self._history)
|
||||
|
||||
def _save_state(self):
|
||||
# 500 tracks a 3 minutes -> 24 hours history
|
||||
count_max = 500
|
||||
count = 1
|
||||
history_list = []
|
||||
for timestamp, track in self._history:
|
||||
history_list.append(
|
||||
HistoryTrack(timestamp=timestamp, track=track))
|
||||
count += 1
|
||||
if count_max < count:
|
||||
logger.info('Limiting history to %s tracks', count_max)
|
||||
break
|
||||
return HistoryState(history=history_list)
|
||||
|
||||
def _load_state(self, state, coverage):
|
||||
if state and 'history' in coverage:
|
||||
self._history = [(h.timestamp, h.track) for h in state.history]
|
||||
|
||||
@ -5,6 +5,7 @@ import logging
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.internal import validation
|
||||
from mopidy.internal.models import MixerState
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -99,3 +100,13 @@ class MixerController(object):
|
||||
return result
|
||||
|
||||
return False
|
||||
|
||||
def _save_state(self):
|
||||
return MixerState(volume=self.get_volume(),
|
||||
mute=self.get_mute())
|
||||
|
||||
def _load_state(self, state, coverage):
|
||||
if state and 'mixer' in coverage:
|
||||
self.set_mute(state.mute)
|
||||
if state.volume:
|
||||
self.set_volume(state.volume)
|
||||
|
||||
@ -2,11 +2,10 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import logging
|
||||
|
||||
from mopidy import models
|
||||
from mopidy.audio import PlaybackState
|
||||
from mopidy.compat import urllib
|
||||
from mopidy.core import listener
|
||||
from mopidy.internal import deprecation, validation
|
||||
from mopidy.internal import deprecation, models, validation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -30,6 +29,9 @@ class PlaybackController(object):
|
||||
self._last_position = None
|
||||
self._previous = False
|
||||
|
||||
self._start_at_position = None
|
||||
self._start_paused = False
|
||||
|
||||
if self._audio:
|
||||
self._audio.set_about_to_finish_callback(
|
||||
self._on_about_to_finish_callback)
|
||||
@ -226,6 +228,13 @@ class PlaybackController(object):
|
||||
if self._pending_position is None:
|
||||
self.set_state(PlaybackState.PLAYING)
|
||||
self._trigger_track_playback_started()
|
||||
seek_ok = False
|
||||
if self._start_at_position:
|
||||
seek_ok = self.seek(self._start_at_position)
|
||||
self._start_at_position = None
|
||||
if not seek_ok and self._start_paused:
|
||||
self.pause()
|
||||
self._start_paused = False
|
||||
else:
|
||||
self._seek(self._pending_position)
|
||||
|
||||
@ -233,6 +242,9 @@ class PlaybackController(object):
|
||||
if self._pending_position is not None:
|
||||
self._trigger_seeked(self._pending_position)
|
||||
self._pending_position = None
|
||||
if self._start_paused:
|
||||
self._start_paused = False
|
||||
self.pause()
|
||||
|
||||
def _on_about_to_finish_callback(self):
|
||||
"""Callback that performs a blocking actor call to the real callback.
|
||||
@ -596,3 +608,17 @@ class PlaybackController(object):
|
||||
# TODO: Trigger this from audio events?
|
||||
logger.debug('Triggering seeked event')
|
||||
listener.CoreListener.send('seeked', time_position=time_position)
|
||||
|
||||
def _save_state(self):
|
||||
return models.PlaybackState(
|
||||
tlid=self.get_current_tlid(),
|
||||
time_position=self.get_time_position(),
|
||||
state=self.get_state())
|
||||
|
||||
def _load_state(self, state, coverage):
|
||||
if state and 'play-last' in coverage and state.tlid is not None:
|
||||
if state.state == PlaybackState.PAUSED:
|
||||
self._start_paused = True
|
||||
if state.state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
|
||||
self._start_at_position = state.time_position
|
||||
self.play(tlid=state.tlid)
|
||||
|
||||
@ -6,6 +6,7 @@ import random
|
||||
from mopidy import exceptions
|
||||
from mopidy.core import listener
|
||||
from mopidy.internal import deprecation, validation
|
||||
from mopidy.internal.models import TracklistState
|
||||
from mopidy.models import TlTrack, Track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -325,7 +326,10 @@ class TracklistController(object):
|
||||
next_index += 1
|
||||
|
||||
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):
|
||||
return None
|
||||
|
||||
@ -646,3 +650,24 @@ class TracklistController(object):
|
||||
def _trigger_options_changed(self):
|
||||
logger.debug('Triggering options changed event')
|
||||
listener.CoreListener.send('options_changed')
|
||||
|
||||
def _save_state(self):
|
||||
return TracklistState(
|
||||
tl_tracks=self._tl_tracks,
|
||||
next_tlid=self._next_tlid,
|
||||
consume=self.get_consume(),
|
||||
random=self.get_random(),
|
||||
repeat=self.get_repeat(),
|
||||
single=self.get_single())
|
||||
|
||||
def _load_state(self, state, coverage):
|
||||
if state:
|
||||
if 'mode' in coverage:
|
||||
self.set_consume(state.consume)
|
||||
self.set_random(state.random)
|
||||
self.set_repeat(state.repeat)
|
||||
self.set_single(state.single)
|
||||
if 'tracklist' in coverage:
|
||||
self._next_tlid = max(state.next_tlid, self._next_tlid)
|
||||
self._tl_tracks = list(state.tl_tracks)
|
||||
self._increase_version()
|
||||
|
||||
@ -22,6 +22,7 @@ class Extension(ext.Extension):
|
||||
def get_config_schema(self):
|
||||
schema = super(Extension, self).get_config_schema()
|
||||
schema['media_dirs'] = config.List(optional=True)
|
||||
schema['excluded_file_extensions'] = config.List(optional=True)
|
||||
schema['show_dotfiles'] = config.Boolean(optional=True)
|
||||
schema['follow_symlinks'] = config.Boolean(optional=True)
|
||||
schema['metadata_timeout'] = config.Integer(optional=True)
|
||||
|
||||
@ -4,5 +4,8 @@ media_dirs =
|
||||
$XDG_MUSIC_DIR|Music
|
||||
~/|Home
|
||||
show_dotfiles = false
|
||||
excluded_file_extensions =
|
||||
.jpg
|
||||
.jpeg
|
||||
follow_symlinks = false
|
||||
metadata_timeout = 1000
|
||||
|
||||
@ -34,8 +34,12 @@ class FileLibraryProvider(backend.LibraryProvider):
|
||||
def __init__(self, backend, config):
|
||||
super(FileLibraryProvider, self).__init__(backend)
|
||||
self._media_dirs = list(self._get_media_dirs(config))
|
||||
self._follow_symlinks = config['file']['follow_symlinks']
|
||||
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(
|
||||
timeout=config['file']['metadata_timeout'])
|
||||
|
||||
@ -60,6 +64,10 @@ class FileLibraryProvider(backend.LibraryProvider):
|
||||
if not self._show_dotfiles and dir_entry.startswith(b'.'):
|
||||
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:
|
||||
logger.debug('Ignoring symlink: %s', uri)
|
||||
continue
|
||||
|
||||
@ -89,16 +89,12 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
|
||||
@classmethod
|
||||
def broadcast(cls, msg):
|
||||
if hasattr(tornado.ioloop.IOLoop, 'current'):
|
||||
loop = tornado.ioloop.IOLoop.current()
|
||||
else:
|
||||
loop = tornado.ioloop.IOLoop.instance() # Fallback for pre 3.0
|
||||
loop = tornado.ioloop.IOLoop.current()
|
||||
|
||||
# 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.
|
||||
for client in cls.clients:
|
||||
# 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))
|
||||
|
||||
def initialize(self, core):
|
||||
@ -139,10 +135,7 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
except Exception as e:
|
||||
error_msg = encoding.locale_decode(e)
|
||||
logger.error('WebSocket request error: %s', error_msg)
|
||||
if self.ws_connection:
|
||||
# Tornado 3.2+ checks if self.ws_connection is None before
|
||||
# using it, but not older versions.
|
||||
self.close()
|
||||
self.close()
|
||||
|
||||
def check_origin(self, origin):
|
||||
# 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):
|
||||
self.fake = None
|
||||
|
||||
|
||||
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)
|
||||
uri = translator.path_to_local_track_uri(relpath)
|
||||
|
||||
if b'/.' in relpath:
|
||||
if b'/.' in relpath or relpath.startswith(b'.'):
|
||||
logger.debug('Skipped %s: Hidden directory/file.', uri)
|
||||
elif relpath.lower().endswith(excluded_file_extensions):
|
||||
logger.debug('Skipped %s: File extension excluded.', uri)
|
||||
|
||||
@ -1,60 +1,22 @@
|
||||
from __future__ import absolute_import, absolute_import, unicode_literals
|
||||
|
||||
import collections
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import mopidy
|
||||
|
||||
from mopidy import compat, local, models
|
||||
from mopidy.internal import encoding, timer
|
||||
from mopidy.internal import storage as internal_storage
|
||||
from mopidy.internal import timer
|
||||
from mopidy.local import search, storage, translator
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TODO: move to load and dump in models?
|
||||
def load_library(json_file):
|
||||
if not os.path.isfile(json_file):
|
||||
logger.info(
|
||||
'No local library metadata cache found at %s. Please run '
|
||||
'`mopidy local scan` to index your local music library. '
|
||||
'If you do not have a local music collection, you can disable the '
|
||||
'local backend to hide this message.',
|
||||
json_file)
|
||||
return {}
|
||||
try:
|
||||
with gzip.open(json_file, 'rb') as fp:
|
||||
return json.load(fp, object_hook=models.model_json_decoder)
|
||||
except (IOError, ValueError) as error:
|
||||
logger.warning(
|
||||
'Loading JSON local library failed: %s',
|
||||
encoding.locale_decode(error))
|
||||
return {}
|
||||
|
||||
|
||||
def write_library(json_file, data):
|
||||
data['version'] = mopidy.__version__
|
||||
directory, basename = os.path.split(json_file)
|
||||
|
||||
# TODO: cleanup directory/basename.* files.
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
prefix=basename + '.', dir=directory, delete=False)
|
||||
|
||||
try:
|
||||
with gzip.GzipFile(fileobj=tmp, mode='wb') as fp:
|
||||
json.dump(data, fp, cls=models.ModelJSONEncoder,
|
||||
indent=2, separators=(',', ': '))
|
||||
os.rename(tmp.name, json_file)
|
||||
finally:
|
||||
if os.path.exists(tmp.name):
|
||||
os.remove(tmp.name)
|
||||
|
||||
|
||||
class _BrowseCache(object):
|
||||
encoding = sys.getfilesystemencoding()
|
||||
splitpath_re = re.compile(r'([^/]+)')
|
||||
@ -128,8 +90,18 @@ class JsonLibrary(local.Library):
|
||||
def load(self):
|
||||
logger.debug('Loading library: %s', self._json_file)
|
||||
with timer.time_logger('Loading tracks'):
|
||||
library = load_library(self._json_file)
|
||||
self._tracks = dict((t.uri, t) for t in library.get('tracks', []))
|
||||
if not os.path.isfile(self._json_file):
|
||||
logger.info(
|
||||
'No local library metadata cache found at %s. Please run '
|
||||
'`mopidy local scan` to index your local music library. '
|
||||
'If you do not have a local music collection, you can '
|
||||
'disable the local backend to hide this message.',
|
||||
self._json_file)
|
||||
self._tracks = {}
|
||||
else:
|
||||
library = internal_storage.load(self._json_file)
|
||||
self._tracks = dict((t.uri, t) for t in
|
||||
library.get('tracks', []))
|
||||
with timer.time_logger('Building browse cache'):
|
||||
self._browse_cache = _BrowseCache(sorted(self._tracks.keys()))
|
||||
return len(self._tracks)
|
||||
@ -195,7 +167,10 @@ class JsonLibrary(local.Library):
|
||||
self._tracks.pop(uri, None)
|
||||
|
||||
def close(self):
|
||||
write_library(self._json_file, {'tracks': self._tracks.values()})
|
||||
internal_storage.dump(self._json_file, {
|
||||
'version': mopidy.__version__,
|
||||
'tracks': self._tracks.values()
|
||||
})
|
||||
|
||||
def clear(self):
|
||||
try:
|
||||
|
||||
@ -9,5 +9,5 @@ logger = logging.getLogger(__name__)
|
||||
def check_dirs_and_files(config):
|
||||
if not os.path.isdir(config['local']['media_dir']):
|
||||
logger.warning(
|
||||
'Local media dir %s does not exist.' %
|
||||
config['local']['media_dir'])
|
||||
'Local media dir %s does not exist or we lack permissions to the '
|
||||
'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):
|
||||
"""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):
|
||||
relpath = relpath.encode('utf-8')
|
||||
return 'local:directory:%s' % urllib.quote(relpath)
|
||||
|
||||
@ -192,9 +192,9 @@ class Track(ValidatedImmutableObject):
|
||||
:param album: track album
|
||||
:type album: :class:`Album`
|
||||
:param composers: track composers
|
||||
:type composers: string
|
||||
:type composers: list of :class:`Artist`
|
||||
:param performers: track performers
|
||||
:type performers: string
|
||||
:type performers: list of :class:`Artist`
|
||||
:param genre: track genre
|
||||
:type genre: string
|
||||
:param track_no: track number in album
|
||||
|
||||
@ -138,6 +138,17 @@ class Integer(Field):
|
||||
return value
|
||||
|
||||
|
||||
class Boolean(Field):
|
||||
"""
|
||||
:class:`Field` for storing boolean values
|
||||
|
||||
:param default: default value for field
|
||||
"""
|
||||
|
||||
def __init__(self, default=None):
|
||||
super(Boolean, self).__init__(type=bool, default=default)
|
||||
|
||||
|
||||
class Collection(Field):
|
||||
"""
|
||||
:class:`Field` for storing collections of a given type.
|
||||
|
||||
@ -8,6 +8,10 @@ from mopidy.internal import deprecation
|
||||
from mopidy.models.fields import Field
|
||||
|
||||
|
||||
# Registered models for automatic deserialization
|
||||
_models = {}
|
||||
|
||||
|
||||
class ImmutableObject(object):
|
||||
"""
|
||||
Superclass for immutable objects whose fields can only be modified via the
|
||||
@ -150,9 +154,14 @@ class _ValidatedImmutableObjectMeta(type):
|
||||
attrs['_instances'] = weakref.WeakValueDictionary()
|
||||
attrs['__slots__'] = list(attrs.get('__slots__', [])) + fields.values()
|
||||
|
||||
return super(_ValidatedImmutableObjectMeta, cls).__new__(
|
||||
clsc = super(_ValidatedImmutableObjectMeta, cls).__new__(
|
||||
cls, name, bases, attrs)
|
||||
|
||||
if clsc.__name__ != 'ValidatedImmutableObject':
|
||||
_models[clsc.__name__] = clsc
|
||||
|
||||
return clsc
|
||||
|
||||
def __call__(cls, *args, **kwargs): # noqa: N805
|
||||
instance = super(_ValidatedImmutableObjectMeta, cls).__call__(
|
||||
*args, **kwargs)
|
||||
|
||||
@ -4,8 +4,6 @@ import json
|
||||
|
||||
from mopidy.models import immutable
|
||||
|
||||
_MODELS = ['Ref', 'Artist', 'Album', 'Track', 'TlTrack', 'Playlist']
|
||||
|
||||
|
||||
class ModelJSONEncoder(json.JSONEncoder):
|
||||
|
||||
@ -40,8 +38,8 @@ def model_json_decoder(dct):
|
||||
|
||||
"""
|
||||
if '__model__' in dct:
|
||||
from mopidy import models
|
||||
model_name = dct.pop('__model__')
|
||||
if model_name in _MODELS:
|
||||
return getattr(models, model_name)(**dct)
|
||||
if model_name in immutable._models:
|
||||
cls = immutable._models[model_name]
|
||||
return cls(**dct)
|
||||
return dct
|
||||
|
||||
@ -325,7 +325,7 @@ def replay_gain_status(context):
|
||||
Prints replay gain options. Currently, only the variable
|
||||
``replay_gain_mode`` is returned.
|
||||
"""
|
||||
return 'off' # TODO
|
||||
return 'replay_gain_mode: off' # TODO
|
||||
|
||||
|
||||
@protocol.commands.add('seek', songpos=protocol.UINT, seconds=protocol.UINT)
|
||||
|
||||
@ -173,6 +173,7 @@ def status(context):
|
||||
decimal places for millisecond precision.
|
||||
"""
|
||||
tl_track = context.core.playback.get_current_tl_track()
|
||||
next_tlid = context.core.tracklist.get_next_tlid()
|
||||
|
||||
futures = {
|
||||
'tracklist.length': context.core.tracklist.get_length(),
|
||||
@ -185,6 +186,9 @@ def status(context):
|
||||
'playback.state': context.core.playback.get_state(),
|
||||
'playback.current_tl_track': tl_track,
|
||||
'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(),
|
||||
}
|
||||
pykka.get_all(futures.values())
|
||||
@ -199,10 +203,12 @@ def status(context):
|
||||
('xfade', _status_xfade(futures)),
|
||||
('state', _status_state(futures)),
|
||||
]
|
||||
# TODO: add nextsong and nextsongid
|
||||
if futures['playback.current_tl_track'].get() is not None:
|
||||
result.append(('song', _status_songpos(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 (
|
||||
PlaybackState.PLAYING, PlaybackState.PAUSED):
|
||||
result.append(('time', _status_time(futures)))
|
||||
@ -259,6 +265,14 @@ def _status_songpos(futures):
|
||||
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):
|
||||
state = futures['playback.state'].get()
|
||||
if state == PlaybackState.PLAYING:
|
||||
|
||||
2
setup.py
2
setup.py
@ -27,7 +27,7 @@ setup(
|
||||
'Pykka >= 1.1',
|
||||
'requests >= 2.0',
|
||||
'setuptools',
|
||||
'tornado >= 2.3',
|
||||
'tornado >= 3.2',
|
||||
],
|
||||
extras_require={'http': []},
|
||||
entry_points={
|
||||
|
||||
@ -120,7 +120,7 @@ class TagsToTrackTest(unittest.TestCase):
|
||||
num_tracks=2, num_discs=3,
|
||||
musicbrainz_id='albumid', artists=[albumartist])
|
||||
|
||||
self.track = Track(name='track',
|
||||
self.track = Track(name='track', date='2006-01-01',
|
||||
genre='genre', track_no=1, disc_no=2,
|
||||
comment='comment', musicbrainz_id='trackid',
|
||||
album=album, bitrate=1000, artists=[artist],
|
||||
@ -183,8 +183,9 @@ class TagsToTrackTest(unittest.TestCase):
|
||||
|
||||
def test_missing_track_date(self):
|
||||
del self.tags['date']
|
||||
self.check(
|
||||
self.track.replace(album=self.track.album.replace(date=None)))
|
||||
self.check(self.track.replace(
|
||||
album=self.track.album.replace(date=None),
|
||||
date=None))
|
||||
|
||||
def test_multiple_track_date(self):
|
||||
self.tags['date'].append('2030-01-01')
|
||||
|
||||
@ -1,13 +1,20 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
import pykka
|
||||
|
||||
import mopidy
|
||||
|
||||
from mopidy.core import Core
|
||||
from mopidy.internal import versioning
|
||||
from mopidy.internal import models, storage, versioning
|
||||
from mopidy.models import Track
|
||||
from tests import dummy_mixer
|
||||
|
||||
|
||||
class CoreActorTest(unittest.TestCase):
|
||||
@ -43,3 +50,106 @@ class CoreActorTest(unittest.TestCase):
|
||||
|
||||
def test_version(self):
|
||||
self.assertEqual(self.core.version, versioning.get_version())
|
||||
|
||||
|
||||
class CoreActorSaveLoadStateTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.state_file = os.path.join(self.temp_dir,
|
||||
b'core', b'state.json.gz')
|
||||
|
||||
config = {
|
||||
'core': {
|
||||
'max_tracklist_length': 10000,
|
||||
'restore_state': True,
|
||||
'data_dir': self.temp_dir,
|
||||
}
|
||||
}
|
||||
|
||||
os.mkdir(os.path.join(self.temp_dir, b'core'))
|
||||
|
||||
self.mixer = dummy_mixer.create_proxy()
|
||||
self.core = Core(
|
||||
config=config, mixer=self.mixer, backends=[])
|
||||
|
||||
def tearDown(self): # noqa: N802
|
||||
pykka.ActorRegistry.stop_all()
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_save_state(self):
|
||||
self.core.teardown()
|
||||
|
||||
assert os.path.isfile(self.state_file)
|
||||
reload_data = storage.load(self.state_file)
|
||||
data = {}
|
||||
data['version'] = mopidy.__version__
|
||||
data['state'] = models.CoreState(
|
||||
tracklist=models.TracklistState(
|
||||
repeat=False, random=False,
|
||||
consume=False, single=False,
|
||||
next_tlid=1),
|
||||
history=models.HistoryState(),
|
||||
playback=models.PlaybackState(state='stopped',
|
||||
time_position=0),
|
||||
mixer=models.MixerState())
|
||||
assert data == reload_data
|
||||
|
||||
def test_load_state_no_file(self):
|
||||
self.core.setup()
|
||||
|
||||
assert self.core.mixer.get_mute() is None
|
||||
assert self.core.mixer.get_volume() is None
|
||||
assert self.core.tracklist._next_tlid == 1
|
||||
assert self.core.tracklist.get_repeat() is False
|
||||
assert self.core.tracklist.get_random() is False
|
||||
assert self.core.tracklist.get_consume() is False
|
||||
assert self.core.tracklist.get_single() is False
|
||||
assert self.core.tracklist.get_length() == 0
|
||||
assert self.core.playback._start_paused is False
|
||||
assert self.core.playback._start_at_position is None
|
||||
assert self.core.history.get_length() == 0
|
||||
|
||||
def test_load_state_with_data(self):
|
||||
data = {}
|
||||
data['version'] = mopidy.__version__
|
||||
data['state'] = models.CoreState(
|
||||
tracklist=models.TracklistState(
|
||||
repeat=True, random=True,
|
||||
consume=False, single=False,
|
||||
tl_tracks=[models.TlTrack(tlid=12, track=Track(uri='a:a'))],
|
||||
next_tlid=14),
|
||||
history=models.HistoryState(history=[
|
||||
models.HistoryTrack(
|
||||
timestamp=12,
|
||||
track=models.Ref.track(uri='a:a', name='a')),
|
||||
models.HistoryTrack(
|
||||
timestamp=13,
|
||||
track=models.Ref.track(uri='a:b', name='b'))]),
|
||||
playback=models.PlaybackState(tlid=12, state='paused',
|
||||
time_position=432),
|
||||
mixer=models.MixerState(mute=True, volume=12))
|
||||
storage.dump(self.state_file, data)
|
||||
|
||||
self.core.setup()
|
||||
|
||||
assert self.core.mixer.get_mute() is True
|
||||
assert self.core.mixer.get_volume() == 12
|
||||
assert self.core.tracklist._next_tlid == 14
|
||||
assert self.core.tracklist.get_repeat() is True
|
||||
assert self.core.tracklist.get_random() is True
|
||||
assert self.core.tracklist.get_consume() is False
|
||||
assert self.core.tracklist.get_single() is False
|
||||
assert self.core.tracklist.get_length() == 1
|
||||
assert self.core.playback._start_paused is True
|
||||
assert self.core.playback._start_at_position == 432
|
||||
assert self.core.history.get_length() == 2
|
||||
|
||||
def test_delete_state_file_on_restore(self):
|
||||
data = {}
|
||||
storage.dump(self.state_file, data)
|
||||
assert os.path.isfile(self.state_file)
|
||||
|
||||
self.core.setup()
|
||||
|
||||
assert not os.path.isfile(self.state_file)
|
||||
|
||||
@ -4,7 +4,8 @@ import unittest
|
||||
|
||||
from mopidy import compat
|
||||
from mopidy.core import HistoryController
|
||||
from mopidy.models import Artist, Track
|
||||
from mopidy.internal.models import HistoryState, HistoryTrack
|
||||
from mopidy.models import Artist, Ref, Track
|
||||
|
||||
|
||||
class PlaybackHistoryTest(unittest.TestCase):
|
||||
@ -46,3 +47,60 @@ class PlaybackHistoryTest(unittest.TestCase):
|
||||
self.assertIn(track.name, ref.name)
|
||||
for artist in track.artists:
|
||||
self.assertIn(artist.name, ref.name)
|
||||
|
||||
|
||||
class CoreHistorySaveLoadStateTest(unittest.TestCase):
|
||||
|
||||
def setUp(self): # noqa: N802
|
||||
self.tracks = [
|
||||
Track(uri='dummy1:a', name='foober'),
|
||||
Track(uri='dummy2:a', name='foo'),
|
||||
Track(uri='dummy3:a', name='bar')
|
||||
]
|
||||
|
||||
self.refs = []
|
||||
for t in self.tracks:
|
||||
self.refs.append(Ref.track(uri=t.uri, name=t.name))
|
||||
|
||||
self.history = HistoryController()
|
||||
|
||||
def test_save(self):
|
||||
self.history._add_track(self.tracks[2])
|
||||
self.history._add_track(self.tracks[1])
|
||||
|
||||
value = self.history._save_state()
|
||||
|
||||
self.assertEqual(len(value.history), 2)
|
||||
# last in, first out
|
||||
self.assertEqual(value.history[0].track, self.refs[1])
|
||||
self.assertEqual(value.history[1].track, self.refs[2])
|
||||
|
||||
def test_load(self):
|
||||
state = HistoryState(history=[
|
||||
HistoryTrack(timestamp=34, track=self.refs[0]),
|
||||
HistoryTrack(timestamp=45, track=self.refs[2]),
|
||||
HistoryTrack(timestamp=56, track=self.refs[1])])
|
||||
coverage = ['history']
|
||||
self.history._load_state(state, coverage)
|
||||
|
||||
hist = self.history.get_history()
|
||||
self.assertEqual(len(hist), 3)
|
||||
self.assertEqual(hist[0], (34, self.refs[0]))
|
||||
self.assertEqual(hist[1], (45, self.refs[2]))
|
||||
self.assertEqual(hist[2], (56, self.refs[1]))
|
||||
|
||||
# after import, adding more tracks must be possible
|
||||
self.history._add_track(self.tracks[1])
|
||||
hist = self.history.get_history()
|
||||
self.assertEqual(len(hist), 4)
|
||||
self.assertEqual(hist[0][1], self.refs[1])
|
||||
self.assertEqual(hist[1], (34, self.refs[0]))
|
||||
self.assertEqual(hist[2], (45, self.refs[2]))
|
||||
self.assertEqual(hist[3], (56, self.refs[1]))
|
||||
|
||||
def test_load_invalid_type(self):
|
||||
with self.assertRaises(TypeError):
|
||||
self.history._load_state(11, None)
|
||||
|
||||
def test_load_none(self):
|
||||
self.history._load_state(None, None)
|
||||
|
||||
@ -7,6 +7,7 @@ import mock
|
||||
import pykka
|
||||
|
||||
from mopidy import core, mixer
|
||||
from mopidy.internal.models import MixerState
|
||||
from tests import dummy_mixer
|
||||
|
||||
|
||||
@ -154,3 +155,68 @@ class SetMuteBadBackendTest(MockBackendCoreMixerBase):
|
||||
def test_backend_returns_wrong_type(self):
|
||||
self.mixer.set_mute.return_value.get.return_value = 'done'
|
||||
self.assertFalse(self.core.mixer.set_mute(True))
|
||||
|
||||
|
||||
class CoreMixerSaveLoadStateTest(unittest.TestCase):
|
||||
|
||||
def setUp(self): # noqa: N802
|
||||
self.mixer = dummy_mixer.create_proxy()
|
||||
self.core = core.Core(mixer=self.mixer, backends=[])
|
||||
|
||||
def test_save_mute(self):
|
||||
volume = 32
|
||||
mute = False
|
||||
target = MixerState(volume=volume, mute=mute)
|
||||
self.core.mixer.set_volume(volume)
|
||||
self.core.mixer.set_mute(mute)
|
||||
value = self.core.mixer._save_state()
|
||||
self.assertEqual(target, value)
|
||||
|
||||
def test_save_unmute(self):
|
||||
volume = 33
|
||||
mute = True
|
||||
target = MixerState(volume=volume, mute=mute)
|
||||
self.core.mixer.set_volume(volume)
|
||||
self.core.mixer.set_mute(mute)
|
||||
value = self.core.mixer._save_state()
|
||||
self.assertEqual(target, value)
|
||||
|
||||
def test_load(self):
|
||||
self.core.mixer.set_volume(11)
|
||||
volume = 45
|
||||
target = MixerState(volume=volume)
|
||||
coverage = ['mixer']
|
||||
self.core.mixer._load_state(target, coverage)
|
||||
self.assertEqual(volume, self.core.mixer.get_volume())
|
||||
|
||||
def test_load_not_covered(self):
|
||||
self.core.mixer.set_volume(21)
|
||||
self.core.mixer.set_mute(True)
|
||||
target = MixerState(volume=56, mute=False)
|
||||
coverage = ['other']
|
||||
self.core.mixer._load_state(target, coverage)
|
||||
self.assertEqual(21, self.core.mixer.get_volume())
|
||||
self.assertEqual(True, self.core.mixer.get_mute())
|
||||
|
||||
def test_load_mute_on(self):
|
||||
self.core.mixer.set_mute(False)
|
||||
self.assertEqual(False, self.core.mixer.get_mute())
|
||||
target = MixerState(mute=True)
|
||||
coverage = ['mixer']
|
||||
self.core.mixer._load_state(target, coverage)
|
||||
self.assertEqual(True, self.core.mixer.get_mute())
|
||||
|
||||
def test_load_mute_off(self):
|
||||
self.core.mixer.set_mute(True)
|
||||
self.assertEqual(True, self.core.mixer.get_mute())
|
||||
target = MixerState(mute=False)
|
||||
coverage = ['mixer']
|
||||
self.core.mixer._load_state(target, coverage)
|
||||
self.assertEqual(False, self.core.mixer.get_mute())
|
||||
|
||||
def test_load_invalid_type(self):
|
||||
with self.assertRaises(TypeError):
|
||||
self.core.mixer._load_state(11, None)
|
||||
|
||||
def test_load_none(self):
|
||||
self.core.mixer._load_state(None, None)
|
||||
|
||||
@ -8,6 +8,7 @@ import pykka
|
||||
|
||||
from mopidy import backend, core
|
||||
from mopidy.internal import deprecation
|
||||
from mopidy.internal.models import PlaybackState
|
||||
from mopidy.models import Track
|
||||
|
||||
from tests import dummy_audio
|
||||
@ -430,6 +431,21 @@ class TestConsumeHandling(BaseTest):
|
||||
|
||||
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):
|
||||
|
||||
@ -1132,6 +1148,62 @@ class TestBug1177Regression(unittest.TestCase):
|
||||
b.playback.change_track.assert_called_once_with(track2)
|
||||
|
||||
|
||||
class TestCorePlaybackSaveLoadState(BaseTest):
|
||||
|
||||
def test_save(self):
|
||||
tl_tracks = self.core.tracklist.get_tl_tracks()
|
||||
|
||||
self.core.playback.play(tl_tracks[1])
|
||||
self.replay_events()
|
||||
|
||||
state = PlaybackState(
|
||||
time_position=0, state='playing', tlid=tl_tracks[1].tlid)
|
||||
value = self.core.playback._save_state()
|
||||
|
||||
self.assertEqual(state, value)
|
||||
|
||||
def test_load(self):
|
||||
tl_tracks = self.core.tracklist.get_tl_tracks()
|
||||
|
||||
self.core.playback.stop()
|
||||
self.replay_events()
|
||||
self.assertEqual('stopped', self.core.playback.get_state())
|
||||
|
||||
state = PlaybackState(
|
||||
time_position=0, state='playing', tlid=tl_tracks[2].tlid)
|
||||
coverage = ['play-last']
|
||||
self.core.playback._load_state(state, coverage)
|
||||
self.replay_events()
|
||||
|
||||
self.assertEqual('playing', self.core.playback.get_state())
|
||||
self.assertEqual(tl_tracks[2],
|
||||
self.core.playback.get_current_tl_track())
|
||||
|
||||
def test_load_not_covered(self):
|
||||
tl_tracks = self.core.tracklist.get_tl_tracks()
|
||||
|
||||
self.core.playback.stop()
|
||||
self.replay_events()
|
||||
self.assertEqual('stopped', self.core.playback.get_state())
|
||||
|
||||
state = PlaybackState(
|
||||
time_position=0, state='playing', tlid=tl_tracks[2].tlid)
|
||||
coverage = ['other']
|
||||
self.core.playback._load_state(state, coverage)
|
||||
self.replay_events()
|
||||
|
||||
self.assertEqual('stopped', self.core.playback.get_state())
|
||||
self.assertEqual(None,
|
||||
self.core.playback.get_current_tl_track())
|
||||
|
||||
def test_load_invalid_type(self):
|
||||
with self.assertRaises(TypeError):
|
||||
self.core.playback._load_state(11, None)
|
||||
|
||||
def test_load_none(self):
|
||||
self.core.playback._load_state(None, None)
|
||||
|
||||
|
||||
class TestBug1352Regression(BaseTest):
|
||||
tracks = [
|
||||
Track(uri='dummy:a', length=40000),
|
||||
|
||||
@ -6,6 +6,7 @@ import mock
|
||||
|
||||
from mopidy import backend, core
|
||||
from mopidy.internal import deprecation
|
||||
from mopidy.internal.models import TracklistState
|
||||
from mopidy.models import TlTrack, Track
|
||||
|
||||
|
||||
@ -177,3 +178,119 @@ class TracklistIndexTest(unittest.TestCase):
|
||||
self.assertEqual(0, self.core.tracklist.index())
|
||||
self.assertEqual(1, self.core.tracklist.index())
|
||||
self.assertEqual(2, self.core.tracklist.index())
|
||||
|
||||
|
||||
class TracklistSaveLoadStateTest(unittest.TestCase):
|
||||
|
||||
def setUp(self): # noqa: N802
|
||||
config = {
|
||||
'core': {
|
||||
'max_tracklist_length': 10000,
|
||||
}
|
||||
}
|
||||
|
||||
self.tracks = [
|
||||
Track(uri='dummy1:a', name='foo'),
|
||||
Track(uri='dummy1:b', name='foo'),
|
||||
Track(uri='dummy1:c', name='bar'),
|
||||
]
|
||||
|
||||
self.tl_tracks = [
|
||||
TlTrack(tlid=4, track=Track(uri='first', name='First')),
|
||||
TlTrack(tlid=5, track=Track(uri='second', name='Second')),
|
||||
TlTrack(tlid=6, track=Track(uri='third', name='Third')),
|
||||
TlTrack(tlid=8, track=Track(uri='last', name='Last'))
|
||||
]
|
||||
|
||||
def lookup(uris):
|
||||
return {u: [t for t in self.tracks if t.uri == u] for u in uris}
|
||||
|
||||
self.core = core.Core(config, mixer=None, backends=[])
|
||||
self.core.library = mock.Mock(spec=core.LibraryController)
|
||||
self.core.library.lookup.side_effect = lookup
|
||||
|
||||
self.core.playback = mock.Mock(spec=core.PlaybackController)
|
||||
|
||||
def test_save(self):
|
||||
tl_tracks = self.core.tracklist.add(uris=[
|
||||
t.uri for t in self.tracks])
|
||||
consume = True
|
||||
next_tlid = len(tl_tracks) + 1
|
||||
self.core.tracklist.set_consume(consume)
|
||||
target = TracklistState(consume=consume,
|
||||
repeat=False,
|
||||
single=False,
|
||||
random=False,
|
||||
next_tlid=next_tlid,
|
||||
tl_tracks=tl_tracks)
|
||||
value = self.core.tracklist._save_state()
|
||||
self.assertEqual(target, value)
|
||||
|
||||
def test_load(self):
|
||||
old_version = self.core.tracklist.get_version()
|
||||
target = TracklistState(consume=False,
|
||||
repeat=True,
|
||||
single=True,
|
||||
random=False,
|
||||
next_tlid=12,
|
||||
tl_tracks=self.tl_tracks)
|
||||
coverage = ['mode', 'tracklist']
|
||||
self.core.tracklist._load_state(target, coverage)
|
||||
self.assertEqual(False, self.core.tracklist.get_consume())
|
||||
self.assertEqual(True, self.core.tracklist.get_repeat())
|
||||
self.assertEqual(True, self.core.tracklist.get_single())
|
||||
self.assertEqual(False, self.core.tracklist.get_random())
|
||||
self.assertEqual(12, self.core.tracklist._next_tlid)
|
||||
self.assertEqual(4, self.core.tracklist.get_length())
|
||||
self.assertEqual(self.tl_tracks, self.core.tracklist.get_tl_tracks())
|
||||
self.assertGreater(self.core.tracklist.get_version(), old_version)
|
||||
|
||||
# after load, adding more tracks must be possible
|
||||
self.core.tracklist.add(uris=[self.tracks[1].uri])
|
||||
self.assertEqual(13, self.core.tracklist._next_tlid)
|
||||
self.assertEqual(5, self.core.tracklist.get_length())
|
||||
|
||||
def test_load_mode_only(self):
|
||||
old_version = self.core.tracklist.get_version()
|
||||
target = TracklistState(consume=False,
|
||||
repeat=True,
|
||||
single=True,
|
||||
random=False,
|
||||
next_tlid=12,
|
||||
tl_tracks=self.tl_tracks)
|
||||
coverage = ['mode']
|
||||
self.core.tracklist._load_state(target, coverage)
|
||||
self.assertEqual(False, self.core.tracklist.get_consume())
|
||||
self.assertEqual(True, self.core.tracklist.get_repeat())
|
||||
self.assertEqual(True, self.core.tracklist.get_single())
|
||||
self.assertEqual(False, self.core.tracklist.get_random())
|
||||
self.assertEqual(1, self.core.tracklist._next_tlid)
|
||||
self.assertEqual(0, self.core.tracklist.get_length())
|
||||
self.assertEqual([], self.core.tracklist.get_tl_tracks())
|
||||
self.assertEqual(self.core.tracklist.get_version(), old_version)
|
||||
|
||||
def test_load_tracklist_only(self):
|
||||
old_version = self.core.tracklist.get_version()
|
||||
target = TracklistState(consume=False,
|
||||
repeat=True,
|
||||
single=True,
|
||||
random=False,
|
||||
next_tlid=12,
|
||||
tl_tracks=self.tl_tracks)
|
||||
coverage = ['tracklist']
|
||||
self.core.tracklist._load_state(target, coverage)
|
||||
self.assertEqual(False, self.core.tracklist.get_consume())
|
||||
self.assertEqual(False, self.core.tracklist.get_repeat())
|
||||
self.assertEqual(False, self.core.tracklist.get_single())
|
||||
self.assertEqual(False, self.core.tracklist.get_random())
|
||||
self.assertEqual(12, self.core.tracklist._next_tlid)
|
||||
self.assertEqual(4, self.core.tracklist.get_length())
|
||||
self.assertEqual(self.tl_tracks, self.core.tracklist.get_tl_tracks())
|
||||
self.assertGreater(self.core.tracklist.get_version(), old_version)
|
||||
|
||||
def test_load_invalid_type(self):
|
||||
with self.assertRaises(TypeError):
|
||||
self.core.tracklist._load_state(11, None)
|
||||
|
||||
def test_load_none(self):
|
||||
self.core.tracklist._load_state(None, None)
|
||||
|
||||
218
tests/internal/test_models.py
Normal file
218
tests/internal/test_models.py
Normal file
@ -0,0 +1,218 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from mopidy.internal.models import (
|
||||
HistoryState, HistoryTrack, MixerState, PlaybackState, TracklistState)
|
||||
from mopidy.models import (
|
||||
ModelJSONEncoder, Ref, TlTrack, Track, model_json_decoder)
|
||||
|
||||
|
||||
class HistoryTrackTest(unittest.TestCase):
|
||||
|
||||
def test_track(self):
|
||||
track = Ref.track()
|
||||
result = HistoryTrack(track=track)
|
||||
self.assertEqual(result.track, track)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.track = None
|
||||
|
||||
def test_timestamp(self):
|
||||
timestamp = 1234
|
||||
result = HistoryTrack(timestamp=timestamp)
|
||||
self.assertEqual(result.timestamp, timestamp)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.timestamp = None
|
||||
|
||||
def test_to_json_and_back(self):
|
||||
result = HistoryTrack(track=Ref.track(), timestamp=1234)
|
||||
serialized = json.dumps(result, cls=ModelJSONEncoder)
|
||||
deserialized = json.loads(serialized, object_hook=model_json_decoder)
|
||||
self.assertEqual(result, deserialized)
|
||||
|
||||
|
||||
class HistoryStateTest(unittest.TestCase):
|
||||
|
||||
def test_history_list(self):
|
||||
history = (HistoryTrack(),
|
||||
HistoryTrack())
|
||||
result = HistoryState(history=history)
|
||||
self.assertEqual(result.history, history)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.history = None
|
||||
|
||||
def test_history_string_fail(self):
|
||||
history = 'not_a_valid_history'
|
||||
with self.assertRaises(TypeError):
|
||||
HistoryState(history=history)
|
||||
|
||||
def test_to_json_and_back(self):
|
||||
result = HistoryState(history=(HistoryTrack(), HistoryTrack()))
|
||||
serialized = json.dumps(result, cls=ModelJSONEncoder)
|
||||
deserialized = json.loads(serialized, object_hook=model_json_decoder)
|
||||
self.assertEqual(result, deserialized)
|
||||
|
||||
|
||||
class MixerStateTest(unittest.TestCase):
|
||||
|
||||
def test_volume(self):
|
||||
volume = 37
|
||||
result = MixerState(volume=volume)
|
||||
self.assertEqual(result.volume, volume)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.volume = None
|
||||
|
||||
def test_volume_invalid(self):
|
||||
volume = 105
|
||||
with self.assertRaises(ValueError):
|
||||
MixerState(volume=volume)
|
||||
|
||||
def test_mute_false(self):
|
||||
mute = False
|
||||
result = MixerState(mute=mute)
|
||||
self.assertEqual(result.mute, mute)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.mute = None
|
||||
|
||||
def test_mute_true(self):
|
||||
mute = True
|
||||
result = MixerState(mute=mute)
|
||||
self.assertEqual(result.mute, mute)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.mute = False
|
||||
|
||||
def test_mute_default(self):
|
||||
result = MixerState()
|
||||
self.assertEqual(result.mute, False)
|
||||
|
||||
def test_to_json_and_back(self):
|
||||
result = MixerState(volume=77)
|
||||
serialized = json.dumps(result, cls=ModelJSONEncoder)
|
||||
deserialized = json.loads(serialized, object_hook=model_json_decoder)
|
||||
self.assertEqual(result, deserialized)
|
||||
|
||||
|
||||
class PlaybackStateTest(unittest.TestCase):
|
||||
|
||||
def test_position(self):
|
||||
time_position = 123456
|
||||
result = PlaybackState(time_position=time_position)
|
||||
self.assertEqual(result.time_position, time_position)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.time_position = None
|
||||
|
||||
def test_position_invalid(self):
|
||||
time_position = -1
|
||||
with self.assertRaises(ValueError):
|
||||
PlaybackState(time_position=time_position)
|
||||
|
||||
def test_tl_track(self):
|
||||
tlid = 42
|
||||
result = PlaybackState(tlid=tlid)
|
||||
self.assertEqual(result.tlid, tlid)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.tlid = None
|
||||
|
||||
def test_tl_track_none(self):
|
||||
tlid = None
|
||||
result = PlaybackState(tlid=tlid)
|
||||
self.assertEqual(result.tlid, tlid)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.tl_track = None
|
||||
|
||||
def test_tl_track_invalid(self):
|
||||
tl_track = Track()
|
||||
with self.assertRaises(TypeError):
|
||||
PlaybackState(tlid=tl_track)
|
||||
|
||||
def test_state(self):
|
||||
state = 'playing'
|
||||
result = PlaybackState(state=state)
|
||||
self.assertEqual(result.state, state)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.state = None
|
||||
|
||||
def test_state_invalid(self):
|
||||
state = 'not_a_state'
|
||||
with self.assertRaises(TypeError):
|
||||
PlaybackState(state=state)
|
||||
|
||||
def test_to_json_and_back(self):
|
||||
result = PlaybackState(state='playing', tlid=4321)
|
||||
serialized = json.dumps(result, cls=ModelJSONEncoder)
|
||||
deserialized = json.loads(serialized, object_hook=model_json_decoder)
|
||||
self.assertEqual(result, deserialized)
|
||||
|
||||
|
||||
class TracklistStateTest(unittest.TestCase):
|
||||
|
||||
def test_repeat_true(self):
|
||||
repeat = True
|
||||
result = TracklistState(repeat=repeat)
|
||||
self.assertEqual(result.repeat, repeat)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.repeat = None
|
||||
|
||||
def test_repeat_false(self):
|
||||
repeat = False
|
||||
result = TracklistState(repeat=repeat)
|
||||
self.assertEqual(result.repeat, repeat)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.repeat = None
|
||||
|
||||
def test_repeat_invalid(self):
|
||||
repeat = 33
|
||||
with self.assertRaises(TypeError):
|
||||
TracklistState(repeat=repeat)
|
||||
|
||||
def test_consume_true(self):
|
||||
val = True
|
||||
result = TracklistState(consume=val)
|
||||
self.assertEqual(result.consume, val)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.repeat = None
|
||||
|
||||
def test_random_true(self):
|
||||
val = True
|
||||
result = TracklistState(random=val)
|
||||
self.assertEqual(result.random, val)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.random = None
|
||||
|
||||
def test_single_true(self):
|
||||
val = True
|
||||
result = TracklistState(single=val)
|
||||
self.assertEqual(result.single, val)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.single = None
|
||||
|
||||
def test_next_tlid(self):
|
||||
val = 654
|
||||
result = TracklistState(next_tlid=val)
|
||||
self.assertEqual(result.next_tlid, val)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.next_tlid = None
|
||||
|
||||
def test_next_tlid_invalid(self):
|
||||
val = -1
|
||||
with self.assertRaises(ValueError):
|
||||
TracklistState(next_tlid=val)
|
||||
|
||||
def test_tracks(self):
|
||||
tracks = (TlTrack(), TlTrack())
|
||||
result = TracklistState(tl_tracks=tracks)
|
||||
self.assertEqual(result.tl_tracks, tracks)
|
||||
with self.assertRaises(AttributeError):
|
||||
result.tl_tracks = None
|
||||
|
||||
def test_tracks_invalid(self):
|
||||
tracks = (Track(), Track())
|
||||
with self.assertRaises(TypeError):
|
||||
TracklistState(tl_tracks=tracks)
|
||||
|
||||
def test_to_json_and_back(self):
|
||||
result = TracklistState(tl_tracks=(TlTrack(), TlTrack()), next_tlid=4)
|
||||
serialized = json.dumps(result, cls=ModelJSONEncoder)
|
||||
deserialized = json.loads(serialized, object_hook=model_json_decoder)
|
||||
self.assertEqual(result, deserialized)
|
||||
@ -4,7 +4,8 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
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):
|
||||
@ -211,6 +212,27 @@ class IntegerTest(unittest.TestCase):
|
||||
instance.attr = 11
|
||||
|
||||
|
||||
class BooleanTest(unittest.TestCase):
|
||||
def test_default_handling(self):
|
||||
instance = create_instance(Boolean(default=True))
|
||||
self.assertEqual(True, instance.attr)
|
||||
|
||||
def test_true_allowed(self):
|
||||
instance = create_instance(Boolean())
|
||||
instance.attr = True
|
||||
self.assertEqual(True, instance.attr)
|
||||
|
||||
def test_false_allowed(self):
|
||||
instance = create_instance(Boolean())
|
||||
instance.attr = False
|
||||
self.assertEqual(False, instance.attr)
|
||||
|
||||
def test_int_forbidden(self):
|
||||
instance = create_instance(Boolean())
|
||||
with self.assertRaises(TypeError):
|
||||
instance.attr = 1
|
||||
|
||||
|
||||
class CollectionTest(unittest.TestCase):
|
||||
def test_container_instance_is_default(self):
|
||||
instance = create_instance(Collection(type=int, container=frozenset))
|
||||
|
||||
@ -4,8 +4,8 @@ import json
|
||||
import unittest
|
||||
|
||||
from mopidy.models import (
|
||||
Album, Artist, Image, ModelJSONEncoder, Playlist, Ref, SearchResult,
|
||||
TlTrack, Track, model_json_decoder)
|
||||
Album, Artist, Image, ModelJSONEncoder, Playlist,
|
||||
Ref, SearchResult, TlTrack, Track, model_json_decoder)
|
||||
|
||||
|
||||
class InheritanceTest(unittest.TestCase):
|
||||
|
||||
@ -115,7 +115,7 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase):
|
||||
def test_replay_gain_status_default(self):
|
||||
self.send_request('replay_gain_status')
|
||||
self.assertInResponse('OK')
|
||||
self.assertInResponse('off')
|
||||
self.assertInResponse('replay_gain_mode: off')
|
||||
|
||||
def test_mixrampdb(self):
|
||||
self.send_request('mixrampdb "10"')
|
||||
|
||||
@ -48,9 +48,9 @@ class StatusHandlerTest(unittest.TestCase):
|
||||
def tearDown(self): # noqa: N802
|
||||
pykka.ActorRegistry.stop_all()
|
||||
|
||||
def set_tracklist(self, track):
|
||||
self.backend.library.dummy_library = [track]
|
||||
self.core.tracklist.add(uris=[track.uri]).get()
|
||||
def set_tracklist(self, tracks):
|
||||
self.backend.library.dummy_library = tracks
|
||||
self.core.tracklist.add(uris=[track.uri for track in tracks]).get()
|
||||
|
||||
def test_stats_method(self):
|
||||
result = status.stats(self.context)
|
||||
@ -154,22 +154,35 @@ class StatusHandlerTest(unittest.TestCase):
|
||||
self.assertEqual(result['state'], 'pause')
|
||||
|
||||
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()
|
||||
result = dict(status.status(self.context))
|
||||
self.assertIn('song', result)
|
||||
self.assertGreaterEqual(int(result['song']), 0)
|
||||
|
||||
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()
|
||||
result = dict(status.status(self.context))
|
||||
self.assertIn('songid', result)
|
||||
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):
|
||||
self.set_tracklist(Track(uri='dummy:/a', length=None))
|
||||
self.set_tracklist([Track(uri='dummy:/a', length=None)])
|
||||
self.core.playback.play().get()
|
||||
result = dict(status.status(self.context))
|
||||
self.assertIn('time', result)
|
||||
@ -179,7 +192,7 @@ class StatusHandlerTest(unittest.TestCase):
|
||||
self.assertLessEqual(position, total)
|
||||
|
||||
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()
|
||||
result = dict(status.status(self.context))
|
||||
self.assertIn('time', result)
|
||||
@ -189,7 +202,7 @@ class StatusHandlerTest(unittest.TestCase):
|
||||
self.assertLessEqual(position, total)
|
||||
|
||||
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.pause()
|
||||
self.core.playback.seek(59123)
|
||||
@ -198,7 +211,7 @@ class StatusHandlerTest(unittest.TestCase):
|
||||
self.assertEqual(result['elapsed'], '59.123')
|
||||
|
||||
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.pause()
|
||||
result = dict(status.status(self.context))
|
||||
@ -206,7 +219,7 @@ class StatusHandlerTest(unittest.TestCase):
|
||||
self.assertEqual(result['elapsed'], '0.000')
|
||||
|
||||
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()
|
||||
result = dict(status.status(self.context))
|
||||
self.assertIn('bitrate', result)
|
||||
|
||||
12
tox.ini
12
tox.ini
@ -1,5 +1,5 @@
|
||||
[tox]
|
||||
envlist = py27, py27-tornado23, py27-tornado31, docs, flake8
|
||||
envlist = py27, py27-tornado32, docs, flake8
|
||||
|
||||
[testenv]
|
||||
sitepackages = true
|
||||
@ -17,17 +17,11 @@ deps =
|
||||
pytest-xdist
|
||||
responses
|
||||
|
||||
[testenv:py27-tornado23]
|
||||
[testenv:py27-tornado32]
|
||||
commands = py.test tests/http
|
||||
deps =
|
||||
{[testenv]deps}
|
||||
tornado==2.3
|
||||
|
||||
[testenv:py27-tornado31]
|
||||
commands = py.test tests/http
|
||||
deps =
|
||||
{[testenv]deps}
|
||||
tornado==3.1.1
|
||||
tornado==3.2.2
|
||||
|
||||
[testenv:docs]
|
||||
deps = -r{toxinidir}/docs/requirements.txt
|
||||
|
||||
Loading…
Reference in New Issue
Block a user