diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index 19e49838..251f6e2c 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -5,9 +5,8 @@ import itertools import pykka -from mopidy import audio, backend, mixer +from mopidy import audio, backend, mixer, models from mopidy.audio import PlaybackState -from mopidy.audio.utils import convert_tags_to_track from mopidy.core.history import HistoryController from mopidy.core.library import LibraryController from mopidy.core.listener import CoreListener @@ -15,7 +14,6 @@ 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.models import TlTrack, Track from mopidy.utils import versioning from mopidy.utils.deprecation import deprecated_property @@ -88,6 +86,9 @@ class Core( def reached_end_of_stream(self): self.playback.on_end_of_track() + def stream_changed(self, uri): + self.playback.on_stream_changed(uri) + def state_changed(self, old_state, new_state, target_state): # XXX: This is a temporary fix for issue #232 while we wait for a more # permanent solution with the implementation of issue #234. When the @@ -116,30 +117,15 @@ class Core( CoreListener.send('mute_changed', mute=mute) def tags_changed(self, tags): - if not self.audio: - return - - current_tl_track = self.playback.get_current_tl_track() - if current_tl_track is None: + if not self.audio or 'title' not in tags: return tags = self.audio.get_current_tags().get() - if not tags: + if not tags or 'title' not in tags or not tags['title']: return - current_track = current_tl_track.track - tags_track = convert_tags_to_track(tags) - - track_kwargs = {k: v for k, v in current_track.__dict__.items() if v} - track_kwargs.update( - {k: v for k, v in tags_track.__dict__.items() if v}) - - self.playback._current_metadata_track = TlTrack(**{ - 'tlid': current_tl_track.tlid, - 'track': Track(**track_kwargs)}) - - # TODO Move this into playback.current_metadata_track setter? - CoreListener.send('current_metadata_changed') + self.playback._stream_ref = models.Ref.track(name=tags['title'][0]) + CoreListener.send('stream_changed') class Backends(list): diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index 9d952473..f013fa18 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -164,9 +164,9 @@ class CoreListener(listener.Listener): """ pass - def current_metadata_changed(self): + def stream_changed(self): """ - Called whenever current track's metadata changed + Called whenever the currently playing stream changes. *MAY* be implemented by actor. """ diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 0d604d61..6314442b 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -20,7 +20,7 @@ class PlaybackController(object): self.core = core self._current_tl_track = None - self._current_metadata_track = None + self._stream_ref = None self._state = PlaybackState.STOPPED def _get_backend(self): @@ -73,20 +73,23 @@ class PlaybackController(object): Use :meth:`get_current_track` instead. """ - def get_current_metadata_track(self): + def get_stream_reference(self): """ - Get a :class:`mopidy.models.TlTrack` with updated metadata for the - currently playing track. + Get additional information about the current stream. - Returns :class:`None` if no track is currently playing. + For most cases this value won't be set, but for radio streams it will + contain a reference with the name of the currently playing track or + program. Clients should show this when available. + + The :class:`mopidy.models.Ref` instance may or may not have an URI set. + If present you can call ``lookup`` on it to get the full metadata for + the URI. + + Returns a :class:`mopidy.models.Ref` instance representing the current + stream. If nothing is playing, or no stream info is available this will + return :class:`None`. """ - return self._current_metadata_track - - current_metadata_track = deprecated_property(get_current_metadata_track) - """ - .. deprecated:: 0.20 - Use :meth:`get_current_metadata_track` instead. - """ + return self._stream_ref def get_state(self): """Get The playback state.""" @@ -244,6 +247,9 @@ class PlaybackController(object): self.stop() self.set_current_tl_track(None) + def on_stream_changed(self, uri): + self._stream_ref = None + def next(self): """ Change to the next track. diff --git a/mopidy/mpd/actor.py b/mopidy/mpd/actor.py index b56e507d..2c63bcb2 100644 --- a/mopidy/mpd/actor.py +++ b/mopidy/mpd/actor.py @@ -74,5 +74,5 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): def mute_changed(self, mute): self.send_idle('output') - def current_metadata_changed(self): + def stream_changed(self): self.send_idle('playlist') diff --git a/mopidy/mpd/protocol/current_playlist.py b/mopidy/mpd/protocol/current_playlist.py index e083ea7c..fdd65bde 100644 --- a/mopidy/mpd/protocol/current_playlist.py +++ b/mopidy/mpd/protocol/current_playlist.py @@ -276,25 +276,20 @@ def plchanges(context, version): """ # XXX Naive implementation that returns all tracks as changed tracklist_version = context.core.tracklist.version.get() - iversion = int(version) - if iversion < tracklist_version: + if version < tracklist_version: return translator.tracks_to_mpd_format( context.core.tracklist.tl_tracks.get()) - elif iversion == tracklist_version: - # If version are equals, it is just a metadata update - # So we replace the updated track in playlist - current_md_track = context.core.playback.current_metadata_track.get() - if current_md_track is None: + elif version == tracklist_version: + # A version match could indicate this is just a metadata update, so + # check for a stream ref and let the client know about the change. + stream_ref = context.core.playback.get_stream_reference().get() + if stream_ref is None: return None - ntl_tracks = [] - tl_tracks = context.core.tracklist.tl_tracks.get() - for tl_track in tl_tracks: - if tl_track.tlid == current_md_track.tlid: - ntl_tracks.append(current_md_track) - else: - ntl_tracks.append(tl_track) - return translator.tracks_to_mpd_format(ntl_tracks) + tl_track = context.core.playback.current_tl_track.get() + position = context.core.tracklist.index(tl_track).get() + return translator.track_to_mpd_format( + tl_track, position=position, stream=stream_ref) @protocol.commands.add('plchangesposid', version=protocol.INT) diff --git a/mopidy/mpd/protocol/status.py b/mopidy/mpd/protocol/status.py index d33e0afa..e2e73e6f 100644 --- a/mopidy/mpd/protocol/status.py +++ b/mopidy/mpd/protocol/status.py @@ -34,12 +34,12 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - tl_track = context.core.playback.current_metadata_track.get() - if tl_track is None: - tl_track = context.core.playback.current_tl_track.get() + tl_track = context.core.playback.current_tl_track.get() + stream = context.core.playback.get_stream_reference().get() if tl_track is not None: position = context.core.tracklist.index(tl_track).get() - return translator.track_to_mpd_format(tl_track, position=position) + return translator.track_to_mpd_format( + tl_track, position=position, stream=stream) @protocol.commands.add('idle', list_command=False) diff --git a/mopidy/mpd/translator.py b/mopidy/mpd/translator.py index 23fb2874..37c1493b 100644 --- a/mopidy/mpd/translator.py +++ b/mopidy/mpd/translator.py @@ -15,7 +15,7 @@ def normalize_path(path, relative=False): return '/'.join(parts) -def track_to_mpd_format(track, position=None): +def track_to_mpd_format(track, position=None, stream=None): """ Format track for output to MPD client. @@ -33,6 +33,7 @@ def track_to_mpd_format(track, position=None): (tlid, track) = track else: (tlid, track) = (None, track) + result = [ ('file', track.uri or ''), ('Time', track.length and (track.length // 1000) or 0), @@ -41,6 +42,9 @@ def track_to_mpd_format(track, position=None): ('Album', track.album and track.album.name or ''), ] + if stream and stream.name != track.name: + result.append(('Name', stream.name)) + if track.date: result.append(('Date', track.date)) diff --git a/tests/core/test_playback.py b/tests/core/test_playback.py index 3b6435c8..15d2d5f8 100644 --- a/tests/core/test_playback.py +++ b/tests/core/test_playback.py @@ -4,8 +4,12 @@ import unittest import mock +import pykka + from mopidy import backend, core -from mopidy.models import Track +from mopidy.models import Ref, Track + +from tests import dummy_audio as audio class CorePlaybackTest(unittest.TestCase): @@ -525,3 +529,87 @@ class CorePlaybackTest(unittest.TestCase): self.assertFalse(self.playback2.get_time_position.called) # TODO Test on_tracklist_change + + +# Since we rely on our DummyAudio to actually emit events we need a "real" +# backend and not a mock so the right calls make it through to audio. +class TestBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes = ['dummy'] + + def __init__(self, config, audio): + super(TestBackend, self).__init__() + self.playback = backend.PlaybackProvider(audio=audio, backend=self) + + +class TestStream(unittest.TestCase): + def setUp(self): # noqa: N802 + self.audio = audio.DummyAudio.start().proxy() + self.backend = TestBackend.start(config={}, audio=self.audio).proxy() + self.core = core.Core(audio=self.audio, backends=[self.backend]) + self.playback = self.core.playback + + self.tracks = [Track(uri='dummy:a', length=1234), + Track(uri='dummy:b', length=1234)] + + self.core.tracklist.add(self.tracks) + + self.events = [] + self.patcher = mock.patch('mopidy.audio.listener.AudioListener.send') + self.send_mock = self.patcher.start() + + def send(event, **kwargs): + self.events.append((event, kwargs)) + + self.send_mock.side_effect = send + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + self.patcher.stop() + + def replay_audio_events(self): + while self.events: + event, kwargs = self.events.pop(0) + self.core.on_event(event, **kwargs) + + def test_get_stream_reference_before_playback(self): + self.assertEqual(self.playback.get_stream_reference(), None) + + def test_get_stream_reference_during_playback(self): + self.core.playback.play() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_reference(), None) + + def test_get_stream_reference_during_playback_with_tags_change(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + + self.replay_audio_events() + expected = Ref.track(name='foobar') + self.assertEqual(self.playback.get_stream_reference(), expected) + + def test_get_stream_reference_after_next(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.core.playback.next() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_reference(), None) + + def test_get_stream_reference_after_next_with_tags_change(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foo']}).get() + self.core.playback.next() + self.audio.trigger_fake_tags_changed({'title': ['bar']}).get() + + self.replay_audio_events() + expected = Ref.track(name='bar') + self.assertEqual(self.playback.get_stream_reference(), expected) + + def test_get_stream_reference_after_stop(self): + self.core.playback.play() + self.audio.trigger_fake_tags_changed({'title': ['foobar']}).get() + self.core.playback.stop() + + self.replay_audio_events() + self.assertEqual(self.playback.get_stream_reference(), None)