Merge branch 'develop' into feature/track_and_count
This commit is contained in:
commit
f7e85b1f12
1
.mailmap
1
.mailmap
@ -8,3 +8,4 @@ John Bäckstrand <sopues@gmail.com> <sandos@XBMCLive.(none)>
|
||||
Alli Witheford <alzeih@gmail.com>
|
||||
Alexandre Petitjean <alpetitjean@gmail.com>
|
||||
Alexandre Petitjean <alpetitjean@gmail.com> <alexandre.petitjean@lne.fr>
|
||||
Javier Domingo Cansino <javier.domingo@fon.com> <javierdo1@gmail.com>
|
||||
|
||||
3
AUTHORS
3
AUTHORS
@ -24,7 +24,6 @@
|
||||
- Alli Witheford <alzeih@gmail.com>
|
||||
- Alexandre Petitjean <alpetitjean@gmail.com>
|
||||
- Terje Larsen <terlar@gmail.com>
|
||||
- Pavol Babincak <scroolik@gmail.com>
|
||||
- Javier Domingo <javierdo1@gmail.com>
|
||||
- Javier Domingo Cansino <javier.domingo@fon.com>
|
||||
- Pavol Babincak <scroolik@gmail.com>
|
||||
- Lasse Bigum <lasse@bigum.org>
|
||||
|
||||
@ -69,6 +69,15 @@ of the following extensions as well:
|
||||
- Added :meth:`mopidy.core.CoreListener.mute_changed` event that is triggered
|
||||
when the mute state changes.
|
||||
|
||||
- In "random" mode, after a full playthrough of the tracklist, playback
|
||||
continued from the last track played to the end of the playlist in non-random
|
||||
order. It now stops when all tracks have been played once, unless "repeat"
|
||||
mode is enabled. (Fixes: :issue:`453`)
|
||||
|
||||
- In "single" mode, after a track ended, playback continued with the next track
|
||||
in the tracklist. It now stops after playing a single track, unless "repeat"
|
||||
mode is enabled. (Fixes: :issue:`496`)
|
||||
|
||||
**Audio**
|
||||
|
||||
- Added support for parsing and playback of playlists in GStreamer. For end
|
||||
@ -88,16 +97,24 @@ of the following extensions as well:
|
||||
|
||||
- Replaced our custom media library scanner with GStreamer's builtin scanner.
|
||||
This should make scanning less error prone and faster as timeouts should be
|
||||
infrequent.
|
||||
infrequent. (Fixes: :issue:`198`)
|
||||
|
||||
- Media files with less than 100ms duration are now excluded from the library.
|
||||
|
||||
- Unknown URIs found in playlists are now made into track objects with the URI
|
||||
set instead of being ignored. This makes it possible to have playlists with
|
||||
e.g. HTTP radio streams and not just ``local:track:...`` URIs. This used to
|
||||
work, but was broken in Mopidy 0.15.0. (Fixes: :issue:`527`)
|
||||
|
||||
- Fixed crash when playing ``local:track:...`` URIs which contained non-ASCII
|
||||
chars after uridecode.
|
||||
|
||||
**MPD frontend**
|
||||
|
||||
- Made the formerly unused commands ``outputs``, ``enableoutput``, and
|
||||
``disableoutput`` mute/unmute audio. (Related to: :issue:`186`)
|
||||
|
||||
- The MPD command ``list`` now works with ``"albumartist"`` as it's second
|
||||
- The MPD command ``list`` now works with ``"albumartist"`` as its second
|
||||
argument, e.g. ``"album" "albumartist" "anartist"``. (Fixes: :issue:`468`)
|
||||
|
||||
**Extension support**
|
||||
|
||||
@ -78,6 +78,9 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../'))
|
||||
# the string True.
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
|
||||
# Enable Read the Docs' new theme
|
||||
RTD_NEW_THEME = True
|
||||
|
||||
# -- General configuration ----------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
|
||||
@ -209,6 +209,13 @@ this work first::
|
||||
Streaming through SHOUTcast/Icecast
|
||||
-----------------------------------
|
||||
|
||||
.. warning:: Known issue
|
||||
|
||||
Currently, Mopidy does not handle end-of-track vs end-of-stream signalling
|
||||
in GStreamer correctly. This causes the SHOUTcast stream to be disconnected
|
||||
at the end of each track, rendering it quite useless. For further details,
|
||||
see :issue:`492`.
|
||||
|
||||
If you want to play the audio on another computer than the one running Mopidy,
|
||||
you can stream the audio from Mopidy through an SHOUTcast or Icecast audio
|
||||
streaming server. Multiple media players can then be connected to the streaming
|
||||
|
||||
@ -33,7 +33,7 @@ developers.
|
||||
Mopidy-Arcam
|
||||
------------
|
||||
|
||||
https://github.com/mopidy/mopidy-arcam
|
||||
https://github.com/TooDizzy/mopidy-arcam
|
||||
|
||||
Extension for controlling volume using an external Arcam amplifier. Developed
|
||||
and tested with an Arcam AVR-300.
|
||||
|
||||
@ -81,4 +81,3 @@ Indices and tables
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
@ -88,7 +88,7 @@ Mopidy Git repo, which always corresponds to the latest release.
|
||||
|
||||
#. Optional: If you want to scrobble your played tracks to Last.fm, you need to
|
||||
install `python2-pylast`::
|
||||
|
||||
|
||||
sudo pacman -S python2-pylast
|
||||
|
||||
#. Finally, you need to set a couple of :doc:`config values </config>`, and
|
||||
@ -175,10 +175,10 @@ can install Mopidy from PyPI using Pip.
|
||||
|
||||
#. Then you'll need to install all of Mopidy's hard non-Python dependencies:
|
||||
|
||||
- GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most
|
||||
popular Linux distributions. Search for GStreamer in your package manager,
|
||||
and make sure to install the Python bindings, and the "good" and "ugly"
|
||||
plugin sets.
|
||||
- GStreamer 0.10 (>= 0.10.31, < 0.11), with Python bindings. GStreamer is
|
||||
packaged for most popular Linux distributions. Search for GStreamer in
|
||||
your package manager, and make sure to install the Python bindings, and
|
||||
the "good" and "ugly" plugin sets.
|
||||
|
||||
If you use Debian/Ubuntu you can install GStreamer like this::
|
||||
|
||||
|
||||
@ -27,9 +27,14 @@ class LocalLibraryProvider(base.BaseLibraryProvider):
|
||||
self._media_dir, self._tag_cache_file)
|
||||
|
||||
tracks = parse_mpd_tag_cache(self._tag_cache_file, self._media_dir)
|
||||
uris_to_remove = set(self._uri_mapping)
|
||||
|
||||
for track in tracks:
|
||||
self._uri_mapping[track.uri] = track
|
||||
uris_to_remove.discard(track.uri)
|
||||
|
||||
for uri in uris_to_remove:
|
||||
del self._uri_mapping[uri]
|
||||
|
||||
logger.info(
|
||||
'Loaded %d local tracks from %s using %s',
|
||||
|
||||
@ -13,7 +13,7 @@ class LocalPlaybackProvider(base.BasePlaybackProvider):
|
||||
def change_track(self, track):
|
||||
media_dir = self.backend.config['local']['media_dir']
|
||||
# TODO: check that type is correct.
|
||||
file_path = path.uri_to_path(track.uri).split(':', 1)[1]
|
||||
file_path = path.uri_to_path(track.uri).split(b':', 1)[1]
|
||||
file_path = os.path.join(media_dir, file_path)
|
||||
track = track.copy(uri=path.path_to_uri(file_path))
|
||||
return super(LocalPlaybackProvider, self).change_track(track)
|
||||
|
||||
@ -2,8 +2,10 @@ from __future__ import unicode_literals
|
||||
|
||||
import ConfigParser as configparser
|
||||
import io
|
||||
import itertools
|
||||
import logging
|
||||
import os.path
|
||||
import re
|
||||
|
||||
from mopidy.config import keyring
|
||||
from mopidy.config.schemas import * # noqa
|
||||
@ -145,6 +147,53 @@ def _format(config, comments, schemas, display):
|
||||
return b'\n'.join(output)
|
||||
|
||||
|
||||
def _preprocess(config_string):
|
||||
"""Convert a raw config into a form that preserves comments etc."""
|
||||
results = ['[__COMMENTS__]']
|
||||
counter = itertools.count(0)
|
||||
|
||||
section_re = re.compile(r'^(\[[^\]]+\])\s*(.+)$')
|
||||
blank_line_re = re.compile(r'^\s*$')
|
||||
comment_re = re.compile(r'^(#|;)')
|
||||
inline_comment_re = re.compile(r' ;')
|
||||
|
||||
def newlines(match):
|
||||
return '__BLANK%d__ =' % next(counter)
|
||||
|
||||
def comments(match):
|
||||
if match.group(1) == '#':
|
||||
return '__HASH%d__ =' % next(counter)
|
||||
elif match.group(1) == ';':
|
||||
return '__SEMICOLON%d__ =' % next(counter)
|
||||
|
||||
def inlinecomments(match):
|
||||
return '\n__INLINE%d__ =' % next(counter)
|
||||
|
||||
def sections(match):
|
||||
return '%s\n__SECTION%d__ = %s' % (
|
||||
match.group(1), next(counter), match.group(2))
|
||||
|
||||
for line in config_string.splitlines():
|
||||
line = blank_line_re.sub(newlines, line)
|
||||
line = section_re.sub(sections, line)
|
||||
line = comment_re.sub(comments, line)
|
||||
line = inline_comment_re.sub(inlinecomments, line)
|
||||
results.append(line)
|
||||
return '\n'.join(results)
|
||||
|
||||
|
||||
def _postprocess(config_string):
|
||||
"""Converts a preprocessed config back to original form."""
|
||||
flags = re.IGNORECASE | re.MULTILINE
|
||||
result = re.sub(r'^\[__COMMENTS__\](\n|$)', '', config_string, flags=flags)
|
||||
result = re.sub(r'\n__INLINE\d+__ =(.*)$', ' ;\g<1>', result, flags=flags)
|
||||
result = re.sub(r'^__HASH\d+__ =(.*)$', '#\g<1>', result, flags=flags)
|
||||
result = re.sub(r'^__SEMICOLON\d+__ =(.*)$', ';\g<1>', result, flags=flags)
|
||||
result = re.sub(r'\n__SECTION\d+__ =(.*)$', '\g<1>', result, flags=flags)
|
||||
result = re.sub(r'^__BLANK\d+__ =$', '', result, flags=flags)
|
||||
return result
|
||||
|
||||
|
||||
class Proxy(collections.Mapping):
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
@ -14,7 +14,7 @@ def disableoutput(context, outputid):
|
||||
Turns an output off.
|
||||
"""
|
||||
if int(outputid) == 0:
|
||||
context.core.playback.set_mute(True)
|
||||
context.core.playback.set_mute(False)
|
||||
else:
|
||||
raise MpdNoExistError('No such audio output', command='disableoutput')
|
||||
|
||||
@ -29,7 +29,7 @@ def enableoutput(context, outputid):
|
||||
Turns an output on.
|
||||
"""
|
||||
if int(outputid) == 0:
|
||||
context.core.playback.set_mute(False)
|
||||
context.core.playback.set_mute(True)
|
||||
else:
|
||||
raise MpdNoExistError('No such audio output', command='enableoutput')
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import pykka
|
||||
@ -11,6 +12,8 @@ from mopidy.models import Track, Album, Artist
|
||||
from tests import path_to_data_dir
|
||||
|
||||
|
||||
# TODO: update tests to only use backend, not core. we need a seperate
|
||||
# core test that does this integration test.
|
||||
class LocalLibraryProviderTest(unittest.TestCase):
|
||||
artists = [
|
||||
Artist(name='artist1'),
|
||||
@ -49,7 +52,6 @@ class LocalLibraryProviderTest(unittest.TestCase):
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.backend = actor.LocalBackend.start(
|
||||
config=self.config, audio=None).proxy()
|
||||
self.core = core.Core(backends=[self.backend])
|
||||
@ -65,9 +67,31 @@ class LocalLibraryProviderTest(unittest.TestCase):
|
||||
def test_refresh_uri(self):
|
||||
pass
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_refresh_missing_uri(self):
|
||||
pass
|
||||
# Verifies that https://github.com/mopidy/mopidy/issues/500
|
||||
# has been fixed.
|
||||
|
||||
tag_cache = tempfile.NamedTemporaryFile()
|
||||
with open(self.config['local']['tag_cache_file']) as fh:
|
||||
tag_cache.write(fh.read())
|
||||
tag_cache.flush()
|
||||
|
||||
config = {'local': self.config['local'].copy()}
|
||||
config['local']['tag_cache_file'] = tag_cache.name
|
||||
backend = actor.LocalBackend(config=config, audio=None)
|
||||
|
||||
# Sanity check that value is in tag cache
|
||||
result = backend.library.lookup(self.tracks[0].uri)
|
||||
self.assertEqual(result, self.tracks[0:1])
|
||||
|
||||
# Clear tag cache and refresh
|
||||
tag_cache.seek(0)
|
||||
tag_cache.truncate()
|
||||
backend.library.refresh()
|
||||
|
||||
# Now it should be gone.
|
||||
result = backend.library.lookup(self.tracks[0].uri)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_lookup(self):
|
||||
tracks = self.library.lookup(self.tracks[0].uri)
|
||||
|
||||
@ -72,6 +72,14 @@ class LocalPlaybackProviderTest(unittest.TestCase):
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
def test_play_uri_with_non_ascii_bytes(self):
|
||||
# Regression test: If trying to do .split(u':') on a bytestring, the
|
||||
# string will be decoded from ASCII to Unicode, which will crash on
|
||||
# non-ASCII strings, like the bytestring the following URI decodes to.
|
||||
self.add_track('local:track:12%20Doin%E2%80%99%20It%20Right.flac')
|
||||
self.playback.play()
|
||||
self.assertEqual(self.playback.state, PlaybackState.PLAYING)
|
||||
|
||||
def test_initial_state_is_stopped(self):
|
||||
self.assertEqual(self.playback.state, PlaybackState.STOPPED)
|
||||
|
||||
|
||||
@ -106,3 +106,162 @@ class ValidateTest(unittest.TestCase):
|
||||
self.assertEqual({'foo': {'bar': 'bad'}}, errors)
|
||||
|
||||
# TODO: add more tests
|
||||
|
||||
|
||||
INPUT_CONFIG = """# comments before first section should work
|
||||
|
||||
[section] anything goes ; after the [] block it seems.
|
||||
; this is a valid comment
|
||||
this-should-equal-baz = baz ; as this is a comment
|
||||
this-should-equal-everything = baz # as this is not a comment
|
||||
|
||||
# this is also a comment ; and the next line should be a blank comment.
|
||||
;
|
||||
# foo # = should all be treated as a comment."""
|
||||
|
||||
PROCESSED_CONFIG = """[__COMMENTS__]
|
||||
__HASH0__ = comments before first section should work
|
||||
__BLANK1__ =
|
||||
[section]
|
||||
__SECTION2__ = anything goes
|
||||
__INLINE3__ = after the [] block it seems.
|
||||
__SEMICOLON4__ = this is a valid comment
|
||||
this-should-equal-baz = baz
|
||||
__INLINE5__ = as this is a comment
|
||||
this-should-equal-everything = baz # as this is not a comment
|
||||
__BLANK6__ =
|
||||
__HASH7__ = this is also a comment
|
||||
__INLINE8__ = and the next line should be a blank comment.
|
||||
__SEMICOLON9__ =
|
||||
__HASH10__ = foo # = should all be treated as a comment."""
|
||||
|
||||
|
||||
class PreProcessorTest(unittest.TestCase):
|
||||
maxDiff = None # Show entire diff.
|
||||
|
||||
def test_empty_config(self):
|
||||
result = config._preprocess('')
|
||||
self.assertEqual(result, '[__COMMENTS__]')
|
||||
|
||||
def test_plain_section(self):
|
||||
result = config._preprocess('[section]\nfoo = bar')
|
||||
self.assertEqual(result, '[__COMMENTS__]\n'
|
||||
'[section]\n'
|
||||
'foo = bar')
|
||||
|
||||
def test_initial_comments(self):
|
||||
result = config._preprocess('; foobar')
|
||||
self.assertEqual(result, '[__COMMENTS__]\n'
|
||||
'__SEMICOLON0__ = foobar')
|
||||
|
||||
result = config._preprocess('# foobar')
|
||||
self.assertEqual(result, '[__COMMENTS__]\n'
|
||||
'__HASH0__ = foobar')
|
||||
|
||||
result = config._preprocess('; foo\n# bar')
|
||||
self.assertEqual(result, '[__COMMENTS__]\n'
|
||||
'__SEMICOLON0__ = foo\n'
|
||||
'__HASH1__ = bar')
|
||||
|
||||
def test_initial_comment_inline_handling(self):
|
||||
result = config._preprocess('; foo ; bar ; baz')
|
||||
self.assertEqual(result, '[__COMMENTS__]\n'
|
||||
'__SEMICOLON0__ = foo\n'
|
||||
'__INLINE1__ = bar\n'
|
||||
'__INLINE2__ = baz')
|
||||
|
||||
def test_inline_semicolon_comment(self):
|
||||
result = config._preprocess('[section]\nfoo = bar ; baz')
|
||||
self.assertEqual(result, '[__COMMENTS__]\n'
|
||||
'[section]\n'
|
||||
'foo = bar\n'
|
||||
'__INLINE0__ = baz')
|
||||
|
||||
def test_no_inline_hash_comment(self):
|
||||
result = config._preprocess('[section]\nfoo = bar # baz')
|
||||
self.assertEqual(result, '[__COMMENTS__]\n'
|
||||
'[section]\n'
|
||||
'foo = bar # baz')
|
||||
|
||||
def test_section_extra_text(self):
|
||||
result = config._preprocess('[section] foobar')
|
||||
self.assertEqual(result, '[__COMMENTS__]\n'
|
||||
'[section]\n'
|
||||
'__SECTION0__ = foobar')
|
||||
|
||||
def test_section_extra_text_inline_semicolon(self):
|
||||
result = config._preprocess('[section] foobar ; baz')
|
||||
self.assertEqual(result, '[__COMMENTS__]\n'
|
||||
'[section]\n'
|
||||
'__SECTION0__ = foobar\n'
|
||||
'__INLINE1__ = baz')
|
||||
|
||||
def test_conversion(self):
|
||||
"""Tests all of the above cases at once."""
|
||||
result = config._preprocess(INPUT_CONFIG)
|
||||
self.assertEqual(result, PROCESSED_CONFIG)
|
||||
|
||||
|
||||
class PostProcessorTest(unittest.TestCase):
|
||||
maxDiff = None # Show entire diff.
|
||||
|
||||
def test_empty_config(self):
|
||||
result = config._postprocess('[__COMMENTS__]')
|
||||
self.assertEqual(result, '')
|
||||
|
||||
def test_plain_section(self):
|
||||
result = config._postprocess('[__COMMENTS__]\n'
|
||||
'[section]\n'
|
||||
'foo = bar')
|
||||
self.assertEqual(result, '[section]\nfoo = bar')
|
||||
|
||||
def test_initial_comments(self):
|
||||
result = config._postprocess('[__COMMENTS__]\n'
|
||||
'__SEMICOLON0__ = foobar')
|
||||
self.assertEqual(result, '; foobar')
|
||||
|
||||
result = config._postprocess('[__COMMENTS__]\n'
|
||||
'__HASH0__ = foobar')
|
||||
self.assertEqual(result, '# foobar')
|
||||
|
||||
result = config._postprocess('[__COMMENTS__]\n'
|
||||
'__SEMICOLON0__ = foo\n'
|
||||
'__HASH1__ = bar')
|
||||
self.assertEqual(result, '; foo\n# bar')
|
||||
|
||||
def test_initial_comment_inline_handling(self):
|
||||
result = config._postprocess('[__COMMENTS__]\n'
|
||||
'__SEMICOLON0__ = foo\n'
|
||||
'__INLINE1__ = bar\n'
|
||||
'__INLINE2__ = baz')
|
||||
self.assertEqual(result, '; foo ; bar ; baz')
|
||||
|
||||
def test_inline_semicolon_comment(self):
|
||||
result = config._postprocess('[__COMMENTS__]\n'
|
||||
'[section]\n'
|
||||
'foo = bar\n'
|
||||
'__INLINE0__ = baz')
|
||||
self.assertEqual(result, '[section]\nfoo = bar ; baz')
|
||||
|
||||
def test_no_inline_hash_comment(self):
|
||||
result = config._preprocess('[section]\nfoo = bar # baz')
|
||||
self.assertEqual(result, '[__COMMENTS__]\n'
|
||||
'[section]\n'
|
||||
'foo = bar # baz')
|
||||
|
||||
def test_section_extra_text(self):
|
||||
result = config._postprocess('[__COMMENTS__]\n'
|
||||
'[section]\n'
|
||||
'__SECTION0__ = foobar')
|
||||
self.assertEqual(result, '[section] foobar')
|
||||
|
||||
def test_section_extra_text_inline_semicolon(self):
|
||||
result = config._postprocess('[__COMMENTS__]\n'
|
||||
'[section]\n'
|
||||
'__SECTION0__ = foobar\n'
|
||||
'__INLINE1__ = baz')
|
||||
self.assertEqual(result, '[section] foobar ; baz')
|
||||
|
||||
def test_conversion(self):
|
||||
result = config._postprocess(PROCESSED_CONFIG)
|
||||
self.assertEqual(result, INPUT_CONFIG)
|
||||
|
||||
@ -5,12 +5,12 @@ from tests.frontends.mpd import protocol
|
||||
|
||||
class AudioOutputHandlerTest(protocol.BaseTestCase):
|
||||
def test_enableoutput(self):
|
||||
self.core.playback.mute = True
|
||||
self.core.playback.mute = False
|
||||
|
||||
self.sendRequest('enableoutput "0"')
|
||||
|
||||
self.assertInResponse('OK')
|
||||
self.assertEqual(self.core.playback.mute.get(), False)
|
||||
self.assertEqual(self.core.playback.mute.get(), True)
|
||||
|
||||
def test_enableoutput_unknown_outputid(self):
|
||||
self.sendRequest('enableoutput "7"')
|
||||
@ -18,12 +18,12 @@ class AudioOutputHandlerTest(protocol.BaseTestCase):
|
||||
self.assertInResponse('ACK [50@0] {enableoutput} No such audio output')
|
||||
|
||||
def test_disableoutput(self):
|
||||
self.core.playback.mute = False
|
||||
self.core.playback.mute = True
|
||||
|
||||
self.sendRequest('disableoutput "0"')
|
||||
|
||||
self.assertInResponse('OK')
|
||||
self.assertEqual(self.core.playback.mute.get(), True)
|
||||
self.assertEqual(self.core.playback.mute.get(), False)
|
||||
|
||||
def test_disableoutput_unknown_outputid(self):
|
||||
self.sendRequest('disableoutput "7"')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user