Merge branch 'develop' into feature/track_and_count

This commit is contained in:
Stein Magnus Jodal 2013-10-27 21:04:09 +01:00
commit f7e85b1f12
16 changed files with 292 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -81,4 +81,3 @@ Indices and tables
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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