diff --git a/.mailmap b/.mailmap index 2ff779fc..242d4d91 100644 --- a/.mailmap +++ b/.mailmap @@ -8,3 +8,4 @@ John Bäckstrand Alli Witheford Alexandre Petitjean Alexandre Petitjean +Javier Domingo Cansino diff --git a/AUTHORS b/AUTHORS index fdfb82fb..e59b92e2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,7 +24,6 @@ - Alli Witheford - Alexandre Petitjean - Terje Larsen -- Pavol Babincak -- Javier Domingo - Javier Domingo Cansino +- Pavol Babincak - Lasse Bigum diff --git a/docs/changelog.rst b/docs/changelog.rst index 9863b6ab..e77cd948 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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** diff --git a/docs/conf.py b/docs/conf.py index 56ddbf92..5a75b7d4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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 diff --git a/docs/config.rst b/docs/config.rst index 5b8f5de1..c381ef70 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -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 diff --git a/docs/ext/index.rst b/docs/ext/index.rst index 940dd37a..27fe3b45 100644 --- a/docs/ext/index.rst +++ b/docs/ext/index.rst @@ -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. diff --git a/docs/index.rst b/docs/index.rst index 17a40c32..732c9f32 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -81,4 +81,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` -* :ref:`search` diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 238184f4..cd4ad983 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -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 `, 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:: diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py index 1cb8534e..0e4a29a2 100644 --- a/mopidy/backends/local/library.py +++ b/mopidy/backends/local/library.py @@ -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', diff --git a/mopidy/backends/local/playback.py b/mopidy/backends/local/playback.py index eda06ff7..98c92a85 100644 --- a/mopidy/backends/local/playback.py +++ b/mopidy/backends/local/playback.py @@ -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) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 0767b50c..6d66e253 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -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 diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 65e693ec..17cf4ac4 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -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') diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 9c3bc13c..f58ca769 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -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) diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index ab135766..8fbc4415 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -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) diff --git a/tests/config/config_test.py b/tests/config/config_test.py index c40baa87..fceb293d 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -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) diff --git a/tests/frontends/mpd/protocol/audio_output_test.py b/tests/frontends/mpd/protocol/audio_output_test.py index cbfb5043..4871f169 100644 --- a/tests/frontends/mpd/protocol/audio_output_test.py +++ b/tests/frontends/mpd/protocol/audio_output_test.py @@ -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"')