From 28d2d88340a320640131d29ec6a7d0171cfe8cb9 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 5 Apr 2023 16:09:41 -0700 Subject: [PATCH 01/22] Resolve error relating to #65 Adds unit tests for OCP class --- ovos_plugin_common_play/ocp/__init__.py | 48 +++++-- test/unittests/test_ocp.py | 176 ++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 test/unittests/test_ocp.py diff --git a/ovos_plugin_common_play/ocp/__init__.py b/ovos_plugin_common_play/ocp/__init__.py index 8b36893..3491313 100644 --- a/ovos_plugin_common_play/ocp/__init__.py +++ b/ovos_plugin_common_play/ocp/__init__.py @@ -45,9 +45,8 @@ class OCP(OVOSAbstractApplication): def __init__(self, bus=None, lang=None, settings=None): # settings = settings or OCPSettings() res_dir = join(dirname(__file__), "res") - gui = OCPMediaPlayerGUI() super().__init__(skill_id=OCP_ID, resources_dir=res_dir, - bus=bus, lang=lang, gui=gui) + bus=bus, lang=lang, gui=OCPMediaPlayerGUI()) if settings: LOG.debug(f"Updating settings from value passed at init") self.settings.merge(settings) @@ -71,20 +70,33 @@ def __init__(self, bus=None, lang=None, settings=None): self.remove_event("mycroft.ready") self.replace_mycroft_cps(skills_ready) try: + # TODO: Should this just happen at install time? A user might not + # want this shortcut. create_desktop_file() except: # permission errors and stuff pass def handle_ping(self, message): + """ + Handle ovos.common_play.ping Messages and emit a response + @param message: message associated with request + """ self.bus.emit(message.reply("ovos.common_play.pong")) def register_ocp_api_events(self): + """ + Register messagebus handlers for OCP events + """ self.add_event("ovos.common_play.ping", self.handle_ping) self.add_event('ovos.common_play.home', self.handle_home) # bus api shared with intents self.add_event("ovos.common_play.search", self.handle_play) - def handle_home(self): + def handle_home(self, _=None): + """ + Handle ovos.common_play.home Messages and show the homescreen + @param _: message associated with request + """ # homescreen / launch from .desktop self.gui.show_home(app_mode=True) @@ -207,6 +219,10 @@ def classify_media(self, query): # playback control intents def handle_open(self, message): + """ + Handle open.intent + @param message: Message associated with intent match + """ self.gui.show_home(app_mode=True) def handle_next(self, message): @@ -218,14 +234,23 @@ def handle_prev(self, message): def handle_pause(self, message): self.player.pause() + def handle_stop(self, message=None): + # will stop any playback in GUI and AudioService + try: + return self.player.stop() + except: + pass + def handle_resume(self, message): """Resume playback if paused""" + # TODO: Should this also handle "stopped"? if self.player.state == PlayerState.PAUSED: self.player.resume() else: + LOG.info("Asked to resume while not paused") query = self.get_response("play.what") if query: - message["utterance"] = query + message.data["utterance"] = query self.handle_play(message) def handle_play(self, message): @@ -299,13 +324,6 @@ def _do_play(self, phrase, results, media_type=MediaType.GENERIC): self.enclosure.mouth_reset() # TODO display music icon in mk1 self.set_context("Playing") - def handle_stop(self, message=None): - # will stop any playback in GUI and AudioService - try: - return self.player.stop() - except: - pass - # helper methods def _search(self, phrase, utterance, media_type): self.enclosure.mouth_think() @@ -363,7 +381,13 @@ def _search(self, phrase, utterance, media_type): LOG.debug(f"Returning {len(results)} results") return results - def _should_resume(self, phrase): + def _should_resume(self, phrase: str) -> bool: + """ + Check if a "play" request should resume playback or be handled as a new + session. + @param phrase: Extracted playback phrase + @return: True if player should resume, False if this is a new request + """ if self.player.state == PlayerState.PAUSED: if not phrase.strip() or \ self.voc_match(phrase, "Resume", exact=True) or \ diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py new file mode 100644 index 0000000..ed91868 --- /dev/null +++ b/test/unittests/test_ocp.py @@ -0,0 +1,176 @@ +import json +import unittest +from time import sleep + +from mycroft_bus_client import Message +from ovos_utils.messagebus import FakeBus +from unittest.mock import Mock, MagicMock + +from ovos_plugin_common_play import OCP, PlayerState +from ovos_plugin_common_play.ocp.status import MediaType + + +class TestOCP(unittest.TestCase): + bus = FakeBus() + ocp = OCP(bus) + + @classmethod + def setUpClass(cls) -> None: + cls.bus.emitted_msgs = [] + + def get_msg(msg): + msg = json.loads(msg) + msg.pop("context") + cls.bus.emitted_msgs.append(msg) + + cls.bus.on("message", get_msg) + + def test_00_ocp_init(self): + from ovos_plugin_common_play.ocp.player import OCPMediaPlayer + from ovos_plugin_common_play.ocp.gui import OCPMediaPlayerGUI + from ovos_workshop.app import OVOSAbstractApplication + self.assertIsInstance(self.ocp, OVOSAbstractApplication) + self.assertIsInstance(self.ocp.gui, OCPMediaPlayerGUI) + self.assertIsInstance(self.ocp.settings, dict) + self.assertIsInstance(self.ocp.player, OCPMediaPlayer) + self.assertIsNotNone(self.ocp.media_intents) + + # Mock startup events + def _handle_skills_check(msg): + self.bus.emit(msg.response(data={'status': True})) + + self.bus.once('mycroft.skills.is_ready', _handle_skills_check) + self.bus.emit(Message('mycroft.ready')) + + self.assertTrue(self.ocp._intents_event.is_set()) + + def test_ping(self): + resp = self.bus.wait_for_response(Message("ovos.common_play.ping"), + reply_type="ovos.common_play.pong") + self.assertIsInstance(resp, Message) + + def test_handle_home(self): + real_gui_home = self.ocp.gui.show_home + self.ocp.gui.show_home = Mock() + self.ocp.handle_home() + self.ocp.gui.show_home.assert_called_once_with(app_mode=True) + self.ocp.gui.show_home = real_gui_home + + def test_register_ocp_events(self): + # TODO + pass + + def test_register_media_events(self): + # TODO + pass + + def test_replace_mycroft_cps(self): + # TODO + pass + + def test_default_shutdown(self): + # TODO + pass + + def test_classify_media(self): + music = "play some music" + movie = "play a movie" + news = "play the latest news" + unknown = "play something" + + self.assertEqual(self.ocp.classify_media(music), MediaType.MUSIC) + self.assertEqual(self.ocp.classify_media(movie), MediaType.MOVIE) + self.assertEqual(self.ocp.classify_media(news), MediaType.NEWS) + self.assertEqual(self.ocp.classify_media(unknown), MediaType.GENERIC) + + def test_handle_open(self): + real_gui_home = self.ocp.gui.show_home + self.ocp.gui.show_home = Mock() + self.ocp.handle_open(None) + self.ocp.gui.show_home.assert_called_once_with(app_mode=True) + self.ocp.gui.show_home = real_gui_home + + def test_handle_playback_intents(self): + real_player = self.ocp.player + self.ocp.player = MagicMock() + + # next + self.ocp.handle_next(None) + self.ocp.player.play_next.assert_called_once() + + # previous + self.ocp.handle_prev(None) + self.ocp.player.play_prev.assert_called_once() + + # pause + self.ocp.handle_pause(None) + self.ocp.player.pause.assert_called_once() + + # stop + self.ocp.handle_stop() + self.ocp.player.stop.assert_called_once() + + # resume + self.ocp.player.state = PlayerState.PAUSED + self.ocp.handle_resume(None) + self.ocp.player.resume.assert_called_once() + + # resume while playing + self.ocp.player.state = PlayerState.PLAYING + real_get_response = self.ocp.get_response + real_play = self.ocp.handle_play + self.ocp.get_response = Mock(return_value="test") + self.ocp.handle_play = Mock() + + test_message = Message("test") + self.ocp.handle_resume(test_message) + self.ocp.get_response.assert_called_once_with("play.what") + self.ocp.handle_play.assert_called_once_with(test_message) + self.assertEqual(test_message.data['utterance'], 'test') + + self.ocp.handle_play = real_play + self.ocp.get_response = real_get_response + self.ocp.player = real_player + + def test_handle_play(self): + # TODO + pass + + def test_handle_read(self): + # TODO + pass + + def test_do_play(self): + # TODO + pass + + def test_search(self): + # TODO + pass + + def test_should_resume(self): + valid_utt = "resume" + invalid_utt = "test" + empty_utt = "" + + # Playing + self.ocp.player.state = PlayerState.PLAYING + self.assertFalse(self.ocp._should_resume(valid_utt)) + self.assertFalse(self.ocp._should_resume(invalid_utt)) + self.assertFalse(self.ocp._should_resume(empty_utt)) + + # Stopped + self.ocp.player.state = PlayerState.STOPPED + self.assertFalse(self.ocp._should_resume(valid_utt)) + self.assertFalse(self.ocp._should_resume(invalid_utt)) + self.assertFalse(self.ocp._should_resume(empty_utt)) + + # Paused + self.ocp.player.state = PlayerState.PAUSED + self.assertTrue(self.ocp._should_resume(valid_utt)) + self.assertFalse(self.ocp._should_resume(invalid_utt)) + self.assertTrue(self.ocp._should_resume(empty_utt)) + + +if __name__ == "__main__": + unittest.main() From 3c44115451cc283f1b2310591ec11ee49d368f00 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 5 Apr 2023 19:51:04 -0700 Subject: [PATCH 02/22] Update logging and docstrings WIP OCPMediaPlayer tests --- ovos_plugin_common_play/ocp/__init__.py | 2 +- ovos_plugin_common_play/ocp/player.py | 86 ++++-- test/unittests/test_ocp.py | 333 +++++++++++++++++++++++- 3 files changed, 402 insertions(+), 19 deletions(-) diff --git a/ovos_plugin_common_play/ocp/__init__.py b/ovos_plugin_common_play/ocp/__init__.py index 3491313..c63caf5 100644 --- a/ovos_plugin_common_play/ocp/__init__.py +++ b/ovos_plugin_common_play/ocp/__init__.py @@ -247,7 +247,7 @@ def handle_resume(self, message): if self.player.state == PlayerState.PAUSED: self.player.resume() else: - LOG.info("Asked to resume while not paused") + LOG.info(f"Asked to resume while not paused. state={self.player.state}") query = self.get_response("play.what") if query: message.data["utterance"] = query diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index 26f624a..121ce4f 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -1,5 +1,7 @@ import random from os.path import join, dirname +from typing import List + from time import sleep from ovos_utils.gui import is_gui_connected, is_gui_running @@ -50,6 +52,11 @@ def __init__(self, bus=None, settings=None, lang=None, gui=None, self.mpris.bind(self) def bind(self, bus=None): + """ + Initialize components that need a MessageBusClient or instance of this + object. + @param bus: MessageBusClient object to register events on + """ super(OCPMediaPlayer, self).bind(bus) self.now_playing.bind(self) self.media.bind(self) @@ -133,30 +140,48 @@ def register_bus_handlers(self): self.handle_set_app_timeout_mode) @property - def active_skill(self): + def active_skill(self) -> str: + """ + Return the skill_id of the skill providing the current media + """ return self.now_playing.skill_id @property - def active_backend(self): + def active_backend(self) -> PlaybackType: + """ + Return the PlaybackType for the current media + """ return self.now_playing.playback @property - def tracks(self): + def tracks(self) -> List[MediaEntry]: + """ + Return the current queue as a list of MediaEntry objects + """ return self.playlist.entries @property - def disambiguation(self): + def disambiguation(self) -> List[MediaEntry]: + """ + Return a list of the previous search results as MediaEntry objects + """ return self.media.search_playlist.entries @property - def can_prev(self): + def can_prev(self) -> bool: + """ + Return true if there is a previous track in the queue to skip to + """ if self.active_backend != PlaybackType.MPRIS and \ self.playlist.is_first_track: return False return True @property - def can_next(self): + def can_next(self) -> bool: + """ + Return true if there is a next track in the queue to skip to + """ if self.loop_state != LoopState.NONE or \ self.shuffle or \ self.active_backend == PlaybackType.MPRIS: @@ -169,18 +194,33 @@ def can_next(self): return False # state - def set_media_state(self, state): + def set_media_state(self, state: MediaState): + """ + Set self.media_state and emit an event announcing this state change. + @param state: New MediaState + """ + if not isinstance(state, MediaState): + raise TypeError(f"Expected MediaState and got: {state}") if state == self.media_state: return self.media_state = state self.bus.emit(Message("ovos.common_play.media.state", {"state": self.media_state})) - def set_player_state(self, state): + def set_player_state(self, state: PlayerState): + """ + Set self.state, update the GUI and MPRIS (if available), and emit an + event announcing this state change. + @param state: New PlayerState + """ + if not isinstance(state, PlayerState): + raise TypeError(f"Expected PlayerState and got: {state}") if state == self.state: return self.state = state - state2str = {PlayerState.PLAYING: "Playing", PlayerState.PAUSED: "Paused", PlayerState.STOPPED: "Stopped"} + state2str = {PlayerState.PLAYING: "Playing", + PlayerState.PAUSED: "Paused", + PlayerState.STOPPED: "Stopped"} self.gui["status"] = state2str[self.state] if self.mpris: self.mpris.update_props({"CanPause": self.state == PlayerState.PLAYING, @@ -251,6 +291,7 @@ def validate_stream(self): return True def on_invalid_media(self): + LOG.warning(f"Failed to play: {self.now_playing}") self.gui.show_playback_error() self.play_next() @@ -272,22 +313,29 @@ def play_media(self, track, disambiguation=None, playlist=None): self.play() @property - def audio_service_player(self): + def audio_service_player(self) -> str: + """ + Return the configured audio player that is handling playback + """ if not self._audio_backend: self._audio_backend = self._get_prefered_audio_backend() return self._audio_backend def _get_prefered_audio_backend(self): - # NOTE - the bus api tells us what backends are loaded - # however it does not provide "type", so we need to get that from config - # we still hit the messagebus to account for loading failures, - # even if config claims backend is enabled it might not load + """ + Check configuration and available backends to select a preferred backend + + NOTE - the bus api tells us what backends are loaded,however it does not + provide "type", so we need to get that from config we still hit the + messagebus to account for loading failures, even if config claims + backend is enabled it might not load + """ backends = self.audio_service.available_backends() cfg = Configuration()["Audio"]["backends"] available = [k for k, v in backends.items() if cfg[k].get("type", "") != "ovos_common_play"] preferred = self.settings.get("preferred_audio_services") or \ - ["vlc", "mplayer", "simple"] + ["vlc", "mplayer", "simple"] for b in preferred: if b in available: return b @@ -493,9 +541,15 @@ def stop(self): # self.mpris.stop() def stop_gui_player(self): + """ + Emit a Message notifying the gui player to stop + """ self.bus.emit(Message("gui.player.media.service.stop")) def stop_audio_skill(self): + """ + Emit a Message notifying self.active_skill to stop + """ self.bus.emit(Message(f'ovos.common_play.{self.active_skill}.stop')) def stop_audio_service(self): @@ -527,6 +581,8 @@ def handle_player_state_update(self, message): state = message.data.get("state") if state == self.state: return + if state not in PlayerState: + LOG.error(f"Got invalid state requested: {state}") for k in PlayerState: if k == state: LOG.info(f"PlayerState changed: {repr(k)}") diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index ed91868..dceb358 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -1,16 +1,16 @@ import json import unittest -from time import sleep from mycroft_bus_client import Message from ovos_utils.messagebus import FakeBus from unittest.mock import Mock, MagicMock -from ovos_plugin_common_play import OCP, PlayerState -from ovos_plugin_common_play.ocp.status import MediaType +from ovos_plugin_common_play import PlayerState +from ovos_plugin_common_play.ocp.status import MediaType, LoopState, MediaState, PlaybackType class TestOCP(unittest.TestCase): + from ovos_plugin_common_play import OCP bus = FakeBus() ocp = OCP(bus) @@ -44,6 +44,8 @@ def _handle_skills_check(msg): self.assertTrue(self.ocp._intents_event.is_set()) + # TODO: Test messagebus event registration + def test_ping(self): resp = self.bus.wait_for_response(Message("ovos.common_play.ping"), reply_type="ovos.common_play.pong") @@ -172,5 +174,330 @@ def test_should_resume(self): self.assertTrue(self.ocp._should_resume(empty_utt)) +class TestOCPPlayer(unittest.TestCase): + from ovos_plugin_common_play.ocp.player import OCPMediaPlayer + bus = FakeBus() + player = OCPMediaPlayer(bus) + emitted_msgs = [] + + @classmethod + def setUpClass(cls) -> None: + def get_msg(msg): + msg = Message.deserialize(msg) + cls.emitted_msgs.append(msg) + + cls.bus.on("message", get_msg) + + def test_00_player_init(self): + from ovos_plugin_common_play.ocp.gui import OCPMediaPlayerGUI + from ovos_plugin_common_play.ocp.search import OCPSearch + from ovos_plugin_common_play.ocp.media import NowPlaying, Playlist + from ovos_plugin_common_play.ocp.mpris import MprisPlayerCtl + from ovos_plugin_common_play.ocp.mycroft_cps import MycroftAudioService + from ovos_workshop import OVOSAbstractApplication + + self.assertIsInstance(self.player, OVOSAbstractApplication) + self.assertIsInstance(self.player.gui, OCPMediaPlayerGUI) + self.assertIsInstance(self.player.now_playing, NowPlaying) + self.assertIsInstance(self.player.media, OCPSearch) + self.assertIsInstance(self.player.playlist, Playlist) + self.assertIsInstance(self.player.settings, dict) + self.assertIsInstance(self.player.mpris, MprisPlayerCtl) + + self.assertEqual(self.player.state, PlayerState.STOPPED) + self.assertEqual(self.player.loop_state, LoopState.NONE) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + self.assertEqual(self.player.track_history, dict()) + self.assertFalse(self.player.shuffle) + # self.assertIsNone(self.player.audio_service) + + # Testing `bind` method + self.assertEqual(self.player.now_playing._player, self.player) + self.assertEqual(self.player.media._player, self.player) + self.assertEqual(self.player.gui.player, self.player) + self.assertIsInstance(self.player.audio_service, MycroftAudioService) + + # TODO: Test messagebus event registration + + # Test properties + self.assertEqual(self.player.active_skill, "ovos.common_play") + self.assertEqual(self.player.active_backend, PlaybackType.UNDEFINED) + self.assertEqual(self.player.tracks, list()) + self.assertEqual(self.player.disambiguation, list()) + self.assertFalse(self.player.can_prev) + self.assertFalse(self.player.can_next) + self.assertIsInstance(self.player.audio_service_player, str) + self.assertIsInstance(self.player.app_view_timeout_enabled, bool) + self.assertIsInstance(self.player.app_view_timeout_value, int) + self.assertIsInstance(self.player.app_view_timeout_mode, str) + + def test_set_media_state(self): + self.player.set_media_state(MediaState.UNKNOWN) + + # Emitted update on state change + self.player.set_media_state(MediaState.NO_MEDIA) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, "ovos.common_play.media.state") + self.assertEqual(last_message.data, {"state": MediaState.NO_MEDIA}) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + + # No emit on same state + self.player.set_media_state(MediaState.NO_MEDIA) + self.assertEqual(last_message, self.emitted_msgs[-1]) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + + # Test invalid state change + with self.assertRaises(TypeError): + self.player.set_media_state(1) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + + def test_set_player_state(self): + real_update_props = self.player.mpris.update_props + self.player.mpris.update_props = Mock() + self.player.set_player_state(PlayerState.STOPPED) + + # Change to "Playing" + self.player.set_player_state(PlayerState.PLAYING) + self.assertEqual(self.player.state, PlayerState.PLAYING) + self.assertEqual(self.player.gui["status"], "Playing") + self.player.mpris.update_props.assert_called_with( + {"CanPause": True, "CanPlay": False, "PlaybackStatus": "Playing"}) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, "ovos.common_play.player.state") + self.assertEqual(last_message.data, {"state": PlayerState.PLAYING}) + + # Change to "Paused" + self.player.set_player_state(PlayerState.PAUSED) + self.assertEqual(self.player.state, PlayerState.PAUSED) + self.assertEqual(self.player.gui["status"], "Paused") + self.player.mpris.update_props.assert_called_with( + {"CanPause": False, "CanPlay": True, "PlaybackStatus": "Paused"}) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, "ovos.common_play.player.state") + self.assertEqual(last_message.data, {"state": PlayerState.PAUSED}) + + # Change to "Stopped" + self.player.set_player_state(PlayerState.STOPPED) + self.assertEqual(self.player.state, PlayerState.STOPPED) + self.assertEqual(self.player.gui["status"], "Stopped") + self.player.mpris.update_props.assert_called_with( + {"CanPause": False, "CanPlay": False, "PlaybackStatus": "Stopped"}) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, "ovos.common_play.player.state") + self.assertEqual(last_message.data, {"state": PlayerState.STOPPED}) + + # Request invalid change + with self.assertRaises(TypeError): + self.player.set_player_state("Paused") + self.assertEqual(last_message, self.emitted_msgs[-1]) + self.assertEqual(self.player.state, PlayerState.STOPPED) + + with self.assertRaises(TypeError): + self.player.set_player_state(2) + self.assertEqual(last_message, self.emitted_msgs[-1]) + self.assertEqual(self.player.state, PlayerState.STOPPED) + + self.player.mpris.update_props = real_update_props + + def test_set_now_playing(self): + # TODO + pass + + def test_validate_stream(self): + # TODO + pass + + def test_on_invalid_media(self): + # TODO + pass + + def test_play_media(self): + # TODO + pass + + def test_get_preferred_audio_backend(self): + # TODO + pass + + def test_play(self): + # TODO + pass + + def test_play_shuffle(self): + # TODO + pass + + def test_play_next(self): + # TODO + pass + + def test_play_prev(self): + # TODO + pass + + def test_pause(self): + # TODO + pass + + def test_resume(self): + # TODO + pass + + def test_seek(self): + # TODO + pass + + def test_stop(self): + # TODO + pass + + def test_stop_gui_player(self): + self.player.stop_gui_player() + message = self.emitted_msgs[-1] + self.assertEqual(message.msg_type, "gui.player.media.service.stop") + + def test_stop_audio_skill(self): + self.player.stop_audio_skill() + message = self.emitted_msgs[-1] + self.assertEqual(message.msg_type, + f"ovos.common_play.{self.player.active_skill}.stop") + + def test_stop_audio_service(self): + real_stop = self.player.audio_service.stop + self.player.audio_service.stop = Mock() + self.player.stop_audio_service() + self.player.audio_service.stop.assert_called_once() + + self.player.audio_service.stop = real_stop + + def test_reset(self): + # TODO + pass + + def test_shutdown(self): + # TODO + pass + + def test_handle_player_state_update(self): + # TODO + pass + + def test_handle_player_media_update(self): + # TODO + pass + + def test_handle_invalid_media(self): + # TODO + pass + + def test_handle_playback_ended(self): + # TODO + pass + + def test_handle_play_request(self): + # TODO + pass + + def test_handle_pause_request(self): + # TODO + pass + + def test_handle_stop_request(self): + # TODO + pass + + def test_handle_resume_request(self): + # TODO + pass + + def test_handle_seek_request(self): + # TODO + pass + + def test_handle_next_request(self): + # TODO + pass + + def test_handle_prev_request(self): + # TODO + pass + + def test_handle_set_shuffle(self): + # TODO + pass + + def test_handle_unset_shuffle(self): + # TODO + pass + + def test_handle_set_repeat(self): + # TODO + pass + + def test_handle_unset_repeat(self): + # TODO + pass + + def test_handle_repeat_toggle_request(self): + # TODO + pass + + def test_handle_shuffle_toggle_request(self): + # TODO + pass + + def test_handle_playlist_set_request(self): + # TODO + pass + + def test_handle_playlist_queue_request(self): + # TODO + pass + + def test_handle_playlist_clear_request(self): + # TODO + pass + + def test_handle_duck_request(self): + # TODO + pass + + def test_handle_unduck_request(self): + # TODO + pass + + def test_handle_track_length_request(self): + # TODO + pass + + def test_handle_track_position_request(self): + # TODO + pass + + def test_handle_set_track_position_request(self): + # TODO + pass + + def test_handle_track_info_request(self): + # TODO + pass + + def test_handle_list_backends_request(self): + # TODO + pass + + def test_handle_enable_app_timeout(self): + # TODO + pass + + def test_handle_set_app_timeout(self): + # TODO + pass + + def test_handle_set_app_timeout_mode(self): + # TODO + pass + + if __name__ == "__main__": unittest.main() From 9bd64d3d95ce4393ad50a0a09479c6bfb31cde4a Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 6 Apr 2023 14:48:13 -0700 Subject: [PATCH 03/22] More unit tests and documentation/code cleanup of player playback methods Raise an exception if trying to get a stream from nothing --- ovos_plugin_common_play/ocp/media.py | 2 + ovos_plugin_common_play/ocp/player.py | 124 +++++---- test/unittests/test_ocp.py | 345 ++++++++++++++++++++++++-- 3 files changed, 410 insertions(+), 61 deletions(-) diff --git a/ovos_plugin_common_play/ocp/media.py b/ovos_plugin_common_play/ocp/media.py index 76ff98a..8690830 100644 --- a/ovos_plugin_common_play/ocp/media.py +++ b/ovos_plugin_common_play/ocp/media.py @@ -316,6 +316,8 @@ def update(self, entry, skipkeys=None, newonly=False): def extract_stream(self): uri = self.uri + if not uri: + raise ValueError("No URI to extract stream from") if self.playback == PlaybackType.VIDEO: video = True else: diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index 121ce4f..b8097f2 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -1,6 +1,6 @@ import random from os.path import join, dirname -from typing import List +from typing import List, Union from time import sleep @@ -35,7 +35,7 @@ def __init__(self, bus=None, settings=None, lang=None, gui=None, self.shuffle = False self.audio_service = None self._audio_backend = None - self.track_history = {} + self.track_history = {} # Dict of track URI to play count super().__init__(OCP_ID, bus=bus, gui=gui, resources_dir=resources_dir, lang=lang) @@ -229,23 +229,28 @@ def set_player_state(self, state: PlayerState): self.bus.emit(Message("ovos.common_play.player.state", {"state": self.state})) - def set_now_playing(self, track): - """ Currently playing media """ + def set_now_playing(self, track: Union[dict, MediaEntry]): + """ + Set `track` as the currently playing media, update the playlist, and + notify any GUI or MPRIS clients. + @param track: MediaEntry or dict representation of a MediaEntry to play + """ LOG.debug(f"Playing: {track}") - if (isinstance(track, dict) and track.get("uri")) or \ - (isinstance(track, MediaEntry) and track.uri): - # single track entry (dict) + if isinstance(track, dict): + LOG.debug("Handling dict track") + track = MediaEntry.from_dict(track) + if not isinstance(track, MediaEntry): + raise ValueError(f"Expected MediaEntry, but got: {track}") + if track.uri: + # single track entry (MediaEntry) self.now_playing.update(track) # copy now_playing (without event handlers) to playlist - entry = self.now_playing.as_entry() - if entry not in self.playlist: # compared by uri - self.playlist.add_entry(entry) - else: + # entry = self.now_playing.as_entry() + if track not in self.playlist: # compared by uri + self.playlist.add_entry(track) + elif track.data.get("playlist"): # this is a playlist result (list of dicts) - if isinstance(track, MediaEntry): - pl = track.data.get("playlist") - else: - pl = track.get("playlist") or track.get("data", {}).get("playlist") + pl = track.data.get("playlist") if pl: self.playlist.clear() for entry in pl: @@ -254,7 +259,10 @@ def set_now_playing(self, track): if len(self.playlist): self.now_playing.update(self.playlist[0]) else: + # TODO This is a track with no URI; why should we try to play it self.now_playing.update(track) + else: + raise ValueError(f"Requested track has no URI: {track}") # sync playlist position self.playlist.goto_track(self.now_playing) @@ -268,7 +276,11 @@ def set_now_playing(self, track): ) # stream handling - def validate_stream(self): + def validate_stream(self) -> bool: + """ + Validate that self.now_playing is playable and update the GUI if it is + @return: True if the `now_playing` stream can be handled + """ if self.now_playing.is_cps: self.now_playing.playback = PlaybackType.SKILL @@ -291,12 +303,27 @@ def validate_stream(self): return True def on_invalid_media(self): + """ + Handle media playback errors. Show an error and play the next track. + """ LOG.warning(f"Failed to play: {self.now_playing}") self.gui.show_playback_error() self.play_next() # media controls - def play_media(self, track, disambiguation=None, playlist=None): + def play_media(self, track: Union[dict, MediaEntry], + disambiguation: List[Union[dict, MediaEntry]] = None, + playlist: List[Union[dict, MediaEntry]] = None): + """ + Start playing the requested media, replacing any current playback. + @param track: dict or MediaEntry to start playing + @param disambiguation: list of tracks returned from search + @param playlist: list of tracks in the current playlist + """ + if isinstance(track, dict): + track = MediaEntry.from_dict(track) + if not isinstance(track, MediaEntry): + raise TypeError(f"Expected MediaEntry, got: {track}") if self.mpris: self.mpris.stop() if self.state == PlayerState.PLAYING: @@ -318,10 +345,10 @@ def audio_service_player(self) -> str: Return the configured audio player that is handling playback """ if not self._audio_backend: - self._audio_backend = self._get_prefered_audio_backend() + self._audio_backend = self._get_preferred_audio_backend() return self._audio_backend - def _get_prefered_audio_backend(self): + def _get_preferred_audio_backend(self): """ Check configuration and available backends to select a preferred backend @@ -343,43 +370,50 @@ def _get_prefered_audio_backend(self): return "simple" def play(self): + """ + Start playback of the current `now_playing` MediaEntry. Displays the GUI + player, updates track history, emits events for any listeners, and + updates mpris (if configured). + """ # stop any external media players - if self.mpris: + if self.mpris and not self.mpris.stop_event.is_set(): + LOG.info("Requested playback with mpris not stopped") self.mpris.stop() # validate new stream # TODO buffering animation ? if not self.validate_stream(): - # TODO error animation + LOG.warning("Stream Validation Failed") self.on_invalid_media() return self.gui.show_player() - if self.now_playing.uri not in self.track_history: - self.track_history[self.now_playing.uri] = 0 + self.track_history.setdefault(self.now_playing.uri, 0) self.track_history[self.now_playing.uri] += 1 - if self.active_backend in [PlaybackType.AUDIO, - PlaybackType.AUDIO_SERVICE]: - LOG.debug("Requesting playback: PlaybackType.AUDIO") - if self.active_backend == PlaybackType.AUDIO_SERVICE: - LOG.debug("Handling playback via audio_service") - # we explicitly want to use a audio backend for audio only output - self.audio_service.play(self.now_playing.uri, - utterance=self.audio_service_player) - self.bus.emit(Message("ovos.common_play.track.state", { - "state": TrackState.PLAYING_AUDIOSERVICE})) - self.set_player_state(PlayerState.PLAYING) - elif is_gui_running(): - LOG.debug("Handling playback via gui") - # handle audio natively in mycroft-gui - sleep(2) # wait for gui page to start or this is sent before page - self.bus.emit(Message("gui.player.media.service.play", { - "track": self.now_playing.uri, - "mime": self.now_playing.mimetype, - "repeat": False})) - sleep(0.2) # wait for the above message to be processed - self.bus.emit(Message("ovos.common_play.track.state", { - "state": TrackState.PLAYING_AUDIO})) + LOG.debug(f"Requesting playback: {self.active_backend}") + if self.active_backend == PlaybackType.AUDIO and not is_gui_running(): + LOG.warning("Requested Audio playback via GUI without GUI. " + "Choosing Audio Service") + self.now_playing.playback = PlaybackType.AUDIO_SERVICE + if self.active_backend == PlaybackType.AUDIO_SERVICE: + LOG.debug("Handling playback via audio_service") + # we explicitly want to use an audio backend for audio only output + self.audio_service.play(self.now_playing.uri, + utterance=self.audio_service_player) + self.bus.emit(Message("ovos.common_play.track.state", { + "state": TrackState.PLAYING_AUDIOSERVICE})) + self.set_player_state(PlayerState.PLAYING) + elif self.active_backend == PlaybackType.AUDIO: + LOG.debug("Handling playback via gui") + # handle audio natively in mycroft-gui + sleep(2) # wait for gui page to start or this is sent before page + self.bus.emit(Message("gui.player.media.service.play", { + "track": self.now_playing.uri, + "mime": self.now_playing.mimetype, + "repeat": False})) + sleep(0.2) # wait for the above message to be processed + self.bus.emit(Message("ovos.common_play.track.state", { + "state": TrackState.PLAYING_AUDIO})) elif self.active_backend == PlaybackType.SKILL: LOG.debug("Requesting playback: PlaybackType.SKILL") if self.now_playing.is_cps: # mycroft-core compat layer diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index dceb358..a4e1738 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -1,12 +1,41 @@ import json import unittest +from threading import Event from mycroft_bus_client import Message from ovos_utils.messagebus import FakeBus -from unittest.mock import Mock, MagicMock +from unittest.mock import Mock, MagicMock, patch from ovos_plugin_common_play import PlayerState -from ovos_plugin_common_play.ocp.status import MediaType, LoopState, MediaState, PlaybackType +from ovos_plugin_common_play.ocp.media import MediaEntry +from ovos_plugin_common_play.ocp.status import MediaType, LoopState, MediaState, PlaybackType, TrackState + +valid_search_results = [ + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/07_-_Quantum_Jazz_-_Orbiting_A_Distant_Planet/stream/', + 'title': 'Orbiting A Distant Planet', + 'artist': 'Quantum Jazz', + 'match_confidence': 65}, + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/05_-_Quantum_Jazz_-_Passing_Fields/stream/', + 'title': 'Passing Fields', + 'artist': 'Quantum Jazz', + 'match_confidence': 65}, + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/04_-_Quantum_Jazz_-_All_About_The_Sun/stream/', + 'title': 'All About The Sun', + 'artist': 'Quantum Jazz', + 'match_confidence': 65} +] class TestOCP(unittest.TestCase): @@ -300,28 +329,312 @@ def test_set_player_state(self): self.player.mpris.update_props = real_update_props def test_set_now_playing(self): - # TODO - pass + real_update_props = self.player.mpris.update_props + real_update_track = self.player.gui.update_current_track + real_update_plist = self.player.gui.update_playlist + self.player.mpris.update_props = Mock() + self.player.gui.update_current_track = Mock() + self.player.gui.update_playlist = Mock() + + valid_dict = valid_search_results[0] + valid_track = MediaEntry.from_dict(valid_search_results[1]) + invalid_str = json.dumps(valid_search_results[2]) + invalid_no_uri = valid_search_results[2] + invalid_no_uri.pop('uri') + # TODO: Test playlist result + + # Play valid dict result + self.player.set_now_playing(valid_dict) + entry = MediaEntry.from_dict(valid_dict) + # self.assertEqual(self.player.now_playing.as_dict, valid_dict) + self.assertEqual(self.player.now_playing.as_entry(), entry) + self.assertEqual(self.player.playlist.current_track, entry) + self.assertEqual(self.player.playlist[-1], entry) + self.player.gui.update_current_track.assert_called_once() + self.player.gui.update_playlist.assert_called_once() + self.player.mpris.update_props.assert_called_once_with( + {"Metadata": self.player.now_playing.mpris_metadata} + ) + self.player.gui.update_current_track.reset_mock() + self.player.gui.update_playlist.reset_mock() + self.player.mpris.update_props.reset_mock() + + # Play valid MediaEntry result + self.player.set_now_playing(valid_track) + self.assertEqual(self.player.now_playing.as_entry(), valid_track) + self.assertEqual(self.player.playlist.current_track, valid_track) + self.assertEqual(self.player.playlist[-1], valid_track) + self.player.gui.update_current_track.assert_called_once() + self.player.gui.update_playlist.assert_called_once() + self.player.mpris.update_props.assert_called_once_with( + {"Metadata": self.player.now_playing.mpris_metadata} + ) + self.player.gui.update_current_track.reset_mock() + self.player.gui.update_playlist.reset_mock() + self.player.mpris.update_props.reset_mock() + + # Play invalid string result + with self.assertRaises(ValueError): + self.player.set_now_playing(invalid_str) + self.player.gui.update_current_track.assert_not_called() + self.player.gui.update_playlist.assert_not_called() + self.player.mpris.update_props.assert_not_called() + + # Play result with no URI + with self.assertRaises(ValueError): + self.player.set_now_playing(invalid_no_uri) + self.player.gui.update_current_track.assert_not_called() + self.player.gui.update_playlist.assert_not_called() + self.player.mpris.update_props.assert_not_called() - def test_validate_stream(self): - # TODO - pass + self.player.mpris.update_props = real_update_props + self.player.gui.update_current_track = real_update_track + self.player.gui.update_playlist = real_update_plist + + @patch("ovos_plugin_common_play.ocp.player.is_gui_running") + def test_validate_stream(self, gui_running): + real_update = self.player.gui.update_current_track + self.player.gui.update_current_track = Mock() + media_entry = MediaEntry.from_dict(valid_search_results[0]) + invalid_result = valid_search_results[1] + invalid_result.pop('uri') + invalid_entry = MediaEntry.from_dict(invalid_result) + + # Valid Entry + self.player.now_playing.update(media_entry) + + self.assertFalse(self.player.now_playing.is_cps) + self.assertEqual(self.player.now_playing.playback, + PlaybackType.AUDIO) + self.assertEqual(self.player.active_backend, PlaybackType.AUDIO) + + # Test with GUI + gui_running.return_value = True + self.assertTrue(self.player.validate_stream()) + self.assertEqual(self.player.gui["stream"], media_entry.uri) + self.player.gui.update_current_track.assert_called_once() + self.assertEqual(self.player.now_playing.playback, + PlaybackType.AUDIO) + + # Invalid Entry + self.player.now_playing.update(invalid_entry) + self.assertFalse(self.player.validate_stream()) + self.assertEqual(self.player.gui["stream"], media_entry.uri) + self.player.gui.update_current_track.assert_called_once() + + # Test without GUI + gui_running.return_value = False + self.player.gui.update_current_track.reset_mock() + self.player.gui["stream"] = None + self.player.now_playing.update(media_entry) + self.assertTrue(self.player.validate_stream()) + self.assertEqual(self.player.gui["stream"], media_entry.uri) + self.player.gui.update_current_track.assert_called_once() + self.assertEqual(self.player.now_playing.playback, + PlaybackType.AUDIO_SERVICE) + + # TODO: Test Skill playback and non-audio playback + self.player.gui.update_current_track = real_update def test_on_invalid_media(self): - # TODO - pass + real_play_next = self.player.play_next + real_show_error = self.player.gui.show_playback_error + self.player.play_next = Mock() + self.player.gui.show_playback_error = Mock() + + self.player.on_invalid_media() + self.player.play_next.assert_called_once() + self.player.gui.show_playback_error.assert_called_once() + + self.player.play_next = real_play_next + self.player.gui.show_playback_error = real_show_error def test_play_media(self): - # TODO - pass + real_stop = self.player.mpris.stop + real_pause = self.player.pause + real_gui_update = self.player.gui.update_search_results + real_play = self.player.play + real_set_now_playing = self.player.set_now_playing + self.player.mpris.stop = Mock() + self.player.pause = Mock() + self.player.gui.update_search_results = Mock() + self.player.play = Mock() + self.player.set_now_playing = Mock() + + results_as_entries = [MediaEntry.from_dict(d) + for d in valid_search_results] + results_as_entries.sort(key=lambda k: k.match_confidence, reverse=True) + + # Test invalid track + with self.assertRaises(TypeError): + self.player.play_media(valid_search_results) + with self.assertRaises(TypeError): + self.player.play_media(json.dumps(valid_search_results[0])) + + # Test track only + self.player.state = PlayerState.STOPPED + track = MediaEntry.from_dict(valid_search_results[0]) + self.player.play_media(track) + self.player.mpris.stop.assert_called_once() + self.player.pause.assert_not_called() + self.assertEqual(self.player.media.search_playlist.entries, list()) + self.player.gui.update_search_results.assert_not_called() + self.assertEqual(self.player.playlist.entries, list()) + self.player.set_now_playing.assert_called_once_with(track) + self.player.play.assert_called_once() + + self.player.mpris.stop.reset_mock() + self.player.set_now_playing.reset_mock() + self.player.play.reset_mock() + + # Test track with disambiguation + self.player.state = PlayerState.PAUSED + track = MediaEntry.from_dict(valid_search_results[0]) + self.player.play_media(track, valid_search_results) + self.player.mpris.stop.assert_called_once() + self.player.pause.assert_not_called() + + self.assertEqual(self.player.media.search_playlist.entries, + results_as_entries) + self.player.gui.update_search_results.assert_called_once() + self.assertEqual(self.player.playlist.entries, list()) + self.player.set_now_playing.assert_called_once_with(track) + self.player.play.assert_called_once() + + self.player.mpris.stop.reset_mock() + self.player.set_now_playing.reset_mock() + self.player.play.reset_mock() + self.player.gui.update_search_results.reset_mock() + + # Test track with playlist + self.player.state = PlayerState.PLAYING + self.player.media.search_playlist.clear() + track = MediaEntry.from_dict(valid_search_results[0]) + self.player.play_media(track, playlist=valid_search_results) + self.player.mpris.stop.assert_called_once() + self.player.pause.assert_called_once() + + self.assertEqual(self.player.media.search_playlist.entries, list()) + self.player.gui.update_search_results.assert_not_called() + self.assertEqual(self.player.playlist.entries, results_as_entries) + self.assertEqual(self.player.playlist.current_track, track) + self.player.set_now_playing.assert_called_once_with(track) + self.player.play.assert_called_once() + + self.player.set_now_playing = real_set_now_playing + self.player.play = real_play + self.player.gui.update_search_results = real_gui_update + self.player.pause = real_pause + self.player.mpris.stop = real_stop def test_get_preferred_audio_backend(self): - # TODO - pass + preferred = self.player._get_preferred_audio_backend() + self.assertIsInstance(preferred, str) + self.assertIn(preferred, + ["ovos_common_play", "vlc", "mplayer", "simple"]) + + @patch("ovos_plugin_common_play.ocp.player.is_gui_running") + def test_play(self, gui_running): + gui_running.return_value = True + real_update_props = self.player.mpris.update_props + real_stop = self.player.mpris.stop + real_validate_stream = self.player.validate_stream + real_show_player = self.player.gui.show_player + real_invalid = self.player.on_invalid_media + real_player_state = self.player.set_player_state + real_audio_service_play = self.player.audio_service.play + self.player.mpris.update_props = Mock() + self.player.validate_stream = Mock(return_value=False) + mpris_stop = self.player.mpris.stop_event + self.player.mpris.stop = Mock() + self.player.gui.show_player = Mock() + self.player.on_invalid_media = Mock() + self.player.track_history = dict() + self.player.set_player_state = Mock() + self.player.audio_service.play = Mock() + + # Test invalid stream + self.player.play() + self.player.mpris.stop.assert_called_once() + self.player.validate_stream.assert_called_once() + self.player.on_invalid_media.assert_called_once() + self.player.gui.show_player.assert_not_called() + + self.player.validate_stream.reset_mock() + self.player.validate_stream.return_value = True + + # Test invalid backend + self.player.now_playing.playback = PlaybackType.UNDEFINED + mpris_stop.set() + with self.assertRaises(ValueError): + self.player.play() + self.player.validate_stream.assert_called_once() + self.player.mpris.update_props.assert_not_called() + + # TODO: Should the GUI be displayed and track history updated for + # invalid playback requests? + self.player.gui.show_player.assert_called_once() + self.assertEqual(set(self.player.track_history.keys()), {''}) + + self.player.gui.show_player.reset_mock() + self.player.validate_stream.reset_mock() + + # Test valid audio with gui + media = MediaEntry.from_dict(valid_search_results[0]) + media.playback = PlaybackType.AUDIO + self.player.now_playing = media + mpris_stop.set() + self.player.play() + self.player.mpris.stop.assert_called_once() + self.player.validate_stream.assert_called_once() + self.player.on_invalid_media.assert_called_once() + self.player.gui.show_player.assert_called_once() + self.assertEqual(set(self.player.track_history.keys()), {'', media.uri}) + self.assertEqual(self.player.track_history[media.uri], 1) + last_message = self.emitted_msgs[-1] + second_last_message = self.emitted_msgs[-2] + self.assertEqual(last_message.msg_type, "ovos.common_play.track.state") + self.assertEqual(last_message.data, {"state": TrackState.PLAYING_AUDIO}) + self.assertEqual(second_last_message.msg_type, + "gui.player.media.service.play") + self.assertEqual(second_last_message.data, + {"track": media.uri, "mime": list(media.mimetype), + "repeat": False}) + + self.player.mpris.stop.reset_mock() + self.player.validate_stream.reset_mock() + self.player.gui.show_player.reset_mock() + + # Test valid audio without gui (AudioService + gui_running.return_value = False + self.player.mpris.stop_event.clear() + self.player.play() + self.player.mpris.stop.assert_called_once() + self.player.validate_stream.assert_called_once() + self.player.on_invalid_media.assert_called_once() + self.player.gui.show_player.assert_called_once() + self.assertEqual(set(self.player.track_history.keys()), {'', media.uri}) + self.assertEqual(self.player.track_history[media.uri], 2) + self.assertEqual(self.player.active_backend, PlaybackType.AUDIO_SERVICE) + self.assertEqual(media.playback, PlaybackType.AUDIO_SERVICE) + self.player.set_player_state.assert_called_once_with( + PlayerState.PLAYING) + self.player.audio_service.play.assert_called_once_with( + media.uri, utterance=self.player.audio_service_player) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, "ovos.common_play.track.state") + self.assertEqual(last_message.data, + {"state": TrackState.PLAYING_AUDIOSERVICE}) - def test_play(self): - # TODO - pass + # TODO: Test Skill, Video, Webview + + self.player.on_invalid_media = real_invalid + self.player.gui.show_player = real_show_player + self.player.mpris.stop = real_stop + self.player.validate_stream = real_validate_stream + self.player.mpris.update_props = real_update_props + self.player.set_player_state = real_player_state + self.player.audio_service.play = real_audio_service_play def test_play_shuffle(self): # TODO From b572e2213122353dff1141e1bd29170c4d1ed82c Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 6 Apr 2023 16:15:08 -0700 Subject: [PATCH 04/22] Add tests and documentation for pause, resume, seek, and reset methods Add init tests for registered events Fixes #22 with unit tests for duck/unduck calling pause/resume --- ovos_plugin_common_play/ocp/player.py | 85 +++++++-- test/unittests/test_ocp.py | 247 ++++++++++++++++++++++++-- 2 files changed, 301 insertions(+), 31 deletions(-) diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index b8097f2..b25f944 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -26,13 +26,13 @@ def __init__(self, bus=None, settings=None, lang=None, gui=None, gui = gui or OCPMediaPlayerGUI() # Define things referenced in `bind` - self.now_playing = NowPlaying() - self.media = OCPSearch() - self.state = PlayerState.STOPPED - self.loop_state = LoopState.NONE - self.media_state = MediaState.NO_MEDIA - self.playlist = Playlist() - self.shuffle = False + self.now_playing: NowPlaying = NowPlaying() + self.media: OCPSearch = OCPSearch() + self.state: PlayerState = PlayerState.STOPPED + self.loop_state: LoopState = LoopState.NONE + self.media_state: MediaState = MediaState.NO_MEDIA + self.playlist: Playlist = Playlist() + self.shuffle: bool = False self.audio_service = None self._audio_backend = None self.track_history = {} # Dict of track URI to play count @@ -42,6 +42,8 @@ def __init__(self, bus=None, settings=None, lang=None, gui=None, if settings: self.settings.merge(settings) + self._paused_on_duck = False + # mpris settings manage_players = self.settings.get("manage_external_players", False) if self.settings.get('disable_mpris'): @@ -136,7 +138,7 @@ def register_bus_handlers(self): self.handle_enable_app_timeout) self.add_event('ovos.common_play.gui.set_app_timeout', self.handle_set_app_timeout) - self.add_event('ovos.common_play.gui.timeout.mode', + self.add_event('ovos.common_play.gui.timeout.mode', self.handle_set_app_timeout_mode) @property @@ -449,8 +451,12 @@ def play(self): self.mpris.update_props({"CanGoPrevious": self.can_prev}) def play_shuffle(self): + """ + Go to a random position in the playlist and play that MediaEntry. + """ LOG.debug("Shuffle == True") if len(self.playlist) > 1 and not self.playlist.is_last_track: + # TODO: does the 'last track' matter in this case? self.playlist.set_position(random.randint(0, len(self.playlist))) self.set_now_playing(self.playlist.current_track) else: @@ -458,12 +464,18 @@ def play_shuffle(self): self.set_now_playing(self.media.search_playlist.current_track) def play_next(self): + """ + Play the next track in the playlist. + If there is no next track, end playback. + """ if self.active_backend in [PlaybackType.MPRIS]: if self.mpris: self.mpris.play_next() return - elif self.active_backend in [PlaybackType.SKILL, PlaybackType.UNDEFINED]: - self.bus.emit(Message(f'ovos.common_play.{self.now_playing.skill_id}.next')) + elif self.active_backend in [PlaybackType.SKILL, + PlaybackType.UNDEFINED]: + self.bus.emit(Message( + f'ovos.common_play.{self.now_playing.skill_id}.next')) return self.pause() # make more responsive @@ -480,7 +492,8 @@ def play_next(self): while self.media.search_playlist.current_track in self.playlist: self.media.search_playlist.next_track() self.set_now_playing(self.media.search_playlist.current_track) - LOG.info(f"Next search index: {self.media.search_playlist.position}") + LOG.info(f"Next search index: " + f"{self.media.search_playlist.position}") else: if self.loop_state == LoopState.REPEAT and len(self.playlist): LOG.debug("end of playlist, repeat == True") @@ -492,16 +505,23 @@ def play_next(self): self.play() def play_prev(self): + """ + Play the previous track in the playlist. + If there is no previous track, do nothing. + """ if self.active_backend in [PlaybackType.MPRIS]: if self.mpris: self.mpris.play_prev() return - elif self.active_backend in [PlaybackType.SKILL, PlaybackType.UNDEFINED]: - self.bus.emit(Message(f'ovos.common_play.{self.now_playing.skill_id}.prev')) + elif self.active_backend in [PlaybackType.SKILL, + PlaybackType.UNDEFINED]: + self.bus.emit(Message( + f'ovos.common_play.{self.now_playing.skill_id}.prev')) return self.pause() # make more responsive if self.shuffle: + # TODO: Should skipping back get a random track instead of previous? self.play_shuffle() elif not self.playlist.is_first_track: self.playlist.prev_track() @@ -512,6 +532,9 @@ def play_prev(self): LOG.debug("requested previous, but already in 1st track") def pause(self): + """ + Ask the current playback to pause. + """ LOG.debug(f"Pausing playback: {self.active_backend}") if self.active_backend in [PlaybackType.AUDIO_SERVICE, PlaybackType.UNDEFINED]: @@ -527,8 +550,12 @@ def pause(self): if self.active_backend in [PlaybackType.MPRIS] and self.mpris: self.mpris.pause() self.set_player_state(PlayerState.PAUSED) + self._paused_on_duck = False def resume(self): + """ + Ask any paused or stopped playback to resume. + """ LOG.debug(f"Resuming playback: {self.active_backend}") if self.active_backend in [PlaybackType.AUDIO_SERVICE, PlaybackType.UNDEFINED]: @@ -548,13 +575,20 @@ def resume(self): self.set_player_state(PlayerState.PLAYING) - def seek(self, position): + def seek(self, position: int): + """ + Request playback to go to a specific position in the current media + @param position: milliseconds position to seek to + """ if self.active_backend in [PlaybackType.AUDIO_SERVICE, PlaybackType.UNDEFINED]: self.audio_service.set_track_position(position / 1000) self.gui["position"] = position def stop(self): + """ + Request stopping current playback and searching + """ # stop any search still happening self.bus.emit(Message("ovos.common_play.search.stop")) @@ -587,9 +621,15 @@ def stop_audio_skill(self): self.bus.emit(Message(f'ovos.common_play.{self.active_skill}.stop')) def stop_audio_service(self): + """ + Call self.audio_service.stop() + """ self.audio_service.stop() def reset(self): + """ + Reset this instance to clear any media or settings + """ self.stop() self.playlist.clear() self.media.clear() @@ -598,6 +638,9 @@ def reset(self): self.loop_state = LoopState.NONE def shutdown(self): + """ + Shutdown this instance and its spawned objects. Remove events. + """ self.stop() if self.mpris: self.mpris.shutdown() @@ -759,12 +802,22 @@ def handle_playlist_clear_request(self, message): # audio ducking def handle_duck_request(self, message): + """ + Pause audio on 'recognizer_loop:record_begin' + @param message: Message associated with event + """ if self.state == PlayerState.PLAYING: self.pause() + self._paused_on_duck = True def handle_unduck_request(self, message): - if self.state == PlayerState.PAUSED: + """ + Resume paused audio on 'recognizer_loop:record_begin' + @param message: Message associated with event + """ + if self.state == PlayerState.PAUSED and self._paused_on_duck: self.resume() + self._paused_on_duck = False # track data def handle_track_length_request(self, message): @@ -826,4 +879,4 @@ def handle_set_app_timeout_mode(self, message): self.settings["app_view_timeout_mode"] = message.data.get("mode", "all") self.settings.store() self.gui["app_view_timeout_mode"] = self.settings.get("app_view_timeout_mode", "all") - self.gui.cancel_app_view_timeout() \ No newline at end of file + self.gui.cancel_app_view_timeout() diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index a4e1738..378b0b9 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -246,7 +246,43 @@ def test_00_player_init(self): self.assertEqual(self.player.gui.player, self.player) self.assertIsInstance(self.player.audio_service, MycroftAudioService) - # TODO: Test messagebus event registration + bus_events = ['recognizer_loop:record_begin', + 'recognizer_loop:record_end', + 'gui.player.media.service.sync.status', + "gui.player.media.service.get.next", + "gui.player.media.service.get.previous", + "gui.player.media.service.get.repeat", + "gui.player.media.service.get.shuffle", + 'ovos.common_play.player.state', + 'ovos.common_play.media.state', + 'ovos.common_play.play', + 'ovos.common_play.pause', + 'ovos.common_play.resume', + 'ovos.common_play.stop', + 'ovos.common_play.next', + 'ovos.common_play.previous', + 'ovos.common_play.seek', + 'ovos.common_play.get_track_length', + 'ovos.common_play.set_track_position', + 'ovos.common_play.get_track_position', + 'ovos.common_play.track_info', + 'ovos.common_play.list_backends', + 'ovos.common_play.playlist.set', + 'ovos.common_play.playlist.clear', + 'ovos.common_play.playlist.queue', + 'ovos.common_play.duck', + 'ovos.common_play.unduck', + 'ovos.common_play.shuffle.set', + 'ovos.common_play.shuffle.unset', + 'ovos.common_play.repeat.set', + 'ovos.common_play.repeat.unset', + 'ovos.common_play.gui.enable_app_timeout', + 'ovos.common_play.gui.set_app_timeout', + 'ovos.common_play.gui.timeout.mode' + ] + + for event in bus_events: + self.assertEqual(len(self.bus.ee.listeners(event)), 1) # Test properties self.assertEqual(self.player.active_skill, "ovos.common_play") @@ -347,7 +383,7 @@ def test_set_now_playing(self): self.player.set_now_playing(valid_dict) entry = MediaEntry.from_dict(valid_dict) # self.assertEqual(self.player.now_playing.as_dict, valid_dict) - self.assertEqual(self.player.now_playing.as_entry(), entry) + self.assertEqual(self.player.now_playing, entry) self.assertEqual(self.player.playlist.current_track, entry) self.assertEqual(self.player.playlist[-1], entry) self.player.gui.update_current_track.assert_called_once() @@ -361,7 +397,7 @@ def test_set_now_playing(self): # Play valid MediaEntry result self.player.set_now_playing(valid_track) - self.assertEqual(self.player.now_playing.as_entry(), valid_track) + self.assertEqual(self.player.now_playing, valid_track) self.assertEqual(self.player.playlist.current_track, valid_track) self.assertEqual(self.player.playlist[-1], valid_track) self.player.gui.update_current_track.assert_called_once() @@ -649,16 +685,136 @@ def test_play_prev(self): pass def test_pause(self): - # TODO - pass + real_audio_pause = self.player.audio_service.pause + real_player_pause = self.player.mpris.pause + real_player_state = self.player.set_player_state + + self.player.audio_service.pause = Mock() + self.player.mpris.pause = Mock() + self.player.set_player_state = Mock() + + # Test Audio service Pause + self.player._paused_on_duck = True + self.player.now_playing.playback = PlaybackType.AUDIO_SERVICE + self.player.pause() + self.player.audio_service.pause.assert_called_once() + self.assertFalse(self.player._paused_on_duck) + self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) + + # Test GUI Pause + self.player.set_player_state.reset_mock() + self.player._paused_on_duck = True + self.player.now_playing.playback = PlaybackType.AUDIO + self.player.pause() + self.assertFalse(self.player._paused_on_duck) + self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, + "gui.player.media.service.pause") + + # Test Skill Pause + self.player.set_player_state.reset_mock() + self.player._paused_on_duck = True + self.player.now_playing.playback = PlaybackType.SKILL + self.player.pause() + self.assertFalse(self.player._paused_on_duck) + self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, + f"ovos.common_play.{self.player.active_skill}.pause") + + # Test MPRIS Pause + self.player.set_player_state.reset_mock() + self.player._paused_on_duck = True + self.player.now_playing.playback = PlaybackType.MPRIS + self.player.pause() + self.assertFalse(self.player._paused_on_duck) + self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) + self.player.mpris.pause.assert_called_once() + + # TODO: Test Undefined playback + + self.player.audio_service.pause.assert_called_once() + + self.player.audio_service.pause = real_audio_pause + self.player.mpris.pause = real_player_pause + self.player.set_player_state = real_player_state def test_resume(self): - # TODO - pass + real_audio_resume = self.player.audio_service.resume + real_player_resume = self.player.mpris.resume + real_player_state = self.player.set_player_state + + self.player.audio_service.resume = Mock() + self.player.mpris.resume = Mock() + self.player.set_player_state = Mock() + + # Test Audio service Resume + self.player.now_playing.playback = PlaybackType.AUDIO_SERVICE + self.player.resume() + self.player.audio_service.resume.assert_called_once() + self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) + + # Test GUI Resume + self.player.set_player_state.reset_mock() + self.player.now_playing.playback = PlaybackType.AUDIO + self.player.resume() + self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, + "gui.player.media.service.resume") + + # Test Skill Resume + self.player.set_player_state.reset_mock() + self.player.now_playing.playback = PlaybackType.SKILL + self.player.resume() + self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, + f"ovos.common_play.{self.player.active_skill}.resume") + + # Test MPRIS Resume + self.player.set_player_state.reset_mock() + self.player.now_playing.playback = PlaybackType.MPRIS + self.player.resume() + self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) + self.player.mpris.resume.assert_called_once() + + # TODO: Test Undefined playback + + self.player.audio_service.resume.assert_called_once() + + self.player.audio_service.resume = real_audio_resume + self.player.mpris.resume = real_player_resume + self.player.set_player_state = real_player_state def test_seek(self): - # TODO - pass + real_method = self.player.audio_service.set_track_position + mock_method = Mock() + self.player.audio_service.set_track_position = mock_method + + # Audio Service + self.player.now_playing.playback = PlaybackType.AUDIO_SERVICE + test_pos = 1234 + self.player.seek(test_pos) + mock_method.assert_called_once_with(1.234) + self.assertEqual(self.player.gui["position"], test_pos) + + # Audio + self.player.now_playing.playback = PlaybackType.AUDIO + test_pos = 10000 + self.player.seek(test_pos) + mock_method.assert_called_once_with(1.234) + self.assertEqual(self.player.gui["position"], test_pos) + + # Undefined + self.player.now_playing.playback = PlaybackType.UNDEFINED + test_pos = 999 + self.player.seek(test_pos) + mock_method.assert_called_with(0.999) + self.assertEqual(self.player.gui["position"], test_pos) + + self.player.audio_service.set_track_position = real_method def test_stop(self): # TODO @@ -684,8 +840,20 @@ def test_stop_audio_service(self): self.player.audio_service.stop = real_stop def test_reset(self): - # TODO - pass + real_stop = self.player.stop + self.player.stop = Mock() + + self.player.reset() + self.player.stop.assert_called_once() + self.assertEqual(self.player.playlist.entries, list()) + self.assertIsNone(self.player.playlist.current_track) + self.assertEqual(self.player.media.search_playlist, list()) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + self.assertEqual(self.player.state, PlayerState.STOPPED) + self.assertFalse(self.player.shuffle) + self.assertEqual(self.player.loop_state, LoopState.NONE) + + self.player.stop = real_stop def test_shutdown(self): # TODO @@ -772,12 +940,61 @@ def test_handle_playlist_clear_request(self): pass def test_handle_duck_request(self): - # TODO - pass + real_pause = self.player.pause + self.player.pause = Mock() + + # Duck already paused + self.player._paused_on_duck = False + self.player.state = PlayerState.PAUSED + self.player.handle_duck_request(None) + self.player.pause.assert_not_called() + self.assertFalse(self.player._paused_on_duck) + + # Duck while stopped + self.player.state = PlayerState.STOPPED + self.player.handle_duck_request(None) + self.player.pause.assert_not_called() + self.assertFalse(self.player._paused_on_duck) + + # Duck while playing + self.player.state = PlayerState.PLAYING + self.player.handle_duck_request(None) + self.player.pause.assert_called_once() + self.assertTrue(self.player._paused_on_duck) + + self.player.pause = real_pause def test_handle_unduck_request(self): - # TODO - pass + real_resume = self.player.resume + self.player.resume = Mock() + self.player._paused_on_duck = False + + # Unduck already playing + self.player.state = PlayerState.PLAYING + self.player.handle_unduck_request(None) + self.player.resume.assert_not_called() + self.assertFalse(self.player._paused_on_duck) + + # Unduck while stopped + self.player.state = PlayerState.STOPPED + self.player.handle_unduck_request(None) + self.player.resume.assert_not_called() + self.assertFalse(self.player._paused_on_duck) + + # Unduck paused (not from duck) + self.player.state = PlayerState.PAUSED + self.player.handle_unduck_request(None) + self.player.resume.assert_not_called() + self.assertFalse(self.player._paused_on_duck) + + # Unduck paused on duck + self.player._paused_on_duck = True + self.player.state = PlayerState.PAUSED + self.player.handle_unduck_request(None) + self.player.resume.assert_called_once() + self.assertFalse(self.player._paused_on_duck) + + self.player.resume = real_resume def test_handle_track_length_request(self): # TODO From 351b8070583bc9ea55c70218186c30dcc4cc7f46 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 6 Apr 2023 16:21:02 -0700 Subject: [PATCH 05/22] Update to handle empty URI per PR feedback --- ovos_plugin_common_play/ocp/player.py | 7 +++++-- test/unittests/test_ocp.py | 17 ++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index b25f944..5fc76fb 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -261,10 +261,13 @@ def set_now_playing(self, track: Union[dict, MediaEntry]): if len(self.playlist): self.now_playing.update(self.playlist[0]) else: - # TODO This is a track with no URI; why should we try to play it + # If there's no URI, the skill might be handling playback so + # now_playing should still be updated self.now_playing.update(track) else: - raise ValueError(f"Requested track has no URI: {track}") + # If there's no URI, the skill might be handling playback so + # now_playing should still be updated + self.now_playing.update(track) # sync playlist position self.playlist.goto_track(self.now_playing) diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index 378b0b9..a8c5bed 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -375,8 +375,8 @@ def test_set_now_playing(self): valid_dict = valid_search_results[0] valid_track = MediaEntry.from_dict(valid_search_results[1]) invalid_str = json.dumps(valid_search_results[2]) - invalid_no_uri = valid_search_results[2] - invalid_no_uri.pop('uri') + track_no_uri = valid_search_results[2] + track_no_uri.pop('uri') # TODO: Test playlist result # Play valid dict result @@ -403,8 +403,7 @@ def test_set_now_playing(self): self.player.gui.update_current_track.assert_called_once() self.player.gui.update_playlist.assert_called_once() self.player.mpris.update_props.assert_called_once_with( - {"Metadata": self.player.now_playing.mpris_metadata} - ) + {"Metadata": self.player.now_playing.mpris_metadata}) self.player.gui.update_current_track.reset_mock() self.player.gui.update_playlist.reset_mock() self.player.mpris.update_props.reset_mock() @@ -417,11 +416,11 @@ def test_set_now_playing(self): self.player.mpris.update_props.assert_not_called() # Play result with no URI - with self.assertRaises(ValueError): - self.player.set_now_playing(invalid_no_uri) - self.player.gui.update_current_track.assert_not_called() - self.player.gui.update_playlist.assert_not_called() - self.player.mpris.update_props.assert_not_called() + self.player.set_now_playing(track_no_uri) + self.player.gui.update_current_track.assert_called_once() + self.player.gui.update_playlist.assert_called_once() + self.player.mpris.update_props.assert_called_once_with( + {"Metadata": self.player.now_playing.mpris_metadata}) self.player.mpris.update_props = real_update_props self.player.gui.update_current_track = real_update_track From 3ed97862c3e8f918c58261c52857c96d91004a38 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 6 Apr 2023 16:32:05 -0700 Subject: [PATCH 06/22] Resolving unit test failures --- test/unittests/test_ocp.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index a8c5bed..c9837c2 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -280,9 +280,21 @@ def test_00_player_init(self): 'ovos.common_play.gui.set_app_timeout', 'ovos.common_play.gui.timeout.mode' ] - + now_playing_events = ["ovos.common_play.track.state", + "ovos.common_play.media.state", + "ovos.common_play.play", + "ovos.common_play.playback_time", + 'gui.player.media.service.get.meta', + 'mycroft.audio.service.track_info_reply', + 'mycroft.audio.service.play', + 'mycroft.audio.playing_track' + ] for event in bus_events: - self.assertEqual(len(self.bus.ee.listeners(event)), 1) + expected_listeners = 1 + if event in now_playing_events: + expected_listeners += 1 + self.assertEqual(len(self.bus.ee.listeners(event)), + expected_listeners, event) # Test properties self.assertEqual(self.player.active_skill, "ovos.common_play") @@ -617,7 +629,7 @@ def test_play(self, gui_running): # Test valid audio with gui media = MediaEntry.from_dict(valid_search_results[0]) media.playback = PlaybackType.AUDIO - self.player.now_playing = media + self.player.now_playing.update(media) mpris_stop.set() self.player.play() self.player.mpris.stop.assert_called_once() @@ -848,7 +860,8 @@ def test_reset(self): self.assertIsNone(self.player.playlist.current_track) self.assertEqual(self.player.media.search_playlist, list()) self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) - self.assertEqual(self.player.state, PlayerState.STOPPED) + # TODO: Should this update player state? + # self.assertEqual(self.player.state, PlayerState.STOPPED) self.assertFalse(self.player.shuffle) self.assertEqual(self.player.loop_state, LoopState.NONE) From 55fdc1b1b7cccecd1e765b6f99aa880f939ef84a Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 6 Apr 2023 16:43:44 -0700 Subject: [PATCH 07/22] Resolving unit test failures --- test/unittests/test_ocp.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index c9837c2..fa76260 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -652,7 +652,7 @@ def test_play(self, gui_running): self.player.validate_stream.reset_mock() self.player.gui.show_player.reset_mock() - # Test valid audio without gui (AudioService + # Test valid audio without gui (AudioService) gui_running.return_value = False self.player.mpris.stop_event.clear() self.player.play() @@ -663,7 +663,6 @@ def test_play(self, gui_running): self.assertEqual(set(self.player.track_history.keys()), {'', media.uri}) self.assertEqual(self.player.track_history[media.uri], 2) self.assertEqual(self.player.active_backend, PlaybackType.AUDIO_SERVICE) - self.assertEqual(media.playback, PlaybackType.AUDIO_SERVICE) self.player.set_player_state.assert_called_once_with( PlayerState.PLAYING) self.player.audio_service.play.assert_called_once_with( From cc153cd7d06779760d25ad65354084831588f402 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 6 Apr 2023 16:48:12 -0700 Subject: [PATCH 08/22] Refactor Message arg per review --- ovos_plugin_common_play/ocp/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ovos_plugin_common_play/ocp/__init__.py b/ovos_plugin_common_play/ocp/__init__.py index c63caf5..4343d9c 100644 --- a/ovos_plugin_common_play/ocp/__init__.py +++ b/ovos_plugin_common_play/ocp/__init__.py @@ -92,10 +92,10 @@ def register_ocp_api_events(self): # bus api shared with intents self.add_event("ovos.common_play.search", self.handle_play) - def handle_home(self, _=None): + def handle_home(self, message=None): """ Handle ovos.common_play.home Messages and show the homescreen - @param _: message associated with request + @param message: message associated with request """ # homescreen / launch from .desktop self.gui.show_home(app_mode=True) From b62aa785b66f2041ec5a3ec66601d158dcc0fd44 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 6 Apr 2023 17:39:49 -0700 Subject: [PATCH 09/22] Add tests for `handle_player_state_update` with handling of int state and simplified logic --- ovos_plugin_common_play/ocp/player.py | 22 +++++-- test/unittests/test_ocp.py | 84 ++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index 5fc76fb..3ae0ecf 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -658,19 +658,29 @@ def shutdown(self): # player -> common play def handle_player_state_update(self, message): + """ + Handles 'gui.player.media.service.sync.status' and + 'ovos.common_play.player.state' messages with player state updates + @param message: Message providing new "state" data + """ state = message.data.get("state") + if state is None: + raise ValueError(f"Got state update message with no state: " + f"{message}") + if isinstance(state, int): + state = PlayerState(state) + if not isinstance(state, PlayerState): + raise ValueError(f"Expected int or PlayerState, but got: {state}") if state == self.state: return - if state not in PlayerState: - LOG.error(f"Got invalid state requested: {state}") - for k in PlayerState: - if k == state: - LOG.info(f"PlayerState changed: {repr(k)}") + LOG.info(f"PlayerState changed: {repr(state)}") if state == PlayerState.PLAYING: self.state = PlayerState.PLAYING elif state == PlayerState.PAUSED: self.state = PlayerState.PAUSED - if self.app_view_timeout_enabled and self.app_view_timeout_mode == "pause": + if self.app_view_timeout_enabled and \ + self.app_view_timeout_mode == "pause": + LOG.debug("Starting GUI pause timeout counter") self.gui.cancel_app_view_timeout() self.gui.schedule_app_view_pause_timeout() elif state == PlayerState.STOPPED: diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index fa76260..564bf22 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -871,8 +871,88 @@ def test_shutdown(self): pass def test_handle_player_state_update(self): - # TODO - pass + real_view_timeout = self.player.gui.cancel_app_view_timeout + real_pause_timeout = self.player.gui.schedule_app_view_pause_timeout + real_update_props = self.player.mpris.update_props + view_timeout = Mock() + view_pause = Mock() + update_props = Mock() + self.player.gui.cancel_app_view_timeout = view_timeout + self.player.gui.schedule_app_view_pause_timeout = view_pause + self.player.mpris.update_props = update_props + + self.player.settings['app_view_timeout_mode'] = "pause" + self.player.settings['app_view_timeout_enabled'] = False + + # Invalid requests + with self.assertRaises(ValueError): + self.player.handle_player_state_update( + Message("", {"not_state": None})) + with self.assertRaises(ValueError): + self.player.handle_player_state_update( + Message("", {"state": None})) + with self.assertRaises(ValueError): + self.player.handle_player_state_update( + Message("", {"state": "Playing"})) + view_timeout.assert_not_called() + view_pause.assert_not_called() + update_props.assert_not_called() + + # State not changed + self.player.state = PlayerState.PLAYING + self.player.handle_player_state_update( + Message("", {"state": PlayerState.PLAYING})) + self.player.handle_player_state_update( + Message("", {"state": int(PlayerState.PLAYING)})) + view_timeout.assert_not_called() + view_pause.assert_not_called() + update_props.assert_not_called() + self.assertEqual(self.player.state, PlayerState.PLAYING) + + # Pause no GUI change + self.player.handle_player_state_update( + Message("", {"state": PlayerState.PAUSED})) + view_timeout.assert_not_called() + view_pause.assert_not_called() + update_props.assert_called_with({"CanPause": False, + "CanPlay": True, + "PlaybackStatus": "Paused"}) + self.assertEqual(self.player.state, PlayerState.PAUSED) + + # Play + self.player.handle_player_state_update( + Message("", {"state": PlayerState.PLAYING})) + view_timeout.assert_not_called() + view_pause.assert_not_called() + update_props.assert_called_with({"CanPause": True, + "CanPlay": False, + "PlaybackStatus": "Playing"}) + self.assertEqual(self.player.state, PlayerState.PLAYING) + + # Pause with GUI Change + self.player.settings['app_view_timeout_enabled'] = True + self.player.handle_player_state_update( + Message("", {"state": PlayerState.PAUSED})) + view_timeout.assert_called_once() + view_pause.assert_called_once() + update_props.assert_called_with({"CanPause": False, + "CanPlay": True, + "PlaybackStatus": "Paused"}) + self.assertEqual(self.player.state, PlayerState.PAUSED) + + # Stop + self.player.handle_player_state_update( + Message("", {"state": PlayerState.STOPPED})) + view_timeout.assert_called_once() + view_pause.assert_called_once() + update_props.assert_called_with({"CanPause": False, + "CanPlay": False, + "PlaybackStatus": "Stopped"}) + self.assertEqual(self.player.state, PlayerState.STOPPED) + + self.player.gui.cancel_app_view_timeout = real_view_timeout + self.player.gui.schedule_app_view_pause_timeout = real_pause_timeout + self.player.mpris.update_props = real_update_props def test_handle_player_media_update(self): # TODO From 34350307b2e145182da5a3055c06cfb14455566b Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 6 Apr 2023 19:07:10 -0700 Subject: [PATCH 10/22] Add tests for `play_next` with updated comments and logging --- ovos_plugin_common_play/ocp/player.py | 20 +++-- test/unittests/test_ocp.py | 110 +++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index 3ae0ecf..246d823 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -234,7 +234,7 @@ def set_player_state(self, state: PlayerState): def set_now_playing(self, track: Union[dict, MediaEntry]): """ Set `track` as the currently playing media, update the playlist, and - notify any GUI or MPRIS clients. + notify any GUI or MPRIS clients. Adds `track` to `playlist` @param track: MediaEntry or dict representation of a MediaEntry to play """ LOG.debug(f"Playing: {track}") @@ -455,7 +455,8 @@ def play(self): def play_shuffle(self): """ - Go to a random position in the playlist and play that MediaEntry. + Go to a random position in the playlist and set that MediaEntry as + 'now_playing` (does NOT call 'play'). """ LOG.debug("Shuffle == True") if len(self.playlist) > 1 and not self.playlist.is_last_track: @@ -468,23 +469,26 @@ def play_shuffle(self): def play_next(self): """ - Play the next track in the playlist. - If there is no next track, end playback. + Play the next track in the playlist or search results. + End playback if there is no next track, accounting for repeat and + shuffle settings. """ - if self.active_backend in [PlaybackType.MPRIS]: + if self.active_backend == PlaybackType.MPRIS: if self.mpris: self.mpris.play_next() return elif self.active_backend in [PlaybackType.SKILL, PlaybackType.UNDEFINED]: + LOG.debug("Defer playing next track to skill") self.bus.emit(Message( f'ovos.common_play.{self.now_playing.skill_id}.next')) return self.pause() # make more responsive if self.loop_state == LoopState.REPEAT_TRACK: - self.play() + LOG.debug("Repeating single track") elif self.shuffle: + LOG.debug("Shuffling") self.play_shuffle() elif not self.playlist.is_last_track: self.playlist.next_track() @@ -493,13 +497,14 @@ def play_next(self): elif not self.media.search_playlist.is_last_track and \ self.settings.get("merge_search", True): while self.media.search_playlist.current_track in self.playlist: + # Don't play media already played from the playlist self.media.search_playlist.next_track() self.set_now_playing(self.media.search_playlist.current_track) LOG.info(f"Next search index: " f"{self.media.search_playlist.position}") else: if self.loop_state == LoopState.REPEAT and len(self.playlist): - LOG.debug("end of playlist, repeat == True") + LOG.info("end of playlist, repeat == True") self.playlist.set_position(0) else: LOG.info("requested next, but there aren't any more tracks") @@ -716,6 +721,7 @@ def handle_playback_ended(self, message): LOG.debug("Playback ended") if self.settings.get("autoplay", True) and \ self.active_backend != PlaybackType.MPRIS: + LOG.debug("Playing next track") self.play_next() return diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index 564bf22..0b8cc48 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -687,8 +687,114 @@ def test_play_shuffle(self): pass def test_play_next(self): - # TODO - pass + real_mpris = self.player.mpris.play_next + real_pause = self.player.pause + real_play = self.player.play + real_shuffle = self.player.play_shuffle + real_gui_end = self.player.gui.handle_end_of_playback + + self.player.mpris.play_next = Mock() + self.player.pause = Mock() + self.player.play = Mock() + self.player.play_shuffle = Mock() + self.player.gui.handle_end_of_playback = Mock() + + # MPRIS Next + self.player.now_playing.playback = PlaybackType.MPRIS + self.player.play_next() + self.player.mpris.play_next.assert_called_once() + + # Skill Next + self.player.now_playing.playback = PlaybackType.SKILL + self.player.play_next() + last_message = self.emitted_msgs[-1] + self.assertEqual( + last_message.msg_type, + f"ovos.common_play.{self.player.now_playing.skill_id}.next") + + # Repeat Track + self.player.now_playing.playback = PlaybackType.AUDIO + self.player.loop_state = LoopState.REPEAT_TRACK + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_called_once() + + # Shuffle + self.player.pause.reset_mock() + self.player.play.reset_mock() + self.player.loop_state = LoopState.NONE + self.player.shuffle = True + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play_shuffle.assert_called_once() + self.player.play.assert_called_once() + + # Playlist next track + self.player.pause.reset_mock() + self.player.play.reset_mock() + self.player.shuffle = False + self.player.playlist.replace(valid_search_results) + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_called_once() + self.assertEqual(self.player.now_playing, self.player.playlist[1]) + + # Playlist repeat + self.player.loop_state = LoopState.REPEAT + self.player.playlist.set_position(len(self.player.playlist) - 1) + self.player.pause.reset_mock() + self.player.play.reset_mock() + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_called_once() + self.assertTrue(self.player.playlist.is_first_track) + + # Playlist no repeat + self.player.loop_state = LoopState.NONE + self.player.playlist.set_position(len(self.player.playlist) - 1) + self.player.pause.reset_mock() + self.player.play.reset_mock() + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_not_called() + self.player.gui.handle_end_of_playback.assert_called_once() + + # Search results next track + self.player.gui.handle_end_of_playback.reset_mock() + self.player.playlist.clear() + self.player.media.search_playlist.replace(valid_search_results) + self.assertEqual(len(self.player.playlist), 0) + self.player.pause.reset_mock() + self.player.play.reset_mock() + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_called_once() + self.player.gui.handle_end_of_playback.assert_not_called() + + # Search results end + self.player.pause.reset_mock() + self.player.play.reset_mock() + # TODO: should we repeat search results, or skip adding them to the playlist + self.player.playlist.clear() + self.player.media.search_playlist.set_position( + len(self.player.media.search_playlist) - 1) + self.assertTrue(self.player.media.search_playlist.is_last_track) + self.assertEqual(len(self.player.playlist), 0) + self.player.loop_state = LoopState.REPEAT + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_not_called() + self.player.gui.handle_end_of_playback.assert_called_once() + + # TODO: Test with `merge_search` set to False + + self.player.mpris.play_next.assert_called_once() + + self.player.gui.handle_end_of_playback = real_gui_end + self.player.play_shuffle = real_shuffle + self.player.play = real_play + self.player.pause = real_pause + self.player.mpris.play_next = real_mpris def test_play_prev(self): # TODO From 38f5b3f8a990d97bafb851e08b6c1027f701cfae Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 10:50:22 -0700 Subject: [PATCH 11/22] Cleanup and fix bugs in `handle_player_media_update` with unit test --- ovos_plugin_common_play/ocp/player.py | 19 +++++-- test/unittests/test_ocp.py | 74 ++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index 246d823..b69cbdd 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -479,7 +479,9 @@ def play_next(self): return elif self.active_backend in [PlaybackType.SKILL, PlaybackType.UNDEFINED]: - LOG.debug("Defer playing next track to skill") + # TODO: This is where Neon playback is failing + LOG.debug(f"Defer playing next track to skill " + f"(Playback={self.active_backend}") self.bus.emit(Message( f'ovos.common_play.{self.now_playing.skill_id}.next')) return @@ -700,12 +702,21 @@ def handle_player_state_update(self, message): "PlaybackStatus": state2str[state]}) def handle_player_media_update(self, message): + """ + Handles 'ovos.common_play.media.state' messages with media state updates + @param message: Message providing new "state" data + """ state = message.data.get("state") + if state is None: + raise ValueError(f"Got state update message with no state: " + f"{message}") + if isinstance(state, int): + state = MediaState(state) + if not isinstance(state, MediaState): + raise ValueError(f"Expected int or MediaState, but got: {state}") if state == self.media_state: return - for k in MediaState: - if k == state: - LOG.info(f"MediaState changed: {repr(k)}") + LOG.info(f"MediaState changed: {repr(state)}") self.media_state = state if state == MediaState.END_OF_MEDIA: self.handle_playback_ended(message) diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index 0b8cc48..5b0f613 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -1061,8 +1061,78 @@ def test_handle_player_state_update(self): self.player.mpris.update_props = real_update_props def test_handle_player_media_update(self): - # TODO - pass + real_handle_playback_ended = self.player.handle_playback_ended + real_handle_invalid_media = self.player.handle_invalid_media + real_play_next = self.player.play_next + + self.player.play_next = Mock() + self.player.handle_playback_ended = Mock() + self.player.handle_invalid_media = Mock() + self.player.media_state = MediaState.UNKNOWN + + # Invalid requests + with self.assertRaises(ValueError): + self.player.handle_player_media_update( + Message("", {"not_state": None})) + with self.assertRaises(ValueError): + self.player.handle_player_media_update( + Message("", {"state": None})) + with self.assertRaises(ValueError): + self.player.handle_player_media_update( + Message("", {"state": "UNKNOWN"})) + self.player.handle_playback_ended.assert_not_called() + self.player.handle_invalid_media.assert_not_called() + self.assertEqual(self.player.media_state, MediaState.UNKNOWN) + + # State not changed + self.player.handle_player_media_update( + Message("", {"state": MediaState.UNKNOWN})) + self.assertEqual(self.player.media_state, MediaState.UNKNOWN) + self.player.handle_player_media_update( + Message("", {"state": 0})) + self.assertEqual(self.player.media_state, MediaState.UNKNOWN) + self.player.handle_playback_ended.assert_not_called() + self.player.handle_invalid_media.assert_not_called() + self.player.play_next.assert_not_called() + + # Valid state changes + self.player.handle_player_media_update( + Message("", {"state": MediaState.NO_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + self.player.handle_player_media_update( + Message("", {"state": MediaState.LOADING_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.LOADING_MEDIA) + self.player.handle_player_media_update( + Message("", {"state": MediaState.STALLED_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.STALLED_MEDIA) + self.player.handle_player_media_update( + Message("", {"state": MediaState.BUFFERING_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.BUFFERING_MEDIA) + self.player.handle_player_media_update( + Message("", {"state": MediaState.BUFFERED_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.BUFFERED_MEDIA) + self.player.handle_playback_ended.assert_not_called() + self.player.handle_invalid_media.assert_not_called() + self.player.play_next.assert_not_called() + + self.player.handle_player_media_update( + Message("", {"state": MediaState.END_OF_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.END_OF_MEDIA) + self.player.handle_playback_ended.assert_called_once() + self.player.handle_invalid_media.assert_not_called() + self.player.play_next.assert_not_called() + + self.player.handle_player_media_update( + Message("", {"state": MediaState.INVALID_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.INVALID_MEDIA) + self.player.handle_playback_ended.assert_called_once() + self.player.handle_invalid_media.assert_called_once() + self.player.play_next.assert_called_once() + # TODO: Test without autoplay + + self.player.play_next = real_play_next + self.player.handle_playback_ended = real_handle_playback_ended + self.player.handle_invalid_media = real_handle_invalid_media def test_handle_invalid_media(self): # TODO From a0a4acc361d0759d9971c415ea4adf3bbd62bebe Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 10:51:28 -0700 Subject: [PATCH 12/22] Revert equality check per PR review --- ovos_plugin_common_play/ocp/player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index b69cbdd..69a9021 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -473,7 +473,7 @@ def play_next(self): End playback if there is no next track, accounting for repeat and shuffle settings. """ - if self.active_backend == PlaybackType.MPRIS: + if self.active_backend in [PlaybackType.MPRIS]: if self.mpris: self.mpris.play_next() return From fda047336c7fc5caf870265e007bac510663f52c Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 12:35:26 -0700 Subject: [PATCH 13/22] Add MediaEntry and Playlist tests and docstrings --- ovos_plugin_common_play/ocp/media.py | 239 ++++++++++++++++++--------- test/unittests/test_ocp.py | 5 +- 2 files changed, 167 insertions(+), 77 deletions(-) diff --git a/ovos_plugin_common_play/ocp/media.py b/ovos_plugin_common_play/ocp/media.py index 8690830..6ed658f 100644 --- a/ovos_plugin_common_play/ocp/media.py +++ b/ovos_plugin_common_play/ocp/media.py @@ -1,3 +1,5 @@ +from typing import Optional, Tuple, List, Union + from ovos_plugin_common_play.ocp import OCP_ID from ovos_plugin_common_play.ocp.status import * from ovos_plugin_common_play.ocp.utils import ocp_plugins, find_mime @@ -25,18 +27,27 @@ def __init__(self, title="", uri="", skill_id=OCP_ID, self.skill_id = skill_id self.status = status self.playback = playback - self.image = image or join(dirname(__file__), "res/ui/images/ocp_bg.png") + self.image = image or join(dirname(__file__), + "res/ui/images/ocp_bg.png") self.position = position self.phrase = phrase self.length = length # None -> live stream - self.skill_icon = skill_icon or join(dirname(__file__), "res/ui/images/ocp.png") - self.bg_image = bg_image or join(dirname(__file__), "res/ui/images/ocp_bg.png") + self.skill_icon = skill_icon or join(dirname(__file__), + "res/ui/images/ocp.png") + self.bg_image = bg_image or join(dirname(__file__), + "res/ui/images/ocp_bg.png") self.is_cps = is_cps self.data = kwargs self.cps_data = cps_data or {} self.javascript = javascript # custom code to run in Webview after page load - def update(self, entry, skipkeys=None, newonly=False): + def update(self, entry: dict, skipkeys: list = None, newonly: bool = False): + """ + Update this MediaEntry object with keys from the provided entry + @param entry: dict or MediaEntry object to update this object with + @param skipkeys: list of keys to not change + @param newonly: if True, only adds new keys; existing keys are unchanged + """ skipkeys = skipkeys or [] if isinstance(entry, MediaEntry): entry = entry.as_dict @@ -49,7 +60,12 @@ def update(self, entry, skipkeys=None, newonly=False): self.__setattr__(k, v) @staticmethod - def from_dict(data): + def from_dict(data: dict): + """ + Construct a `MediaEntry` object from dict data. + @param data: dict information to build the `MediaEntry` for + @return: MediaEntry object + """ if data.get("bg_image") and data["bg_image"].startswith("/"): data["bg_image"] = "file:/" + data["bg_image"] data["skill"] = data.get("skill_id") or OCP_ID @@ -68,12 +84,17 @@ def from_dict(data): return MediaEntry(**data) @property - def info(self): - # media results / playlist QML data model + def info(self) -> dict: + """ + Return a dict representation of this MediaEntry + infocard for QML model + """ return merge_dict(self.as_dict, self.infocard) @property - def infocard(self): + def infocard(self) -> dict: + """ + Return dict data used for a UI display + """ return { "duration": self.length, "track": self.title, @@ -84,7 +105,10 @@ def infocard(self): } @property - def mpris_metadata(self): + def mpris_metadata(self) -> dict: + """ + Return dict data used by MPRIS + """ meta = {"xesam:url": Variant('s', self.uri)} if self.artist: meta['xesam:artist'] = Variant('as', [self.artist]) @@ -97,12 +121,18 @@ def mpris_metadata(self): return meta @property - def as_dict(self): + def as_dict(self) -> dict: + """ + Return a dict reporesentation of this MediaEntry + """ return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} @property - def mimetype(self): + def mimetype(self) -> Optional[Tuple[Optional[str], Optional[str]]]: + """ + Get the detected mimetype tuple (type, encoding) if it can be determined + """ if self.uri: return find_mime(self.uri) @@ -125,18 +155,17 @@ def __init__(self, *args, **kwargs): self._position = 0 @property - def position(self): + def position(self) -> int: + """ + Return the current position in the playlist + """ return self._position - def goto_start(self): - self._position = 0 - - def clear(self) -> None: - super(Playlist, self).clear() - self._position = 0 - @property - def entries(self): + def entries(self) -> List[MediaEntry]: + """ + Return a list of MediaEntry objects in the playlist + """ entries = [] for e in self: if isinstance(e, dict): @@ -145,13 +174,68 @@ def entries(self): entries.append(e) return entries + @property + def current_track(self) -> Optional[MediaEntry]: + """ + Return the current MediaEntry or None if the playlist is empty + """ + if len(self) == 0: + return None + self._validate_position() + track = self[self.position] + if isinstance(track, dict): + track = MediaEntry.from_dict(track) + return track + + @property + def is_first_track(self) -> bool: + """ + Return `True` if the current position is the first track or if the + playlist is empty + """ + if len(self) == 0: + return True + return self.position == 0 + + @property + def is_last_track(self) -> bool: + """ + Return `True` if the current position is the last track of if the + playlist is empty + """ + if len(self) == 0: + return True + return self.position == len(self) - 1 + + def goto_start(self) -> None: + """ + Move to the first entry in the playlist + """ + self._position = 0 + + def clear(self) -> None: + """ + Remove all entries from the Playlist and reset the position + """ + super(Playlist, self).clear() + self._position = 0 + def sort_by_conf(self): + """ + Sort the Playlist by `match_confidence` with high confidence first + """ self.sort(key=lambda k: k.match_confidence if isinstance(k, MediaEntry) else k.get("match_confidence", 0), reverse=True) - def add_entry(self, entry, index=-1): + def add_entry(self, entry: MediaEntry, index: int = -1) -> None: + """ + Add an entry at the requested index + @param entry: MediaEntry to add to playlist + @param index: index to insert entry at (default -1 to append) + """ assert isinstance(index, int) + # TODO: Handle index out of range if isinstance(entry, dict): entry = MediaEntry.from_dict(entry) assert isinstance(entry, MediaEntry) @@ -163,7 +247,11 @@ def add_entry(self, entry, index=-1): self.insert(index, entry) - def remove_entry(self, entry): + def remove_entry(self, entry: Union[int, dict, MediaEntry]) -> None: + """ + Remove the requested entry from the playlist or raise a ValueError + @param entry: index or MediaEntry to remove from the playlist + """ if isinstance(entry, int): self.pop(entry) return @@ -175,13 +263,66 @@ def remove_entry(self, entry): self.pop(idx) break else: - raise ValueError("entry not in playlist") + raise ValueError(f"entry not in playlist: {entry}") - def replace(self, new_list): + def replace(self, new_list: List[Union[dict, MediaEntry]]) -> None: + """ + Replace the contents of this Playlist with new_list + @param new_list: list of MediaEntry or dict objects to set this list to + """ self.clear() for e in new_list: self.add_entry(e) + def set_position(self, idx: int): + """ + Set the position in the playlist to a specific index + @param idx: Index to set position to + """ + self._position = idx + self._validate_position() + + def goto_track(self, track: Union[MediaEntry, dict]) -> None: + """ + Go to the requested track in the playlist + @param track: MediaEntry to find and go to in the playlist + """ + if isinstance(track, MediaEntry): + requested_uri = track.uri + else: + requested_uri = track.get("uri", "") + for idx, t in enumerate(self): + if isinstance(t, MediaEntry): + pl_entry_uri = t.uri + else: + pl_entry_uri = t.get("uri", "") + if requested_uri == pl_entry_uri: + self.set_position(idx) + LOG.debug(f"New playlist position: {self.position}") + return + LOG.error(f"requested track not in the playlist: {track}") + + def next_track(self) -> None: + """ + Go to the next track in the playlist + """ + self.set_position(self.position + 1) + + def prev_track(self) -> None: + """ + Go to the previous track in the playlist + """ + self.set_position(self.position - 1) + + def _validate_position(self) -> None: + """ + Make sure the current position is valid; default `position` to 0 + """ + if self.position < 0 or self.position >= len(self): + LOG.error(f"Playlist pointer is in an invalid position " + f"({self.position}! Going to start of playlist") + self._position = 0 + def __contains__(self, item): if isinstance(item, dict): item = MediaEntry.from_dict(item) @@ -199,56 +340,6 @@ def __contains__(self, item): return True return False - def _validate_position(self): - if len(self) and (self.position >= len(self) or self.position < 0): - LOG.error("Playlist pointer is in an invalid position! Going to " - "start of playlist") - self._position = 0 - - def set_position(self, idx): - self._position = idx - self._validate_position() - - @property - def is_first_track(self): - if len(self) == 0: - return True - return self.position == 0 - - @property - def is_last_track(self): - if len(self) == 0: - return True - return self.position == len(self) - 1 - - def goto_track(self, track): - if isinstance(track, MediaEntry): - uri = track.uri - else: - uri = track.get("uri", "") - for idx, t in enumerate(self): - if isinstance(t, MediaEntry): - uri2 = t.uri - else: - uri2 = t.get("uri", "") - if uri == uri2: - self.set_position(idx) - LOG.debug(f"New playlist position: {self.position}") - return - - @property - def current_track(self): - if len(self) == 0: - return None - self._validate_position() - return self[self.position] - - def next_track(self): - self.set_position(self.position + 1) - - def prev_track(self): - self.set_position(self.position - 1) - class NowPlaying(MediaEntry): @property diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index 5b0f613..c09c345 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -1,14 +1,13 @@ import json import unittest -from threading import Event from mycroft_bus_client import Message from ovos_utils.messagebus import FakeBus from unittest.mock import Mock, MagicMock, patch -from ovos_plugin_common_play import PlayerState from ovos_plugin_common_play.ocp.media import MediaEntry -from ovos_plugin_common_play.ocp.status import MediaType, LoopState, MediaState, PlaybackType, TrackState +from ovos_plugin_common_play.ocp.status import MediaType, LoopState, \ + MediaState, PlaybackType, TrackState, PlayerState valid_search_results = [ {'media_type': MediaType.MUSIC, From 10863ba44a643eaaacfb5b687348096e81fcae8b Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 12:36:10 -0700 Subject: [PATCH 14/22] Add MediaEntry and Playlist tests --- test/unittests/test_media.py | 176 +++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 test/unittests/test_media.py diff --git a/test/unittests/test_media.py b/test/unittests/test_media.py new file mode 100644 index 0000000..c2ac4f3 --- /dev/null +++ b/test/unittests/test_media.py @@ -0,0 +1,176 @@ +import unittest + +from ovos_plugin_common_play.ocp.media import MediaEntry, Playlist +from ovos_plugin_common_play.ocp.status import MediaType, PlaybackType + + +valid_search_results = [ + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/07_-_Quantum_Jazz_-_Orbiting_A_Distant_Planet/stream/', + 'title': 'Orbiting A Distant Planet', + 'artist': 'Quantum Jazz', + 'match_confidence': 65}, + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/05_-_Quantum_Jazz_-_Passing_Fields/stream/', + 'title': 'Passing Fields', + 'artist': 'Quantum Jazz', + 'match_confidence': 65}, + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/04_-_Quantum_Jazz_-_All_About_The_Sun/stream/', + 'title': 'All About The Sun', + 'artist': 'Quantum Jazz', + 'match_confidence': 65} +] + + +class TestMediaEntry(unittest.TestCase): + def test_update(self): + # TODO + pass + + def test_from_dict(self): + dict_data = valid_search_results[0] + from_dict = MediaEntry.from_dict(dict_data) + self.assertIsInstance(from_dict, MediaEntry) + from_init = MediaEntry(dict_data["title"], dict_data["uri"], + image=dict_data["image"], + match_confidence=dict_data["match_confidence"], + playback=PlaybackType.AUDIO, + skill_icon=dict_data["skill_icon"], + artist=dict_data["artist"]) + self.assertEqual(from_init, from_dict) + + self.assertIsInstance(MediaEntry.from_dict({}), MediaEntry) + + def test_info(self): + # TODO + pass + + def test_infocard(self): + # TODO + pass + + def test_mpris_metadata(self): + # TODO + pass + + def test_as_dict(self): + # TODO + pass + + def test_mimetype(self): + # TODO + pass + + +class TestPlaylist(unittest.TestCase): + def test_properties(self): + # Empty Playlist + pl = Playlist() + self.assertEqual(pl.position, 0) + self.assertEqual(pl.entries, []) + self.assertIsNone(pl.current_track) + self.assertTrue(pl.is_first_track) + self.assertTrue(pl.is_last_track) + + # Playlist of dicts + pl = Playlist(valid_search_results) + self.assertEqual(pl.position, 0) + self.assertEqual(len(pl.entries), len(valid_search_results)) + for entry in pl.entries: + self.assertIsInstance(entry, MediaEntry) + self.assertIsInstance(pl.current_track, MediaEntry) + self.assertTrue(pl.is_first_track) + self.assertFalse(pl.is_last_track) + + def test_goto_start(self): + # TODO + pass + + def test_clear(self): + # TODO + pass + + def test_sort_by_conf(self): + # TODO + pass + + def test_add_entry(self): + # TODO + pass + + def test_remove_entry(self): + # TODO + pass + + def test_replace(self): + # TODO + pass + + def test_set_position(self): + # TODO + pass + + def test_goto_track(self): + # TODO + pass + + def test_next_track(self): + # TODO + pass + + def test_prev_track(self): + # TODO + pass + + def test_validate_position(self): + # Test empty playlist + pl = Playlist() + pl._position = 0 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl._position = -1 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl._position = 1 + pl._validate_position() + self.assertEqual(pl.position, 0) + + # Test playlist of len 1 + pl = Playlist([valid_search_results[0]]) + pl._position = 0 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl._position = 1 + pl._validate_position() + self.assertEqual(pl.position, 0) + + # Test playlist of len>1 + pl = Playlist(valid_search_results) + pl._position = 0 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl._position = 1 + pl._validate_position() + self.assertEqual(pl.position, 1) + pl._position = 10 + pl._validate_position() + self.assertEqual(pl.position, 0) + + +class TestNowPlaying(unittest.TestCase): + def test(self): + pass + + +if __name__ == "__main__": + unittest.main() From 41919746b5290f325e42b5e3fec99d7627ba72a1 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 15:18:35 -0700 Subject: [PATCH 15/22] Complete `media` tests and docstrings Split `player` tests to separate test file --- ovos_plugin_common_play/ocp/media.py | 131 ++- test/unittests/test_media.py | 176 ---- test/unittests/test_ocp.py | 1128 +------------------------ test/unittests/test_ocp_media.py | 342 ++++++++ test/unittests/test_ocp_player.py | 1135 ++++++++++++++++++++++++++ 5 files changed, 1590 insertions(+), 1322 deletions(-) delete mode 100644 test/unittests/test_media.py create mode 100644 test/unittests/test_ocp_media.py create mode 100644 test/unittests/test_ocp_player.py diff --git a/ovos_plugin_common_play/ocp/media.py b/ovos_plugin_common_play/ocp/media.py index 6ed658f..d744792 100644 --- a/ovos_plugin_common_play/ocp/media.py +++ b/ovos_plugin_common_play/ocp/media.py @@ -1,5 +1,6 @@ from typing import Optional, Tuple, List, Union +from mycroft.messagebus import MessageBusClient from ovos_plugin_common_play.ocp import OCP_ID from ovos_plugin_common_play.ocp.status import * from ovos_plugin_common_play.ocp.utils import ocp_plugins, find_mime @@ -342,18 +343,36 @@ def __contains__(self, item): class NowPlaying(MediaEntry): + def __init__(self, *args, **kwargs): + MediaEntry.__init__(self, *args, **kwargs) + self._player = None + @property - def bus(self): + def bus(self) -> MessageBusClient: + """ + Return the MessageBusClient inherited from the bound OCPMediaPlayer + """ return self._player.bus @property - def _settings(self): + def _settings(self) -> dict: + """ + Return the dict settings inherited from the bound OCPMediaPlayer + """ return self._player.settings - def as_entry(self): + def as_entry(self) -> MediaEntry: + """ + Return a MediaEntry representation of this object + """ return MediaEntry.from_dict(self.as_dict) def bind(self, player): + """ + Bind an OCPMediaPlayer object to this NowPlaying instance. Registers + messagebus event handlers and defines `self._player` + @param player: OCPMediaPlayer instance to bind + """ # needs to start with _ to avoid json serialization errors self._player = player self._player.add_event("ovos.common_play.track.state", @@ -374,12 +393,18 @@ def bind(self, player): self.handle_audio_service_play_start) def shutdown(self): + """ + Remove NowPlaying events from the MessageBusClient + """ self._player.remove_event("ovos.common_play.track.state") self._player.remove_event("ovos.common_play.playback_time") self._player.remove_event('gui.player.media.service.get.meta') self._player.remove_event('mycroft.audio_only.service.track_info_reply') def reset(self): + """ + Reset the NowPlaying MediaEntry to default parameters + """ self.title = "" self.artist = None self.skill_icon = None @@ -394,18 +419,33 @@ def reset(self): self.playback = PlaybackType.UNDEFINED self.status = TrackState.DISAMBIGUATION - def update(self, entry, skipkeys=None, newonly=False): + def update(self, entry: dict, skipkeys: list = None, newonly: bool = False): + """ + Update this MediaEntry and emit `gui.player.media.service.set.meta` + @param entry: dict or MediaEntry object to update this object with + @param skipkeys: list of keys to not change + @param newonly: if True, only adds new keys; existing keys are unchanged + """ + if isinstance(entry, MediaEntry): + entry = entry.as_dict super().update(entry, skipkeys, newonly) # uri updates should not be skipped if newonly and entry.get("uri"): super().update({"uri": entry["uri"]}) # sync with gui media player on track change + if not self._player: + LOG.error("Instance not bound! Call `bind` before trying to use " + "the messagebus.") + return self.bus.emit(Message("gui.player.media.service.set.meta", {"title": self.title, "image": self.image, "artist": self.artist})) def extract_stream(self): + """ + Get metadata from ocp_plugins and add it to this MediaEntry + """ uri = self.uri if not uri: raise ValueError("No URI to extract stream from") @@ -416,15 +456,19 @@ def extract_stream(self): meta = ocp_plugins.extract_stream(uri, video) # update media entry with new data if meta: - LOG.debug(f"OCP plugins metadata: {meta}") + LOG.info(f"OCP plugins metadata: {meta}") self.update(meta, newonly=True) elif not any((uri.startswith(s) for s in ["http", "file", "/"])): LOG.info(f"OCP WARNING: plugins returned no metadata for uri {uri}") # events from gui_player/audio_service def handle_external_play(self, message): - # update metadata unconditionally - # otherwise previous song keys might bleed into new track + """ + Handle 'ovos.common_play.play' Messages. Update the metadata with new + data received unconditionally, otherwise previous song keys might + bleed into the new track + @param message: Message associated with request + """ if message.data.get("tracks"): # backwards compat / old style playlist = message.data["tracks"] @@ -435,55 +479,96 @@ def handle_external_play(self, message): self.update(media, newonly=False) def handle_player_metadata_request(self, message): + """ + Handle 'gui.player.media.service.get.meta' Messages. Emit a response for + the GUI to handle new metadata. + @param message: Message associated with request + """ self.bus.emit(message.reply("gui.player.media.service.set.meta", {"title": self.title, "image": self.image, "artist": self.artist})) def handle_track_state_change(self, message): - status = message.data["state"] - self.status = status - for k in TrackState: - if k == status: - LOG.info(f"TrackState changed: {repr(k)}") + """ + Handle 'ovos.common_play.track.state' Messages. Update status + @param message: Message with updated `state` data + @return: + """ + state = message.data.get("state") + if state is None: + raise ValueError(f"Got state update message with no state: " + f"{message}") + if isinstance(state, int): + state = TrackState(state) + if not isinstance(state, TrackState): + raise ValueError(f"Expected int or TrackState, but got: {state}") - if status == TrackState.PLAYING_SKILL: + if state == self.status: + return + self.status = state + LOG.info(f"TrackState changed: {state}") + + if state == TrackState.PLAYING_SKILL: # skill is handling playback internally pass - elif status == TrackState.PLAYING_AUDIOSERVICE: + elif state == TrackState.PLAYING_AUDIOSERVICE: # audio service is handling playback pass - elif status == TrackState.PLAYING_VIDEO: + elif state == TrackState.PLAYING_VIDEO: # ovos common play is handling playback in GUI pass - elif status == TrackState.PLAYING_AUDIO: + elif state == TrackState.PLAYING_AUDIO: # ovos common play is handling playback in GUI pass - elif status == TrackState.DISAMBIGUATION: + elif state == TrackState.DISAMBIGUATION: # alternative results # TODO its this 1 track or a list ? pass - elif status in [TrackState.QUEUED_SKILL, + elif state in [TrackState.QUEUED_SKILL, TrackState.QUEUED_VIDEO, TrackState.QUEUED_AUDIOSERVICE]: # audio service is handling playback and this is in playlist pass def handle_media_state_change(self, message): - status = message.data["state"] - if status == MediaState.END_OF_MEDIA: + """ + Handle 'ovos.common_play.media.state' Messages. If ended, reset. + @param message: Message with updated MediaState + """ + state = message.data.get("state") + if state is None: + raise ValueError(f"Got state update message with no state: " + f"{message}") + if isinstance(state, int): + state = MediaState(state) + if not isinstance(state, MediaState): + raise ValueError(f"Expected int or TrackState, but got: {state}") + if state == MediaState.END_OF_MEDIA: # playback ended, allow next track to change metadata again self.reset() def handle_sync_seekbar(self, message): - """ event sent by ovos audio backend plugins """ + """ + Handle 'ovos.common_play.playback_time' Messages sent by audio backend + @param message: Message with 'length' and 'position' data + """ self.length = message.data["length"] self.position = message.data["position"] def handle_sync_trackinfo(self, message): + """ + Handle 'mycroft.audio.service.track_info_reply' Messages with current + media defined in message.data + @param message: Message with dict MediaEntry data + """ self.update(message.data) def handle_audio_service_play(self, message): + """ + Handle 'mycroft.audio.service.play' Messages with list of tracks in data + @param message: Message with 'tracks' data + """ tracks = message.data.get("tracks") or [] # only present in ovos-core skill_id = message.context.get("skill_id") or 'mycroft.audio_interface' @@ -502,6 +587,10 @@ def handle_audio_service_play(self, message): pass def handle_audio_service_play_start(self, message): + """ + Handle 'mycroft.audio.playing_track' Messages + @param message: Message notifying playback has started + """ self.update( {"status": TrackState.PLAYING_AUDIOSERVICE, "playback": PlaybackType.AUDIO_SERVICE}) diff --git a/test/unittests/test_media.py b/test/unittests/test_media.py deleted file mode 100644 index c2ac4f3..0000000 --- a/test/unittests/test_media.py +++ /dev/null @@ -1,176 +0,0 @@ -import unittest - -from ovos_plugin_common_play.ocp.media import MediaEntry, Playlist -from ovos_plugin_common_play.ocp.status import MediaType, PlaybackType - - -valid_search_results = [ - {'media_type': MediaType.MUSIC, - 'playback': PlaybackType.AUDIO, - 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'uri': 'https://freemusicarchive.org/track/07_-_Quantum_Jazz_-_Orbiting_A_Distant_Planet/stream/', - 'title': 'Orbiting A Distant Planet', - 'artist': 'Quantum Jazz', - 'match_confidence': 65}, - {'media_type': MediaType.MUSIC, - 'playback': PlaybackType.AUDIO, - 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'uri': 'https://freemusicarchive.org/track/05_-_Quantum_Jazz_-_Passing_Fields/stream/', - 'title': 'Passing Fields', - 'artist': 'Quantum Jazz', - 'match_confidence': 65}, - {'media_type': MediaType.MUSIC, - 'playback': PlaybackType.AUDIO, - 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'uri': 'https://freemusicarchive.org/track/04_-_Quantum_Jazz_-_All_About_The_Sun/stream/', - 'title': 'All About The Sun', - 'artist': 'Quantum Jazz', - 'match_confidence': 65} -] - - -class TestMediaEntry(unittest.TestCase): - def test_update(self): - # TODO - pass - - def test_from_dict(self): - dict_data = valid_search_results[0] - from_dict = MediaEntry.from_dict(dict_data) - self.assertIsInstance(from_dict, MediaEntry) - from_init = MediaEntry(dict_data["title"], dict_data["uri"], - image=dict_data["image"], - match_confidence=dict_data["match_confidence"], - playback=PlaybackType.AUDIO, - skill_icon=dict_data["skill_icon"], - artist=dict_data["artist"]) - self.assertEqual(from_init, from_dict) - - self.assertIsInstance(MediaEntry.from_dict({}), MediaEntry) - - def test_info(self): - # TODO - pass - - def test_infocard(self): - # TODO - pass - - def test_mpris_metadata(self): - # TODO - pass - - def test_as_dict(self): - # TODO - pass - - def test_mimetype(self): - # TODO - pass - - -class TestPlaylist(unittest.TestCase): - def test_properties(self): - # Empty Playlist - pl = Playlist() - self.assertEqual(pl.position, 0) - self.assertEqual(pl.entries, []) - self.assertIsNone(pl.current_track) - self.assertTrue(pl.is_first_track) - self.assertTrue(pl.is_last_track) - - # Playlist of dicts - pl = Playlist(valid_search_results) - self.assertEqual(pl.position, 0) - self.assertEqual(len(pl.entries), len(valid_search_results)) - for entry in pl.entries: - self.assertIsInstance(entry, MediaEntry) - self.assertIsInstance(pl.current_track, MediaEntry) - self.assertTrue(pl.is_first_track) - self.assertFalse(pl.is_last_track) - - def test_goto_start(self): - # TODO - pass - - def test_clear(self): - # TODO - pass - - def test_sort_by_conf(self): - # TODO - pass - - def test_add_entry(self): - # TODO - pass - - def test_remove_entry(self): - # TODO - pass - - def test_replace(self): - # TODO - pass - - def test_set_position(self): - # TODO - pass - - def test_goto_track(self): - # TODO - pass - - def test_next_track(self): - # TODO - pass - - def test_prev_track(self): - # TODO - pass - - def test_validate_position(self): - # Test empty playlist - pl = Playlist() - pl._position = 0 - pl._validate_position() - self.assertEqual(pl.position, 0) - pl._position = -1 - pl._validate_position() - self.assertEqual(pl.position, 0) - pl._position = 1 - pl._validate_position() - self.assertEqual(pl.position, 0) - - # Test playlist of len 1 - pl = Playlist([valid_search_results[0]]) - pl._position = 0 - pl._validate_position() - self.assertEqual(pl.position, 0) - pl._position = 1 - pl._validate_position() - self.assertEqual(pl.position, 0) - - # Test playlist of len>1 - pl = Playlist(valid_search_results) - pl._position = 0 - pl._validate_position() - self.assertEqual(pl.position, 0) - pl._position = 1 - pl._validate_position() - self.assertEqual(pl.position, 1) - pl._position = 10 - pl._validate_position() - self.assertEqual(pl.position, 0) - - -class TestNowPlaying(unittest.TestCase): - def test(self): - pass - - -if __name__ == "__main__": - unittest.main() diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index c09c345..c57a8d7 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -3,42 +3,13 @@ from mycroft_bus_client import Message from ovos_utils.messagebus import FakeBus -from unittest.mock import Mock, MagicMock, patch +from unittest.mock import Mock, MagicMock -from ovos_plugin_common_play.ocp.media import MediaEntry -from ovos_plugin_common_play.ocp.status import MediaType, LoopState, \ - MediaState, PlaybackType, TrackState, PlayerState - -valid_search_results = [ - {'media_type': MediaType.MUSIC, - 'playback': PlaybackType.AUDIO, - 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'uri': 'https://freemusicarchive.org/track/07_-_Quantum_Jazz_-_Orbiting_A_Distant_Planet/stream/', - 'title': 'Orbiting A Distant Planet', - 'artist': 'Quantum Jazz', - 'match_confidence': 65}, - {'media_type': MediaType.MUSIC, - 'playback': PlaybackType.AUDIO, - 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'uri': 'https://freemusicarchive.org/track/05_-_Quantum_Jazz_-_Passing_Fields/stream/', - 'title': 'Passing Fields', - 'artist': 'Quantum Jazz', - 'match_confidence': 65}, - {'media_type': MediaType.MUSIC, - 'playback': PlaybackType.AUDIO, - 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', - 'uri': 'https://freemusicarchive.org/track/04_-_Quantum_Jazz_-_All_About_The_Sun/stream/', - 'title': 'All About The Sun', - 'artist': 'Quantum Jazz', - 'match_confidence': 65} -] +from ovos_plugin_common_play.ocp import OCP +from ovos_plugin_common_play.ocp.status import MediaType, PlayerState class TestOCP(unittest.TestCase): - from ovos_plugin_common_play import OCP bus = FakeBus() ocp = OCP(bus) @@ -202,1098 +173,5 @@ def test_should_resume(self): self.assertTrue(self.ocp._should_resume(empty_utt)) -class TestOCPPlayer(unittest.TestCase): - from ovos_plugin_common_play.ocp.player import OCPMediaPlayer - bus = FakeBus() - player = OCPMediaPlayer(bus) - emitted_msgs = [] - - @classmethod - def setUpClass(cls) -> None: - def get_msg(msg): - msg = Message.deserialize(msg) - cls.emitted_msgs.append(msg) - - cls.bus.on("message", get_msg) - - def test_00_player_init(self): - from ovos_plugin_common_play.ocp.gui import OCPMediaPlayerGUI - from ovos_plugin_common_play.ocp.search import OCPSearch - from ovos_plugin_common_play.ocp.media import NowPlaying, Playlist - from ovos_plugin_common_play.ocp.mpris import MprisPlayerCtl - from ovos_plugin_common_play.ocp.mycroft_cps import MycroftAudioService - from ovos_workshop import OVOSAbstractApplication - - self.assertIsInstance(self.player, OVOSAbstractApplication) - self.assertIsInstance(self.player.gui, OCPMediaPlayerGUI) - self.assertIsInstance(self.player.now_playing, NowPlaying) - self.assertIsInstance(self.player.media, OCPSearch) - self.assertIsInstance(self.player.playlist, Playlist) - self.assertIsInstance(self.player.settings, dict) - self.assertIsInstance(self.player.mpris, MprisPlayerCtl) - - self.assertEqual(self.player.state, PlayerState.STOPPED) - self.assertEqual(self.player.loop_state, LoopState.NONE) - self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) - self.assertEqual(self.player.track_history, dict()) - self.assertFalse(self.player.shuffle) - # self.assertIsNone(self.player.audio_service) - - # Testing `bind` method - self.assertEqual(self.player.now_playing._player, self.player) - self.assertEqual(self.player.media._player, self.player) - self.assertEqual(self.player.gui.player, self.player) - self.assertIsInstance(self.player.audio_service, MycroftAudioService) - - bus_events = ['recognizer_loop:record_begin', - 'recognizer_loop:record_end', - 'gui.player.media.service.sync.status', - "gui.player.media.service.get.next", - "gui.player.media.service.get.previous", - "gui.player.media.service.get.repeat", - "gui.player.media.service.get.shuffle", - 'ovos.common_play.player.state', - 'ovos.common_play.media.state', - 'ovos.common_play.play', - 'ovos.common_play.pause', - 'ovos.common_play.resume', - 'ovos.common_play.stop', - 'ovos.common_play.next', - 'ovos.common_play.previous', - 'ovos.common_play.seek', - 'ovos.common_play.get_track_length', - 'ovos.common_play.set_track_position', - 'ovos.common_play.get_track_position', - 'ovos.common_play.track_info', - 'ovos.common_play.list_backends', - 'ovos.common_play.playlist.set', - 'ovos.common_play.playlist.clear', - 'ovos.common_play.playlist.queue', - 'ovos.common_play.duck', - 'ovos.common_play.unduck', - 'ovos.common_play.shuffle.set', - 'ovos.common_play.shuffle.unset', - 'ovos.common_play.repeat.set', - 'ovos.common_play.repeat.unset', - 'ovos.common_play.gui.enable_app_timeout', - 'ovos.common_play.gui.set_app_timeout', - 'ovos.common_play.gui.timeout.mode' - ] - now_playing_events = ["ovos.common_play.track.state", - "ovos.common_play.media.state", - "ovos.common_play.play", - "ovos.common_play.playback_time", - 'gui.player.media.service.get.meta', - 'mycroft.audio.service.track_info_reply', - 'mycroft.audio.service.play', - 'mycroft.audio.playing_track' - ] - for event in bus_events: - expected_listeners = 1 - if event in now_playing_events: - expected_listeners += 1 - self.assertEqual(len(self.bus.ee.listeners(event)), - expected_listeners, event) - - # Test properties - self.assertEqual(self.player.active_skill, "ovos.common_play") - self.assertEqual(self.player.active_backend, PlaybackType.UNDEFINED) - self.assertEqual(self.player.tracks, list()) - self.assertEqual(self.player.disambiguation, list()) - self.assertFalse(self.player.can_prev) - self.assertFalse(self.player.can_next) - self.assertIsInstance(self.player.audio_service_player, str) - self.assertIsInstance(self.player.app_view_timeout_enabled, bool) - self.assertIsInstance(self.player.app_view_timeout_value, int) - self.assertIsInstance(self.player.app_view_timeout_mode, str) - - def test_set_media_state(self): - self.player.set_media_state(MediaState.UNKNOWN) - - # Emitted update on state change - self.player.set_media_state(MediaState.NO_MEDIA) - last_message = self.emitted_msgs[-1] - self.assertEqual(last_message.msg_type, "ovos.common_play.media.state") - self.assertEqual(last_message.data, {"state": MediaState.NO_MEDIA}) - self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) - - # No emit on same state - self.player.set_media_state(MediaState.NO_MEDIA) - self.assertEqual(last_message, self.emitted_msgs[-1]) - self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) - - # Test invalid state change - with self.assertRaises(TypeError): - self.player.set_media_state(1) - self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) - - def test_set_player_state(self): - real_update_props = self.player.mpris.update_props - self.player.mpris.update_props = Mock() - self.player.set_player_state(PlayerState.STOPPED) - - # Change to "Playing" - self.player.set_player_state(PlayerState.PLAYING) - self.assertEqual(self.player.state, PlayerState.PLAYING) - self.assertEqual(self.player.gui["status"], "Playing") - self.player.mpris.update_props.assert_called_with( - {"CanPause": True, "CanPlay": False, "PlaybackStatus": "Playing"}) - last_message = self.emitted_msgs[-1] - self.assertEqual(last_message.msg_type, "ovos.common_play.player.state") - self.assertEqual(last_message.data, {"state": PlayerState.PLAYING}) - - # Change to "Paused" - self.player.set_player_state(PlayerState.PAUSED) - self.assertEqual(self.player.state, PlayerState.PAUSED) - self.assertEqual(self.player.gui["status"], "Paused") - self.player.mpris.update_props.assert_called_with( - {"CanPause": False, "CanPlay": True, "PlaybackStatus": "Paused"}) - last_message = self.emitted_msgs[-1] - self.assertEqual(last_message.msg_type, "ovos.common_play.player.state") - self.assertEqual(last_message.data, {"state": PlayerState.PAUSED}) - - # Change to "Stopped" - self.player.set_player_state(PlayerState.STOPPED) - self.assertEqual(self.player.state, PlayerState.STOPPED) - self.assertEqual(self.player.gui["status"], "Stopped") - self.player.mpris.update_props.assert_called_with( - {"CanPause": False, "CanPlay": False, "PlaybackStatus": "Stopped"}) - last_message = self.emitted_msgs[-1] - self.assertEqual(last_message.msg_type, "ovos.common_play.player.state") - self.assertEqual(last_message.data, {"state": PlayerState.STOPPED}) - - # Request invalid change - with self.assertRaises(TypeError): - self.player.set_player_state("Paused") - self.assertEqual(last_message, self.emitted_msgs[-1]) - self.assertEqual(self.player.state, PlayerState.STOPPED) - - with self.assertRaises(TypeError): - self.player.set_player_state(2) - self.assertEqual(last_message, self.emitted_msgs[-1]) - self.assertEqual(self.player.state, PlayerState.STOPPED) - - self.player.mpris.update_props = real_update_props - - def test_set_now_playing(self): - real_update_props = self.player.mpris.update_props - real_update_track = self.player.gui.update_current_track - real_update_plist = self.player.gui.update_playlist - self.player.mpris.update_props = Mock() - self.player.gui.update_current_track = Mock() - self.player.gui.update_playlist = Mock() - - valid_dict = valid_search_results[0] - valid_track = MediaEntry.from_dict(valid_search_results[1]) - invalid_str = json.dumps(valid_search_results[2]) - track_no_uri = valid_search_results[2] - track_no_uri.pop('uri') - # TODO: Test playlist result - - # Play valid dict result - self.player.set_now_playing(valid_dict) - entry = MediaEntry.from_dict(valid_dict) - # self.assertEqual(self.player.now_playing.as_dict, valid_dict) - self.assertEqual(self.player.now_playing, entry) - self.assertEqual(self.player.playlist.current_track, entry) - self.assertEqual(self.player.playlist[-1], entry) - self.player.gui.update_current_track.assert_called_once() - self.player.gui.update_playlist.assert_called_once() - self.player.mpris.update_props.assert_called_once_with( - {"Metadata": self.player.now_playing.mpris_metadata} - ) - self.player.gui.update_current_track.reset_mock() - self.player.gui.update_playlist.reset_mock() - self.player.mpris.update_props.reset_mock() - - # Play valid MediaEntry result - self.player.set_now_playing(valid_track) - self.assertEqual(self.player.now_playing, valid_track) - self.assertEqual(self.player.playlist.current_track, valid_track) - self.assertEqual(self.player.playlist[-1], valid_track) - self.player.gui.update_current_track.assert_called_once() - self.player.gui.update_playlist.assert_called_once() - self.player.mpris.update_props.assert_called_once_with( - {"Metadata": self.player.now_playing.mpris_metadata}) - self.player.gui.update_current_track.reset_mock() - self.player.gui.update_playlist.reset_mock() - self.player.mpris.update_props.reset_mock() - - # Play invalid string result - with self.assertRaises(ValueError): - self.player.set_now_playing(invalid_str) - self.player.gui.update_current_track.assert_not_called() - self.player.gui.update_playlist.assert_not_called() - self.player.mpris.update_props.assert_not_called() - - # Play result with no URI - self.player.set_now_playing(track_no_uri) - self.player.gui.update_current_track.assert_called_once() - self.player.gui.update_playlist.assert_called_once() - self.player.mpris.update_props.assert_called_once_with( - {"Metadata": self.player.now_playing.mpris_metadata}) - - self.player.mpris.update_props = real_update_props - self.player.gui.update_current_track = real_update_track - self.player.gui.update_playlist = real_update_plist - - @patch("ovos_plugin_common_play.ocp.player.is_gui_running") - def test_validate_stream(self, gui_running): - real_update = self.player.gui.update_current_track - self.player.gui.update_current_track = Mock() - media_entry = MediaEntry.from_dict(valid_search_results[0]) - invalid_result = valid_search_results[1] - invalid_result.pop('uri') - invalid_entry = MediaEntry.from_dict(invalid_result) - - # Valid Entry - self.player.now_playing.update(media_entry) - - self.assertFalse(self.player.now_playing.is_cps) - self.assertEqual(self.player.now_playing.playback, - PlaybackType.AUDIO) - self.assertEqual(self.player.active_backend, PlaybackType.AUDIO) - - # Test with GUI - gui_running.return_value = True - self.assertTrue(self.player.validate_stream()) - self.assertEqual(self.player.gui["stream"], media_entry.uri) - self.player.gui.update_current_track.assert_called_once() - self.assertEqual(self.player.now_playing.playback, - PlaybackType.AUDIO) - - # Invalid Entry - self.player.now_playing.update(invalid_entry) - self.assertFalse(self.player.validate_stream()) - self.assertEqual(self.player.gui["stream"], media_entry.uri) - self.player.gui.update_current_track.assert_called_once() - - # Test without GUI - gui_running.return_value = False - self.player.gui.update_current_track.reset_mock() - self.player.gui["stream"] = None - self.player.now_playing.update(media_entry) - self.assertTrue(self.player.validate_stream()) - self.assertEqual(self.player.gui["stream"], media_entry.uri) - self.player.gui.update_current_track.assert_called_once() - self.assertEqual(self.player.now_playing.playback, - PlaybackType.AUDIO_SERVICE) - - # TODO: Test Skill playback and non-audio playback - self.player.gui.update_current_track = real_update - - def test_on_invalid_media(self): - real_play_next = self.player.play_next - real_show_error = self.player.gui.show_playback_error - self.player.play_next = Mock() - self.player.gui.show_playback_error = Mock() - - self.player.on_invalid_media() - self.player.play_next.assert_called_once() - self.player.gui.show_playback_error.assert_called_once() - - self.player.play_next = real_play_next - self.player.gui.show_playback_error = real_show_error - - def test_play_media(self): - real_stop = self.player.mpris.stop - real_pause = self.player.pause - real_gui_update = self.player.gui.update_search_results - real_play = self.player.play - real_set_now_playing = self.player.set_now_playing - self.player.mpris.stop = Mock() - self.player.pause = Mock() - self.player.gui.update_search_results = Mock() - self.player.play = Mock() - self.player.set_now_playing = Mock() - - results_as_entries = [MediaEntry.from_dict(d) - for d in valid_search_results] - results_as_entries.sort(key=lambda k: k.match_confidence, reverse=True) - - # Test invalid track - with self.assertRaises(TypeError): - self.player.play_media(valid_search_results) - with self.assertRaises(TypeError): - self.player.play_media(json.dumps(valid_search_results[0])) - - # Test track only - self.player.state = PlayerState.STOPPED - track = MediaEntry.from_dict(valid_search_results[0]) - self.player.play_media(track) - self.player.mpris.stop.assert_called_once() - self.player.pause.assert_not_called() - self.assertEqual(self.player.media.search_playlist.entries, list()) - self.player.gui.update_search_results.assert_not_called() - self.assertEqual(self.player.playlist.entries, list()) - self.player.set_now_playing.assert_called_once_with(track) - self.player.play.assert_called_once() - - self.player.mpris.stop.reset_mock() - self.player.set_now_playing.reset_mock() - self.player.play.reset_mock() - - # Test track with disambiguation - self.player.state = PlayerState.PAUSED - track = MediaEntry.from_dict(valid_search_results[0]) - self.player.play_media(track, valid_search_results) - self.player.mpris.stop.assert_called_once() - self.player.pause.assert_not_called() - - self.assertEqual(self.player.media.search_playlist.entries, - results_as_entries) - self.player.gui.update_search_results.assert_called_once() - self.assertEqual(self.player.playlist.entries, list()) - self.player.set_now_playing.assert_called_once_with(track) - self.player.play.assert_called_once() - - self.player.mpris.stop.reset_mock() - self.player.set_now_playing.reset_mock() - self.player.play.reset_mock() - self.player.gui.update_search_results.reset_mock() - - # Test track with playlist - self.player.state = PlayerState.PLAYING - self.player.media.search_playlist.clear() - track = MediaEntry.from_dict(valid_search_results[0]) - self.player.play_media(track, playlist=valid_search_results) - self.player.mpris.stop.assert_called_once() - self.player.pause.assert_called_once() - - self.assertEqual(self.player.media.search_playlist.entries, list()) - self.player.gui.update_search_results.assert_not_called() - self.assertEqual(self.player.playlist.entries, results_as_entries) - self.assertEqual(self.player.playlist.current_track, track) - self.player.set_now_playing.assert_called_once_with(track) - self.player.play.assert_called_once() - - self.player.set_now_playing = real_set_now_playing - self.player.play = real_play - self.player.gui.update_search_results = real_gui_update - self.player.pause = real_pause - self.player.mpris.stop = real_stop - - def test_get_preferred_audio_backend(self): - preferred = self.player._get_preferred_audio_backend() - self.assertIsInstance(preferred, str) - self.assertIn(preferred, - ["ovos_common_play", "vlc", "mplayer", "simple"]) - - @patch("ovos_plugin_common_play.ocp.player.is_gui_running") - def test_play(self, gui_running): - gui_running.return_value = True - real_update_props = self.player.mpris.update_props - real_stop = self.player.mpris.stop - real_validate_stream = self.player.validate_stream - real_show_player = self.player.gui.show_player - real_invalid = self.player.on_invalid_media - real_player_state = self.player.set_player_state - real_audio_service_play = self.player.audio_service.play - self.player.mpris.update_props = Mock() - self.player.validate_stream = Mock(return_value=False) - mpris_stop = self.player.mpris.stop_event - self.player.mpris.stop = Mock() - self.player.gui.show_player = Mock() - self.player.on_invalid_media = Mock() - self.player.track_history = dict() - self.player.set_player_state = Mock() - self.player.audio_service.play = Mock() - - # Test invalid stream - self.player.play() - self.player.mpris.stop.assert_called_once() - self.player.validate_stream.assert_called_once() - self.player.on_invalid_media.assert_called_once() - self.player.gui.show_player.assert_not_called() - - self.player.validate_stream.reset_mock() - self.player.validate_stream.return_value = True - - # Test invalid backend - self.player.now_playing.playback = PlaybackType.UNDEFINED - mpris_stop.set() - with self.assertRaises(ValueError): - self.player.play() - self.player.validate_stream.assert_called_once() - self.player.mpris.update_props.assert_not_called() - - # TODO: Should the GUI be displayed and track history updated for - # invalid playback requests? - self.player.gui.show_player.assert_called_once() - self.assertEqual(set(self.player.track_history.keys()), {''}) - - self.player.gui.show_player.reset_mock() - self.player.validate_stream.reset_mock() - - # Test valid audio with gui - media = MediaEntry.from_dict(valid_search_results[0]) - media.playback = PlaybackType.AUDIO - self.player.now_playing.update(media) - mpris_stop.set() - self.player.play() - self.player.mpris.stop.assert_called_once() - self.player.validate_stream.assert_called_once() - self.player.on_invalid_media.assert_called_once() - self.player.gui.show_player.assert_called_once() - self.assertEqual(set(self.player.track_history.keys()), {'', media.uri}) - self.assertEqual(self.player.track_history[media.uri], 1) - last_message = self.emitted_msgs[-1] - second_last_message = self.emitted_msgs[-2] - self.assertEqual(last_message.msg_type, "ovos.common_play.track.state") - self.assertEqual(last_message.data, {"state": TrackState.PLAYING_AUDIO}) - self.assertEqual(second_last_message.msg_type, - "gui.player.media.service.play") - self.assertEqual(second_last_message.data, - {"track": media.uri, "mime": list(media.mimetype), - "repeat": False}) - - self.player.mpris.stop.reset_mock() - self.player.validate_stream.reset_mock() - self.player.gui.show_player.reset_mock() - - # Test valid audio without gui (AudioService) - gui_running.return_value = False - self.player.mpris.stop_event.clear() - self.player.play() - self.player.mpris.stop.assert_called_once() - self.player.validate_stream.assert_called_once() - self.player.on_invalid_media.assert_called_once() - self.player.gui.show_player.assert_called_once() - self.assertEqual(set(self.player.track_history.keys()), {'', media.uri}) - self.assertEqual(self.player.track_history[media.uri], 2) - self.assertEqual(self.player.active_backend, PlaybackType.AUDIO_SERVICE) - self.player.set_player_state.assert_called_once_with( - PlayerState.PLAYING) - self.player.audio_service.play.assert_called_once_with( - media.uri, utterance=self.player.audio_service_player) - last_message = self.emitted_msgs[-1] - self.assertEqual(last_message.msg_type, "ovos.common_play.track.state") - self.assertEqual(last_message.data, - {"state": TrackState.PLAYING_AUDIOSERVICE}) - - # TODO: Test Skill, Video, Webview - - self.player.on_invalid_media = real_invalid - self.player.gui.show_player = real_show_player - self.player.mpris.stop = real_stop - self.player.validate_stream = real_validate_stream - self.player.mpris.update_props = real_update_props - self.player.set_player_state = real_player_state - self.player.audio_service.play = real_audio_service_play - - def test_play_shuffle(self): - # TODO - pass - - def test_play_next(self): - real_mpris = self.player.mpris.play_next - real_pause = self.player.pause - real_play = self.player.play - real_shuffle = self.player.play_shuffle - real_gui_end = self.player.gui.handle_end_of_playback - - self.player.mpris.play_next = Mock() - self.player.pause = Mock() - self.player.play = Mock() - self.player.play_shuffle = Mock() - self.player.gui.handle_end_of_playback = Mock() - - # MPRIS Next - self.player.now_playing.playback = PlaybackType.MPRIS - self.player.play_next() - self.player.mpris.play_next.assert_called_once() - - # Skill Next - self.player.now_playing.playback = PlaybackType.SKILL - self.player.play_next() - last_message = self.emitted_msgs[-1] - self.assertEqual( - last_message.msg_type, - f"ovos.common_play.{self.player.now_playing.skill_id}.next") - - # Repeat Track - self.player.now_playing.playback = PlaybackType.AUDIO - self.player.loop_state = LoopState.REPEAT_TRACK - self.player.play_next() - self.player.pause.assert_called_once() - self.player.play.assert_called_once() - - # Shuffle - self.player.pause.reset_mock() - self.player.play.reset_mock() - self.player.loop_state = LoopState.NONE - self.player.shuffle = True - self.player.play_next() - self.player.pause.assert_called_once() - self.player.play_shuffle.assert_called_once() - self.player.play.assert_called_once() - - # Playlist next track - self.player.pause.reset_mock() - self.player.play.reset_mock() - self.player.shuffle = False - self.player.playlist.replace(valid_search_results) - self.player.play_next() - self.player.pause.assert_called_once() - self.player.play.assert_called_once() - self.assertEqual(self.player.now_playing, self.player.playlist[1]) - - # Playlist repeat - self.player.loop_state = LoopState.REPEAT - self.player.playlist.set_position(len(self.player.playlist) - 1) - self.player.pause.reset_mock() - self.player.play.reset_mock() - self.player.play_next() - self.player.pause.assert_called_once() - self.player.play.assert_called_once() - self.assertTrue(self.player.playlist.is_first_track) - - # Playlist no repeat - self.player.loop_state = LoopState.NONE - self.player.playlist.set_position(len(self.player.playlist) - 1) - self.player.pause.reset_mock() - self.player.play.reset_mock() - self.player.play_next() - self.player.pause.assert_called_once() - self.player.play.assert_not_called() - self.player.gui.handle_end_of_playback.assert_called_once() - - # Search results next track - self.player.gui.handle_end_of_playback.reset_mock() - self.player.playlist.clear() - self.player.media.search_playlist.replace(valid_search_results) - self.assertEqual(len(self.player.playlist), 0) - self.player.pause.reset_mock() - self.player.play.reset_mock() - self.player.play_next() - self.player.pause.assert_called_once() - self.player.play.assert_called_once() - self.player.gui.handle_end_of_playback.assert_not_called() - - # Search results end - self.player.pause.reset_mock() - self.player.play.reset_mock() - # TODO: should we repeat search results, or skip adding them to the playlist - self.player.playlist.clear() - self.player.media.search_playlist.set_position( - len(self.player.media.search_playlist) - 1) - self.assertTrue(self.player.media.search_playlist.is_last_track) - self.assertEqual(len(self.player.playlist), 0) - self.player.loop_state = LoopState.REPEAT - self.player.play_next() - self.player.pause.assert_called_once() - self.player.play.assert_not_called() - self.player.gui.handle_end_of_playback.assert_called_once() - - # TODO: Test with `merge_search` set to False - - self.player.mpris.play_next.assert_called_once() - - self.player.gui.handle_end_of_playback = real_gui_end - self.player.play_shuffle = real_shuffle - self.player.play = real_play - self.player.pause = real_pause - self.player.mpris.play_next = real_mpris - - def test_play_prev(self): - # TODO - pass - - def test_pause(self): - real_audio_pause = self.player.audio_service.pause - real_player_pause = self.player.mpris.pause - real_player_state = self.player.set_player_state - - self.player.audio_service.pause = Mock() - self.player.mpris.pause = Mock() - self.player.set_player_state = Mock() - - # Test Audio service Pause - self.player._paused_on_duck = True - self.player.now_playing.playback = PlaybackType.AUDIO_SERVICE - self.player.pause() - self.player.audio_service.pause.assert_called_once() - self.assertFalse(self.player._paused_on_duck) - self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) - - # Test GUI Pause - self.player.set_player_state.reset_mock() - self.player._paused_on_duck = True - self.player.now_playing.playback = PlaybackType.AUDIO - self.player.pause() - self.assertFalse(self.player._paused_on_duck) - self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) - last_message = self.emitted_msgs[-1] - self.assertEqual(last_message.msg_type, - "gui.player.media.service.pause") - - # Test Skill Pause - self.player.set_player_state.reset_mock() - self.player._paused_on_duck = True - self.player.now_playing.playback = PlaybackType.SKILL - self.player.pause() - self.assertFalse(self.player._paused_on_duck) - self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) - last_message = self.emitted_msgs[-1] - self.assertEqual(last_message.msg_type, - f"ovos.common_play.{self.player.active_skill}.pause") - - # Test MPRIS Pause - self.player.set_player_state.reset_mock() - self.player._paused_on_duck = True - self.player.now_playing.playback = PlaybackType.MPRIS - self.player.pause() - self.assertFalse(self.player._paused_on_duck) - self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) - self.player.mpris.pause.assert_called_once() - - # TODO: Test Undefined playback - - self.player.audio_service.pause.assert_called_once() - - self.player.audio_service.pause = real_audio_pause - self.player.mpris.pause = real_player_pause - self.player.set_player_state = real_player_state - - def test_resume(self): - real_audio_resume = self.player.audio_service.resume - real_player_resume = self.player.mpris.resume - real_player_state = self.player.set_player_state - - self.player.audio_service.resume = Mock() - self.player.mpris.resume = Mock() - self.player.set_player_state = Mock() - - # Test Audio service Resume - self.player.now_playing.playback = PlaybackType.AUDIO_SERVICE - self.player.resume() - self.player.audio_service.resume.assert_called_once() - self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) - - # Test GUI Resume - self.player.set_player_state.reset_mock() - self.player.now_playing.playback = PlaybackType.AUDIO - self.player.resume() - self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) - last_message = self.emitted_msgs[-1] - self.assertEqual(last_message.msg_type, - "gui.player.media.service.resume") - - # Test Skill Resume - self.player.set_player_state.reset_mock() - self.player.now_playing.playback = PlaybackType.SKILL - self.player.resume() - self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) - last_message = self.emitted_msgs[-1] - self.assertEqual(last_message.msg_type, - f"ovos.common_play.{self.player.active_skill}.resume") - - # Test MPRIS Resume - self.player.set_player_state.reset_mock() - self.player.now_playing.playback = PlaybackType.MPRIS - self.player.resume() - self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) - self.player.mpris.resume.assert_called_once() - - # TODO: Test Undefined playback - - self.player.audio_service.resume.assert_called_once() - - self.player.audio_service.resume = real_audio_resume - self.player.mpris.resume = real_player_resume - self.player.set_player_state = real_player_state - - def test_seek(self): - real_method = self.player.audio_service.set_track_position - mock_method = Mock() - self.player.audio_service.set_track_position = mock_method - - # Audio Service - self.player.now_playing.playback = PlaybackType.AUDIO_SERVICE - test_pos = 1234 - self.player.seek(test_pos) - mock_method.assert_called_once_with(1.234) - self.assertEqual(self.player.gui["position"], test_pos) - - # Audio - self.player.now_playing.playback = PlaybackType.AUDIO - test_pos = 10000 - self.player.seek(test_pos) - mock_method.assert_called_once_with(1.234) - self.assertEqual(self.player.gui["position"], test_pos) - - # Undefined - self.player.now_playing.playback = PlaybackType.UNDEFINED - test_pos = 999 - self.player.seek(test_pos) - mock_method.assert_called_with(0.999) - self.assertEqual(self.player.gui["position"], test_pos) - - self.player.audio_service.set_track_position = real_method - - def test_stop(self): - # TODO - pass - - def test_stop_gui_player(self): - self.player.stop_gui_player() - message = self.emitted_msgs[-1] - self.assertEqual(message.msg_type, "gui.player.media.service.stop") - - def test_stop_audio_skill(self): - self.player.stop_audio_skill() - message = self.emitted_msgs[-1] - self.assertEqual(message.msg_type, - f"ovos.common_play.{self.player.active_skill}.stop") - - def test_stop_audio_service(self): - real_stop = self.player.audio_service.stop - self.player.audio_service.stop = Mock() - self.player.stop_audio_service() - self.player.audio_service.stop.assert_called_once() - - self.player.audio_service.stop = real_stop - - def test_reset(self): - real_stop = self.player.stop - self.player.stop = Mock() - - self.player.reset() - self.player.stop.assert_called_once() - self.assertEqual(self.player.playlist.entries, list()) - self.assertIsNone(self.player.playlist.current_track) - self.assertEqual(self.player.media.search_playlist, list()) - self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) - # TODO: Should this update player state? - # self.assertEqual(self.player.state, PlayerState.STOPPED) - self.assertFalse(self.player.shuffle) - self.assertEqual(self.player.loop_state, LoopState.NONE) - - self.player.stop = real_stop - - def test_shutdown(self): - # TODO - pass - - def test_handle_player_state_update(self): - real_view_timeout = self.player.gui.cancel_app_view_timeout - real_pause_timeout = self.player.gui.schedule_app_view_pause_timeout - real_update_props = self.player.mpris.update_props - view_timeout = Mock() - view_pause = Mock() - update_props = Mock() - self.player.gui.cancel_app_view_timeout = view_timeout - self.player.gui.schedule_app_view_pause_timeout = view_pause - self.player.mpris.update_props = update_props - - self.player.settings['app_view_timeout_mode'] = "pause" - self.player.settings['app_view_timeout_enabled'] = False - - # Invalid requests - with self.assertRaises(ValueError): - self.player.handle_player_state_update( - Message("", {"not_state": None})) - with self.assertRaises(ValueError): - self.player.handle_player_state_update( - Message("", {"state": None})) - with self.assertRaises(ValueError): - self.player.handle_player_state_update( - Message("", {"state": "Playing"})) - view_timeout.assert_not_called() - view_pause.assert_not_called() - update_props.assert_not_called() - - # State not changed - self.player.state = PlayerState.PLAYING - self.player.handle_player_state_update( - Message("", {"state": PlayerState.PLAYING})) - self.player.handle_player_state_update( - Message("", {"state": int(PlayerState.PLAYING)})) - view_timeout.assert_not_called() - view_pause.assert_not_called() - update_props.assert_not_called() - self.assertEqual(self.player.state, PlayerState.PLAYING) - - # Pause no GUI change - self.player.handle_player_state_update( - Message("", {"state": PlayerState.PAUSED})) - view_timeout.assert_not_called() - view_pause.assert_not_called() - update_props.assert_called_with({"CanPause": False, - "CanPlay": True, - "PlaybackStatus": "Paused"}) - self.assertEqual(self.player.state, PlayerState.PAUSED) - - # Play - self.player.handle_player_state_update( - Message("", {"state": PlayerState.PLAYING})) - view_timeout.assert_not_called() - view_pause.assert_not_called() - update_props.assert_called_with({"CanPause": True, - "CanPlay": False, - "PlaybackStatus": "Playing"}) - self.assertEqual(self.player.state, PlayerState.PLAYING) - - # Pause with GUI Change - self.player.settings['app_view_timeout_enabled'] = True - self.player.handle_player_state_update( - Message("", {"state": PlayerState.PAUSED})) - view_timeout.assert_called_once() - view_pause.assert_called_once() - update_props.assert_called_with({"CanPause": False, - "CanPlay": True, - "PlaybackStatus": "Paused"}) - self.assertEqual(self.player.state, PlayerState.PAUSED) - - # Stop - self.player.handle_player_state_update( - Message("", {"state": PlayerState.STOPPED})) - view_timeout.assert_called_once() - view_pause.assert_called_once() - update_props.assert_called_with({"CanPause": False, - "CanPlay": False, - "PlaybackStatus": "Stopped"}) - self.assertEqual(self.player.state, PlayerState.STOPPED) - - self.player.gui.cancel_app_view_timeout = real_view_timeout - self.player.gui.schedule_app_view_pause_timeout = real_pause_timeout - self.player.mpris.update_props = real_update_props - - def test_handle_player_media_update(self): - real_handle_playback_ended = self.player.handle_playback_ended - real_handle_invalid_media = self.player.handle_invalid_media - real_play_next = self.player.play_next - - self.player.play_next = Mock() - self.player.handle_playback_ended = Mock() - self.player.handle_invalid_media = Mock() - self.player.media_state = MediaState.UNKNOWN - - # Invalid requests - with self.assertRaises(ValueError): - self.player.handle_player_media_update( - Message("", {"not_state": None})) - with self.assertRaises(ValueError): - self.player.handle_player_media_update( - Message("", {"state": None})) - with self.assertRaises(ValueError): - self.player.handle_player_media_update( - Message("", {"state": "UNKNOWN"})) - self.player.handle_playback_ended.assert_not_called() - self.player.handle_invalid_media.assert_not_called() - self.assertEqual(self.player.media_state, MediaState.UNKNOWN) - - # State not changed - self.player.handle_player_media_update( - Message("", {"state": MediaState.UNKNOWN})) - self.assertEqual(self.player.media_state, MediaState.UNKNOWN) - self.player.handle_player_media_update( - Message("", {"state": 0})) - self.assertEqual(self.player.media_state, MediaState.UNKNOWN) - self.player.handle_playback_ended.assert_not_called() - self.player.handle_invalid_media.assert_not_called() - self.player.play_next.assert_not_called() - - # Valid state changes - self.player.handle_player_media_update( - Message("", {"state": MediaState.NO_MEDIA})) - self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) - self.player.handle_player_media_update( - Message("", {"state": MediaState.LOADING_MEDIA})) - self.assertEqual(self.player.media_state, MediaState.LOADING_MEDIA) - self.player.handle_player_media_update( - Message("", {"state": MediaState.STALLED_MEDIA})) - self.assertEqual(self.player.media_state, MediaState.STALLED_MEDIA) - self.player.handle_player_media_update( - Message("", {"state": MediaState.BUFFERING_MEDIA})) - self.assertEqual(self.player.media_state, MediaState.BUFFERING_MEDIA) - self.player.handle_player_media_update( - Message("", {"state": MediaState.BUFFERED_MEDIA})) - self.assertEqual(self.player.media_state, MediaState.BUFFERED_MEDIA) - self.player.handle_playback_ended.assert_not_called() - self.player.handle_invalid_media.assert_not_called() - self.player.play_next.assert_not_called() - - self.player.handle_player_media_update( - Message("", {"state": MediaState.END_OF_MEDIA})) - self.assertEqual(self.player.media_state, MediaState.END_OF_MEDIA) - self.player.handle_playback_ended.assert_called_once() - self.player.handle_invalid_media.assert_not_called() - self.player.play_next.assert_not_called() - - self.player.handle_player_media_update( - Message("", {"state": MediaState.INVALID_MEDIA})) - self.assertEqual(self.player.media_state, MediaState.INVALID_MEDIA) - self.player.handle_playback_ended.assert_called_once() - self.player.handle_invalid_media.assert_called_once() - self.player.play_next.assert_called_once() - # TODO: Test without autoplay - - self.player.play_next = real_play_next - self.player.handle_playback_ended = real_handle_playback_ended - self.player.handle_invalid_media = real_handle_invalid_media - - def test_handle_invalid_media(self): - # TODO - pass - - def test_handle_playback_ended(self): - # TODO - pass - - def test_handle_play_request(self): - # TODO - pass - - def test_handle_pause_request(self): - # TODO - pass - - def test_handle_stop_request(self): - # TODO - pass - - def test_handle_resume_request(self): - # TODO - pass - - def test_handle_seek_request(self): - # TODO - pass - - def test_handle_next_request(self): - # TODO - pass - - def test_handle_prev_request(self): - # TODO - pass - - def test_handle_set_shuffle(self): - # TODO - pass - - def test_handle_unset_shuffle(self): - # TODO - pass - - def test_handle_set_repeat(self): - # TODO - pass - - def test_handle_unset_repeat(self): - # TODO - pass - - def test_handle_repeat_toggle_request(self): - # TODO - pass - - def test_handle_shuffle_toggle_request(self): - # TODO - pass - - def test_handle_playlist_set_request(self): - # TODO - pass - - def test_handle_playlist_queue_request(self): - # TODO - pass - - def test_handle_playlist_clear_request(self): - # TODO - pass - - def test_handle_duck_request(self): - real_pause = self.player.pause - self.player.pause = Mock() - - # Duck already paused - self.player._paused_on_duck = False - self.player.state = PlayerState.PAUSED - self.player.handle_duck_request(None) - self.player.pause.assert_not_called() - self.assertFalse(self.player._paused_on_duck) - - # Duck while stopped - self.player.state = PlayerState.STOPPED - self.player.handle_duck_request(None) - self.player.pause.assert_not_called() - self.assertFalse(self.player._paused_on_duck) - - # Duck while playing - self.player.state = PlayerState.PLAYING - self.player.handle_duck_request(None) - self.player.pause.assert_called_once() - self.assertTrue(self.player._paused_on_duck) - - self.player.pause = real_pause - - def test_handle_unduck_request(self): - real_resume = self.player.resume - self.player.resume = Mock() - self.player._paused_on_duck = False - - # Unduck already playing - self.player.state = PlayerState.PLAYING - self.player.handle_unduck_request(None) - self.player.resume.assert_not_called() - self.assertFalse(self.player._paused_on_duck) - - # Unduck while stopped - self.player.state = PlayerState.STOPPED - self.player.handle_unduck_request(None) - self.player.resume.assert_not_called() - self.assertFalse(self.player._paused_on_duck) - - # Unduck paused (not from duck) - self.player.state = PlayerState.PAUSED - self.player.handle_unduck_request(None) - self.player.resume.assert_not_called() - self.assertFalse(self.player._paused_on_duck) - - # Unduck paused on duck - self.player._paused_on_duck = True - self.player.state = PlayerState.PAUSED - self.player.handle_unduck_request(None) - self.player.resume.assert_called_once() - self.assertFalse(self.player._paused_on_duck) - - self.player.resume = real_resume - - def test_handle_track_length_request(self): - # TODO - pass - - def test_handle_track_position_request(self): - # TODO - pass - - def test_handle_set_track_position_request(self): - # TODO - pass - - def test_handle_track_info_request(self): - # TODO - pass - - def test_handle_list_backends_request(self): - # TODO - pass - - def test_handle_enable_app_timeout(self): - # TODO - pass - - def test_handle_set_app_timeout(self): - # TODO - pass - - def test_handle_set_app_timeout_mode(self): - # TODO - pass - - if __name__ == "__main__": unittest.main() diff --git a/test/unittests/test_ocp_media.py b/test/unittests/test_ocp_media.py new file mode 100644 index 0000000..3ec3d62 --- /dev/null +++ b/test/unittests/test_ocp_media.py @@ -0,0 +1,342 @@ +import unittest +from unittest.mock import Mock + +from mycroft_bus_client import Message + +from ovos_plugin_common_play.ocp.media import MediaEntry, Playlist, NowPlaying +from ovos_plugin_common_play.ocp.status import MediaType, PlaybackType, TrackState, MediaState +from ovos_utils.messagebus import FakeBus + + +valid_search_results = [ + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/07_-_Quantum_Jazz_-_Orbiting_A_Distant_Planet/stream/', + 'title': 'Orbiting A Distant Planet', + 'artist': 'Quantum Jazz', + 'match_confidence': 65}, + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/05_-_Quantum_Jazz_-_Passing_Fields/stream/', + 'title': 'Passing Fields', + 'artist': 'Quantum Jazz', + 'match_confidence': 65}, + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/04_-_Quantum_Jazz_-_All_About_The_Sun/stream/', + 'title': 'All About The Sun', + 'artist': 'Quantum Jazz', + 'match_confidence': 65} +] + + +class TestMediaEntry(unittest.TestCase): + def test_update(self): + # TODO + pass + + def test_from_dict(self): + dict_data = valid_search_results[0] + from_dict = MediaEntry.from_dict(dict_data) + self.assertIsInstance(from_dict, MediaEntry) + from_init = MediaEntry(dict_data["title"], dict_data["uri"], + image=dict_data["image"], + match_confidence=dict_data["match_confidence"], + playback=PlaybackType.AUDIO, + skill_icon=dict_data["skill_icon"], + artist=dict_data["artist"]) + self.assertEqual(from_init, from_dict) + + self.assertIsInstance(MediaEntry.from_dict({}), MediaEntry) + + def test_info(self): + # TODO + pass + + def test_infocard(self): + # TODO + pass + + def test_mpris_metadata(self): + # TODO + pass + + def test_as_dict(self): + # TODO + pass + + def test_mimetype(self): + # TODO + pass + + +class TestPlaylist(unittest.TestCase): + def test_properties(self): + # Empty Playlist + pl = Playlist() + self.assertEqual(pl.position, 0) + self.assertEqual(pl.entries, []) + self.assertIsNone(pl.current_track) + self.assertTrue(pl.is_first_track) + self.assertTrue(pl.is_last_track) + + # Playlist of dicts + pl = Playlist(valid_search_results) + self.assertEqual(pl.position, 0) + self.assertEqual(len(pl.entries), len(valid_search_results)) + for entry in pl.entries: + self.assertIsInstance(entry, MediaEntry) + self.assertIsInstance(pl.current_track, MediaEntry) + self.assertTrue(pl.is_first_track) + self.assertFalse(pl.is_last_track) + + def test_goto_start(self): + # TODO + pass + + def test_clear(self): + # TODO + pass + + def test_sort_by_conf(self): + # TODO + pass + + def test_add_entry(self): + # TODO + pass + + def test_remove_entry(self): + # TODO + pass + + def test_replace(self): + # TODO + pass + + def test_set_position(self): + # TODO + pass + + def test_goto_track(self): + # TODO + pass + + def test_next_track(self): + # TODO + pass + + def test_prev_track(self): + # TODO + pass + + def test_validate_position(self): + # Test empty playlist + pl = Playlist() + pl._position = 0 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl._position = -1 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl._position = 1 + pl._validate_position() + self.assertEqual(pl.position, 0) + + # Test playlist of len 1 + pl = Playlist([valid_search_results[0]]) + pl._position = 0 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl._position = 1 + pl._validate_position() + self.assertEqual(pl.position, 0) + + # Test playlist of len>1 + pl = Playlist(valid_search_results) + pl._position = 0 + pl._validate_position() + self.assertEqual(pl.position, 0) + pl._position = 1 + pl._validate_position() + self.assertEqual(pl.position, 1) + pl._position = 10 + pl._validate_position() + self.assertEqual(pl.position, 0) + + +class TestNowPlaying(unittest.TestCase): + from ovos_plugin_common_play.ocp import OCPMediaPlayer + + bus = FakeBus() + ocp = OCPMediaPlayer(bus) + player = ocp.now_playing + + def test_init_bind_shutdown(self): + now_playing = NowPlaying() + self.assertIsInstance(now_playing, NowPlaying) + self.assertIsInstance(now_playing, MediaEntry) + + # Bind OCP + now_playing.bind(self.ocp) + self.assertEqual(now_playing._player, self.ocp) + self.assertEqual(now_playing.bus, self.bus) + self.assertEqual(now_playing._settings, self.ocp.settings) + + + # TODO: Improve tests for event registration + events = [ + "ovos.common_play.track.state", + "ovos.common_play.playback_time", + "gui.player.media.service.get.meta", + "mycroft.audio.service.track_info_reply", + ] + media_events = [ + "ovos.common_play.media.state", + "ovos.common_play.play", + "mycroft.audio.service.play", + "mycroft.audio.playing_track" + ] + # Check event registration + for event in media_events: + self.assertGreaterEqual(len(self.bus.ee.listeners(event)), 1) + for event in events: + self.assertGreaterEqual(len(self.bus.ee.listeners(event)), 1) + + # Check shutdown + now_playing.shutdown() + for event in events: + self.assertLessEqual(len(self.bus.ee.listeners(event)), 2) + + def test_as_entry(self): + entry = MediaEntry.from_dict(valid_search_results[0]) + player = NowPlaying() + player.update(entry) + self.assertNotIsInstance(player.as_entry(), NowPlaying) + self.assertIsInstance(player.as_entry(), MediaEntry) + self.assertEqual(player.as_entry(), entry) + + def test_reset(self): + entry = MediaEntry.from_dict(valid_search_results[0]) + self.player.update(entry) + self.assertEqual(self.player.as_entry(), entry) + + self.assertNotEqual(self.player.title, "") + self.assertNotEqual(self.player.artist, None) + self.assertNotEqual(self.player.skill_icon, None) + self.assertNotEqual(self.player.skill_id, None) + # self.assertNotEqual(self.player.position, 0) + # self.assertNotEqual(self.player.length, None) + # self.assertNotEqual(self.player.is_cps, False) + # self.assertNotEqual(self.player.cps_data, dict()) + self.assertNotEqual(self.player.data, dict()) + # self.assertNotEqual(self.player.phrase, None) + # self.assertNotEqual(self.player.javascript, "") + self.assertNotEqual(self.player.playback, PlaybackType.UNDEFINED) + # self.assertNotEqual(self.player.status, TrackState.DISAMBIGUATION) + + self.player.reset() + self.assertEqual(self.player.title, "") + self.assertEqual(self.player.artist, None) + self.assertEqual(self.player.skill_icon, None) + self.assertEqual(self.player.skill_id, None) + self.assertEqual(self.player.position, 0) + self.assertEqual(self.player.length, None) + self.assertEqual(self.player.is_cps, False) + self.assertEqual(self.player.cps_data, dict()) + self.assertEqual(self.player.data, dict()) + self.assertEqual(self.player.phrase, None) + self.assertEqual(self.player.javascript, "") + self.assertEqual(self.player.playback, PlaybackType.UNDEFINED) + self.assertEqual(self.player.status, TrackState.DISAMBIGUATION) + + def test_update(self): + # TODO + pass + + def test_extract_stream(self): + # TODO + pass + + def test_handle_external_play(self): + # TODO + pass + + def test_handle_player_metadata_request(self): + # TODO + pass + + def test_handle_track_state_change(self): + self.player.status = TrackState.DISAMBIGUATION + + # Test invalid update + with self.assertRaises(ValueError): + self.player.handle_track_state_change( + Message("", {"state": "PLAYING_AUDIO"})) + with self.assertRaises(ValueError): + self.player.handle_track_state_change( + Message("", {"stat": "PLAYING_AUDIO"})) + + # Test int update + self.player.handle_track_state_change( + Message("", {"state": int(TrackState.PLAYING_AUDIO)})) + self.assertEqual(self.player.status, TrackState.PLAYING_AUDIO) + + # Test TrackState update + self.player.handle_track_state_change( + Message("", {"state": TrackState.PLAYING_SKILL})) + self.assertEqual(self.player.status, TrackState.PLAYING_SKILL) + + def test_handle_media_state_change(self): + real_reset = self.player.reset + self.player.reset = Mock() + + # Test invalid update + with self.assertRaises(ValueError): + self.player.handle_media_state_change( + Message("", {"state": "END_OF_MEDIA"})) + with self.assertRaises(ValueError): + self.player.handle_media_state_change( + Message("", {"stat": "END_OF_MEDIA"})) + + # Test int update + self.player.handle_media_state_change( + Message("", {"state": int(MediaState.NO_MEDIA)})) + + # Test MediaState update + self.player.handle_media_state_change( + Message("", {"state": MediaState.BUFFERED_MEDIA})) + + # Test END_OF_MEDIA + self.player.reset.assert_not_called() + self.player.handle_media_state_change( + Message("", {"state": MediaState.END_OF_MEDIA})) + self.player.reset.assert_called_once() + + self.player.reset = real_reset + + def test_handle_sync_seekbar(self): + # TODO + pass + + def test_handle_sync_trackinfo(self): + # TODO + pass + + def test_handle_audio_service_play(self): + # TODO + pass + + def test_handle_audio_service_play_start(self): + # TODO + pass + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unittests/test_ocp_player.py b/test/unittests/test_ocp_player.py new file mode 100644 index 0000000..377f757 --- /dev/null +++ b/test/unittests/test_ocp_player.py @@ -0,0 +1,1135 @@ +import json +import unittest + +from mycroft_bus_client import Message +from ovos_utils.messagebus import FakeBus +from unittest.mock import Mock, patch + +from ovos_plugin_common_play.ocp.player import OCPMediaPlayer +from ovos_plugin_common_play.ocp.media import MediaEntry +from ovos_plugin_common_play.ocp.status import MediaType, LoopState, \ + MediaState, PlaybackType, TrackState, PlayerState + + +valid_search_results = [ + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/07_-_Quantum_Jazz_-_Orbiting_A_Distant_Planet/stream/', + 'title': 'Orbiting A Distant Planet', + 'artist': 'Quantum Jazz', + 'match_confidence': 65}, + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/05_-_Quantum_Jazz_-_Passing_Fields/stream/', + 'title': 'Passing Fields', + 'artist': 'Quantum Jazz', + 'match_confidence': 65}, + {'media_type': MediaType.MUSIC, + 'playback': PlaybackType.AUDIO, + 'image': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'skill_icon': 'https://freemusicarchive.org/legacy/fma-smaller.jpg', + 'uri': 'https://freemusicarchive.org/track/04_-_Quantum_Jazz_-_All_About_The_Sun/stream/', + 'title': 'All About The Sun', + 'artist': 'Quantum Jazz', + 'match_confidence': 65} +] + + +class TestOCPPlayer(unittest.TestCase): + bus = FakeBus() + player = OCPMediaPlayer(bus) + emitted_msgs = [] + + @classmethod + def setUpClass(cls) -> None: + def get_msg(msg): + msg = Message.deserialize(msg) + cls.emitted_msgs.append(msg) + + cls.bus.on("message", get_msg) + + def test_00_player_init(self): + from ovos_plugin_common_play.ocp.gui import OCPMediaPlayerGUI + from ovos_plugin_common_play.ocp.search import OCPSearch + from ovos_plugin_common_play.ocp.media import NowPlaying, Playlist + from ovos_plugin_common_play.ocp.mpris import MprisPlayerCtl + from ovos_plugin_common_play.ocp.mycroft_cps import MycroftAudioService + from ovos_workshop import OVOSAbstractApplication + + self.assertIsInstance(self.player, OVOSAbstractApplication) + self.assertIsInstance(self.player.gui, OCPMediaPlayerGUI) + self.assertIsInstance(self.player.now_playing, NowPlaying) + self.assertIsInstance(self.player.media, OCPSearch) + self.assertIsInstance(self.player.playlist, Playlist) + self.assertIsInstance(self.player.settings, dict) + self.assertIsInstance(self.player.mpris, MprisPlayerCtl) + + self.assertEqual(self.player.state, PlayerState.STOPPED) + self.assertEqual(self.player.loop_state, LoopState.NONE) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + self.assertEqual(self.player.track_history, dict()) + self.assertFalse(self.player.shuffle) + # self.assertIsNone(self.player.audio_service) + + # Testing `bind` method + self.assertEqual(self.player.now_playing._player, self.player) + self.assertEqual(self.player.media._player, self.player) + self.assertEqual(self.player.gui.player, self.player) + self.assertIsInstance(self.player.audio_service, MycroftAudioService) + + bus_events = ['recognizer_loop:record_begin', + 'recognizer_loop:record_end', + 'gui.player.media.service.sync.status', + "gui.player.media.service.get.next", + "gui.player.media.service.get.previous", + "gui.player.media.service.get.repeat", + "gui.player.media.service.get.shuffle", + 'ovos.common_play.player.state', + 'ovos.common_play.media.state', + 'ovos.common_play.play', + 'ovos.common_play.pause', + 'ovos.common_play.resume', + 'ovos.common_play.stop', + 'ovos.common_play.next', + 'ovos.common_play.previous', + 'ovos.common_play.seek', + 'ovos.common_play.get_track_length', + 'ovos.common_play.set_track_position', + 'ovos.common_play.get_track_position', + 'ovos.common_play.track_info', + 'ovos.common_play.list_backends', + 'ovos.common_play.playlist.set', + 'ovos.common_play.playlist.clear', + 'ovos.common_play.playlist.queue', + 'ovos.common_play.duck', + 'ovos.common_play.unduck', + 'ovos.common_play.shuffle.set', + 'ovos.common_play.shuffle.unset', + 'ovos.common_play.repeat.set', + 'ovos.common_play.repeat.unset', + 'ovos.common_play.gui.enable_app_timeout', + 'ovos.common_play.gui.set_app_timeout', + 'ovos.common_play.gui.timeout.mode' + ] + now_playing_events = ["ovos.common_play.track.state", + "ovos.common_play.media.state", + "ovos.common_play.play", + "ovos.common_play.playback_time", + 'gui.player.media.service.get.meta', + 'mycroft.audio.service.track_info_reply', + 'mycroft.audio.service.play', + 'mycroft.audio.playing_track' + ] + for event in bus_events: + expected_listeners = 1 + if event in now_playing_events: + expected_listeners += 1 + self.assertEqual(len(self.bus.ee.listeners(event)), + expected_listeners, event) + + # Test properties + self.assertEqual(self.player.active_skill, "ovos.common_play") + self.assertEqual(self.player.active_backend, PlaybackType.UNDEFINED) + self.assertEqual(self.player.tracks, list()) + self.assertEqual(self.player.disambiguation, list()) + self.assertFalse(self.player.can_prev) + self.assertFalse(self.player.can_next) + self.assertIsInstance(self.player.audio_service_player, str) + self.assertIsInstance(self.player.app_view_timeout_enabled, bool) + self.assertIsInstance(self.player.app_view_timeout_value, int) + self.assertIsInstance(self.player.app_view_timeout_mode, str) + + def test_set_media_state(self): + self.player.set_media_state(MediaState.UNKNOWN) + + # Emitted update on state change + self.player.set_media_state(MediaState.NO_MEDIA) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, "ovos.common_play.media.state") + self.assertEqual(last_message.data, {"state": MediaState.NO_MEDIA}) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + + # No emit on same state + self.player.set_media_state(MediaState.NO_MEDIA) + self.assertEqual(last_message, self.emitted_msgs[-1]) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + + # Test invalid state change + with self.assertRaises(TypeError): + self.player.set_media_state(1) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + + def test_set_player_state(self): + real_update_props = self.player.mpris.update_props + self.player.mpris.update_props = Mock() + self.player.set_player_state(PlayerState.STOPPED) + + # Change to "Playing" + self.player.set_player_state(PlayerState.PLAYING) + self.assertEqual(self.player.state, PlayerState.PLAYING) + self.assertEqual(self.player.gui["status"], "Playing") + self.player.mpris.update_props.assert_called_with( + {"CanPause": True, "CanPlay": False, "PlaybackStatus": "Playing"}) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, "ovos.common_play.player.state") + self.assertEqual(last_message.data, {"state": PlayerState.PLAYING}) + + # Change to "Paused" + self.player.set_player_state(PlayerState.PAUSED) + self.assertEqual(self.player.state, PlayerState.PAUSED) + self.assertEqual(self.player.gui["status"], "Paused") + self.player.mpris.update_props.assert_called_with( + {"CanPause": False, "CanPlay": True, "PlaybackStatus": "Paused"}) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, "ovos.common_play.player.state") + self.assertEqual(last_message.data, {"state": PlayerState.PAUSED}) + + # Change to "Stopped" + self.player.set_player_state(PlayerState.STOPPED) + self.assertEqual(self.player.state, PlayerState.STOPPED) + self.assertEqual(self.player.gui["status"], "Stopped") + self.player.mpris.update_props.assert_called_with( + {"CanPause": False, "CanPlay": False, "PlaybackStatus": "Stopped"}) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, "ovos.common_play.player.state") + self.assertEqual(last_message.data, {"state": PlayerState.STOPPED}) + + # Request invalid change + with self.assertRaises(TypeError): + self.player.set_player_state("Paused") + self.assertEqual(last_message, self.emitted_msgs[-1]) + self.assertEqual(self.player.state, PlayerState.STOPPED) + + with self.assertRaises(TypeError): + self.player.set_player_state(2) + self.assertEqual(last_message, self.emitted_msgs[-1]) + self.assertEqual(self.player.state, PlayerState.STOPPED) + + self.player.mpris.update_props = real_update_props + + def test_set_now_playing(self): + real_update_props = self.player.mpris.update_props + real_update_track = self.player.gui.update_current_track + real_update_plist = self.player.gui.update_playlist + self.player.mpris.update_props = Mock() + self.player.gui.update_current_track = Mock() + self.player.gui.update_playlist = Mock() + + valid_dict = valid_search_results[0] + valid_track = MediaEntry.from_dict(valid_search_results[1]) + invalid_str = json.dumps(valid_search_results[2]) + track_no_uri = valid_search_results[2] + track_no_uri.pop('uri') + # TODO: Test playlist result + + # Play valid dict result + self.player.set_now_playing(valid_dict) + entry = MediaEntry.from_dict(valid_dict) + # self.assertEqual(self.player.now_playing.as_dict, valid_dict) + self.assertEqual(self.player.now_playing, entry) + self.assertEqual(self.player.playlist.current_track, entry) + self.assertEqual(self.player.playlist[-1], entry) + self.player.gui.update_current_track.assert_called_once() + self.player.gui.update_playlist.assert_called_once() + self.player.mpris.update_props.assert_called_once_with( + {"Metadata": self.player.now_playing.mpris_metadata} + ) + self.player.gui.update_current_track.reset_mock() + self.player.gui.update_playlist.reset_mock() + self.player.mpris.update_props.reset_mock() + + # Play valid MediaEntry result + self.player.set_now_playing(valid_track) + self.assertEqual(self.player.now_playing, valid_track) + self.assertEqual(self.player.playlist.current_track, valid_track) + self.assertEqual(self.player.playlist[-1], valid_track) + self.player.gui.update_current_track.assert_called_once() + self.player.gui.update_playlist.assert_called_once() + self.player.mpris.update_props.assert_called_once_with( + {"Metadata": self.player.now_playing.mpris_metadata}) + self.player.gui.update_current_track.reset_mock() + self.player.gui.update_playlist.reset_mock() + self.player.mpris.update_props.reset_mock() + + # Play invalid string result + with self.assertRaises(ValueError): + self.player.set_now_playing(invalid_str) + self.player.gui.update_current_track.assert_not_called() + self.player.gui.update_playlist.assert_not_called() + self.player.mpris.update_props.assert_not_called() + + # Play result with no URI + self.player.set_now_playing(track_no_uri) + self.player.gui.update_current_track.assert_called_once() + self.player.gui.update_playlist.assert_called_once() + self.player.mpris.update_props.assert_called_once_with( + {"Metadata": self.player.now_playing.mpris_metadata}) + + self.player.mpris.update_props = real_update_props + self.player.gui.update_current_track = real_update_track + self.player.gui.update_playlist = real_update_plist + + @patch("ovos_plugin_common_play.ocp.player.is_gui_running") + def test_validate_stream(self, gui_running): + real_update = self.player.gui.update_current_track + self.player.gui.update_current_track = Mock() + media_entry = MediaEntry.from_dict(valid_search_results[0]) + invalid_result = valid_search_results[1] + invalid_result.pop('uri') + invalid_entry = MediaEntry.from_dict(invalid_result) + + # Valid Entry + self.player.now_playing.update(media_entry) + + self.assertFalse(self.player.now_playing.is_cps) + self.assertEqual(self.player.now_playing.playback, + PlaybackType.AUDIO) + self.assertEqual(self.player.active_backend, PlaybackType.AUDIO) + + # Test with GUI + gui_running.return_value = True + self.assertTrue(self.player.validate_stream()) + self.assertEqual(self.player.gui["stream"], media_entry.uri) + self.player.gui.update_current_track.assert_called_once() + self.assertEqual(self.player.now_playing.playback, + PlaybackType.AUDIO) + + # Invalid Entry + self.player.now_playing.update(invalid_entry) + self.assertFalse(self.player.validate_stream()) + self.assertEqual(self.player.gui["stream"], media_entry.uri) + self.player.gui.update_current_track.assert_called_once() + + # Test without GUI + gui_running.return_value = False + self.player.gui.update_current_track.reset_mock() + self.player.gui["stream"] = None + self.player.now_playing.update(media_entry) + self.assertTrue(self.player.validate_stream()) + self.assertEqual(self.player.gui["stream"], media_entry.uri) + self.player.gui.update_current_track.assert_called_once() + self.assertEqual(self.player.now_playing.playback, + PlaybackType.AUDIO_SERVICE) + + # TODO: Test Skill playback and non-audio playback + self.player.gui.update_current_track = real_update + + def test_on_invalid_media(self): + real_play_next = self.player.play_next + real_show_error = self.player.gui.show_playback_error + self.player.play_next = Mock() + self.player.gui.show_playback_error = Mock() + + self.player.on_invalid_media() + self.player.play_next.assert_called_once() + self.player.gui.show_playback_error.assert_called_once() + + self.player.play_next = real_play_next + self.player.gui.show_playback_error = real_show_error + + def test_play_media(self): + real_stop = self.player.mpris.stop + real_pause = self.player.pause + real_gui_update = self.player.gui.update_search_results + real_play = self.player.play + real_set_now_playing = self.player.set_now_playing + self.player.mpris.stop = Mock() + self.player.pause = Mock() + self.player.gui.update_search_results = Mock() + self.player.play = Mock() + self.player.set_now_playing = Mock() + + results_as_entries = [MediaEntry.from_dict(d) + for d in valid_search_results] + results_as_entries.sort(key=lambda k: k.match_confidence, reverse=True) + + # Test invalid track + with self.assertRaises(TypeError): + self.player.play_media(valid_search_results) + with self.assertRaises(TypeError): + self.player.play_media(json.dumps(valid_search_results[0])) + + # Test track only + self.player.state = PlayerState.STOPPED + track = MediaEntry.from_dict(valid_search_results[0]) + self.player.play_media(track) + self.player.mpris.stop.assert_called_once() + self.player.pause.assert_not_called() + self.assertEqual(self.player.media.search_playlist.entries, list()) + self.player.gui.update_search_results.assert_not_called() + self.assertEqual(self.player.playlist.entries, list()) + self.player.set_now_playing.assert_called_once_with(track) + self.player.play.assert_called_once() + + self.player.mpris.stop.reset_mock() + self.player.set_now_playing.reset_mock() + self.player.play.reset_mock() + + # Test track with disambiguation + self.player.state = PlayerState.PAUSED + track = MediaEntry.from_dict(valid_search_results[0]) + self.player.play_media(track, valid_search_results) + self.player.mpris.stop.assert_called_once() + self.player.pause.assert_not_called() + + self.assertEqual(self.player.media.search_playlist.entries, + results_as_entries) + self.player.gui.update_search_results.assert_called_once() + self.assertEqual(self.player.playlist.entries, list()) + self.player.set_now_playing.assert_called_once_with(track) + self.player.play.assert_called_once() + + self.player.mpris.stop.reset_mock() + self.player.set_now_playing.reset_mock() + self.player.play.reset_mock() + self.player.gui.update_search_results.reset_mock() + + # Test track with playlist + self.player.state = PlayerState.PLAYING + self.player.media.search_playlist.clear() + track = MediaEntry.from_dict(valid_search_results[0]) + self.player.play_media(track, playlist=valid_search_results) + self.player.mpris.stop.assert_called_once() + self.player.pause.assert_called_once() + + self.assertEqual(self.player.media.search_playlist.entries, list()) + self.player.gui.update_search_results.assert_not_called() + self.assertEqual(self.player.playlist.entries, results_as_entries) + self.assertEqual(self.player.playlist.current_track, track) + self.player.set_now_playing.assert_called_once_with(track) + self.player.play.assert_called_once() + + self.player.set_now_playing = real_set_now_playing + self.player.play = real_play + self.player.gui.update_search_results = real_gui_update + self.player.pause = real_pause + self.player.mpris.stop = real_stop + + def test_get_preferred_audio_backend(self): + preferred = self.player._get_preferred_audio_backend() + self.assertIsInstance(preferred, str) + self.assertIn(preferred, + ["ovos_common_play", "vlc", "mplayer", "simple"]) + + @patch("ovos_plugin_common_play.ocp.player.is_gui_running") + def test_play(self, gui_running): + gui_running.return_value = True + real_update_props = self.player.mpris.update_props + real_stop = self.player.mpris.stop + real_validate_stream = self.player.validate_stream + real_show_player = self.player.gui.show_player + real_invalid = self.player.on_invalid_media + real_player_state = self.player.set_player_state + real_audio_service_play = self.player.audio_service.play + self.player.mpris.update_props = Mock() + self.player.validate_stream = Mock(return_value=False) + mpris_stop = self.player.mpris.stop_event + self.player.mpris.stop = Mock() + self.player.gui.show_player = Mock() + self.player.on_invalid_media = Mock() + self.player.track_history = dict() + self.player.set_player_state = Mock() + self.player.audio_service.play = Mock() + + # Test invalid stream + self.player.play() + self.player.mpris.stop.assert_called_once() + self.player.validate_stream.assert_called_once() + self.player.on_invalid_media.assert_called_once() + self.player.gui.show_player.assert_not_called() + + self.player.validate_stream.reset_mock() + self.player.validate_stream.return_value = True + + # Test invalid backend + self.player.now_playing.playback = PlaybackType.UNDEFINED + mpris_stop.set() + with self.assertRaises(ValueError): + self.player.play() + self.player.validate_stream.assert_called_once() + self.player.mpris.update_props.assert_not_called() + + # TODO: Should the GUI be displayed and track history updated for + # invalid playback requests? + self.player.gui.show_player.assert_called_once() + self.assertEqual(set(self.player.track_history.keys()), {''}) + + self.player.gui.show_player.reset_mock() + self.player.validate_stream.reset_mock() + + # Test valid audio with gui + media = MediaEntry.from_dict(valid_search_results[0]) + media.playback = PlaybackType.AUDIO + self.player.now_playing.update(media) + mpris_stop.set() + self.player.play() + self.player.mpris.stop.assert_called_once() + self.player.validate_stream.assert_called_once() + self.player.on_invalid_media.assert_called_once() + self.player.gui.show_player.assert_called_once() + self.assertEqual(set(self.player.track_history.keys()), {'', media.uri}) + self.assertEqual(self.player.track_history[media.uri], 1) + last_message = self.emitted_msgs[-1] + second_last_message = self.emitted_msgs[-2] + self.assertEqual(last_message.msg_type, "ovos.common_play.track.state") + self.assertEqual(last_message.data, {"state": TrackState.PLAYING_AUDIO}) + self.assertEqual(second_last_message.msg_type, + "gui.player.media.service.play") + self.assertEqual(second_last_message.data, + {"track": media.uri, "mime": list(media.mimetype), + "repeat": False}) + + self.player.mpris.stop.reset_mock() + self.player.validate_stream.reset_mock() + self.player.gui.show_player.reset_mock() + + # Test valid audio without gui (AudioService) + gui_running.return_value = False + self.player.mpris.stop_event.clear() + self.player.play() + self.player.mpris.stop.assert_called_once() + self.player.validate_stream.assert_called_once() + self.player.on_invalid_media.assert_called_once() + self.player.gui.show_player.assert_called_once() + self.assertEqual(set(self.player.track_history.keys()), {'', media.uri}) + self.assertEqual(self.player.track_history[media.uri], 2) + self.assertEqual(self.player.active_backend, PlaybackType.AUDIO_SERVICE) + self.player.set_player_state.assert_called_once_with( + PlayerState.PLAYING) + self.player.audio_service.play.assert_called_once_with( + media.uri, utterance=self.player.audio_service_player) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, "ovos.common_play.track.state") + self.assertEqual(last_message.data, + {"state": TrackState.PLAYING_AUDIOSERVICE}) + + # TODO: Test Skill, Video, Webview + + self.player.on_invalid_media = real_invalid + self.player.gui.show_player = real_show_player + self.player.mpris.stop = real_stop + self.player.validate_stream = real_validate_stream + self.player.mpris.update_props = real_update_props + self.player.set_player_state = real_player_state + self.player.audio_service.play = real_audio_service_play + + def test_play_shuffle(self): + # TODO + pass + + def test_play_next(self): + real_mpris = self.player.mpris.play_next + real_pause = self.player.pause + real_play = self.player.play + real_shuffle = self.player.play_shuffle + real_gui_end = self.player.gui.handle_end_of_playback + + self.player.mpris.play_next = Mock() + self.player.pause = Mock() + self.player.play = Mock() + self.player.play_shuffle = Mock() + self.player.gui.handle_end_of_playback = Mock() + + # MPRIS Next + self.player.now_playing.playback = PlaybackType.MPRIS + self.player.play_next() + self.player.mpris.play_next.assert_called_once() + + # Skill Next + self.player.now_playing.playback = PlaybackType.SKILL + self.player.play_next() + last_message = self.emitted_msgs[-1] + self.assertEqual( + last_message.msg_type, + f"ovos.common_play.{self.player.now_playing.skill_id}.next") + + # Repeat Track + self.player.now_playing.playback = PlaybackType.AUDIO + self.player.loop_state = LoopState.REPEAT_TRACK + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_called_once() + + # Shuffle + self.player.pause.reset_mock() + self.player.play.reset_mock() + self.player.loop_state = LoopState.NONE + self.player.shuffle = True + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play_shuffle.assert_called_once() + self.player.play.assert_called_once() + + # Playlist next track + self.player.pause.reset_mock() + self.player.play.reset_mock() + self.player.shuffle = False + self.player.playlist.replace(valid_search_results) + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_called_once() + self.assertEqual(self.player.now_playing, self.player.playlist[1]) + + # Playlist repeat + self.player.loop_state = LoopState.REPEAT + self.player.playlist.set_position(len(self.player.playlist) - 1) + self.player.pause.reset_mock() + self.player.play.reset_mock() + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_called_once() + self.assertTrue(self.player.playlist.is_first_track) + + # Playlist no repeat + self.player.loop_state = LoopState.NONE + self.player.playlist.set_position(len(self.player.playlist) - 1) + self.player.pause.reset_mock() + self.player.play.reset_mock() + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_not_called() + self.player.gui.handle_end_of_playback.assert_called_once() + + # Search results next track + self.player.gui.handle_end_of_playback.reset_mock() + self.player.playlist.clear() + self.player.media.search_playlist.replace(valid_search_results) + self.assertEqual(len(self.player.playlist), 0) + self.player.pause.reset_mock() + self.player.play.reset_mock() + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_called_once() + self.player.gui.handle_end_of_playback.assert_not_called() + + # Search results end + self.player.pause.reset_mock() + self.player.play.reset_mock() + # TODO: should we repeat search results, or skip adding them to the playlist + self.player.playlist.clear() + self.player.media.search_playlist.set_position( + len(self.player.media.search_playlist) - 1) + self.assertTrue(self.player.media.search_playlist.is_last_track) + self.assertEqual(len(self.player.playlist), 0) + self.player.loop_state = LoopState.REPEAT + self.player.play_next() + self.player.pause.assert_called_once() + self.player.play.assert_not_called() + self.player.gui.handle_end_of_playback.assert_called_once() + + # TODO: Test with `merge_search` set to False + + self.player.mpris.play_next.assert_called_once() + + self.player.gui.handle_end_of_playback = real_gui_end + self.player.play_shuffle = real_shuffle + self.player.play = real_play + self.player.pause = real_pause + self.player.mpris.play_next = real_mpris + + def test_play_prev(self): + # TODO + pass + + def test_pause(self): + real_audio_pause = self.player.audio_service.pause + real_player_pause = self.player.mpris.pause + real_player_state = self.player.set_player_state + + self.player.audio_service.pause = Mock() + self.player.mpris.pause = Mock() + self.player.set_player_state = Mock() + + # Test Audio service Pause + self.player._paused_on_duck = True + self.player.now_playing.playback = PlaybackType.AUDIO_SERVICE + self.player.pause() + self.player.audio_service.pause.assert_called_once() + self.assertFalse(self.player._paused_on_duck) + self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) + + # Test GUI Pause + self.player.set_player_state.reset_mock() + self.player._paused_on_duck = True + self.player.now_playing.playback = PlaybackType.AUDIO + self.player.pause() + self.assertFalse(self.player._paused_on_duck) + self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, + "gui.player.media.service.pause") + + # Test Skill Pause + self.player.set_player_state.reset_mock() + self.player._paused_on_duck = True + self.player.now_playing.playback = PlaybackType.SKILL + self.player.pause() + self.assertFalse(self.player._paused_on_duck) + self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, + f"ovos.common_play.{self.player.active_skill}.pause") + + # Test MPRIS Pause + self.player.set_player_state.reset_mock() + self.player._paused_on_duck = True + self.player.now_playing.playback = PlaybackType.MPRIS + self.player.pause() + self.assertFalse(self.player._paused_on_duck) + self.player.set_player_state.assert_called_once_with(PlayerState.PAUSED) + self.player.mpris.pause.assert_called_once() + + # TODO: Test Undefined playback + + self.player.audio_service.pause.assert_called_once() + + self.player.audio_service.pause = real_audio_pause + self.player.mpris.pause = real_player_pause + self.player.set_player_state = real_player_state + + def test_resume(self): + real_audio_resume = self.player.audio_service.resume + real_player_resume = self.player.mpris.resume + real_player_state = self.player.set_player_state + + self.player.audio_service.resume = Mock() + self.player.mpris.resume = Mock() + self.player.set_player_state = Mock() + + # Test Audio service Resume + self.player.now_playing.playback = PlaybackType.AUDIO_SERVICE + self.player.resume() + self.player.audio_service.resume.assert_called_once() + self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) + + # Test GUI Resume + self.player.set_player_state.reset_mock() + self.player.now_playing.playback = PlaybackType.AUDIO + self.player.resume() + self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, + "gui.player.media.service.resume") + + # Test Skill Resume + self.player.set_player_state.reset_mock() + self.player.now_playing.playback = PlaybackType.SKILL + self.player.resume() + self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) + last_message = self.emitted_msgs[-1] + self.assertEqual(last_message.msg_type, + f"ovos.common_play.{self.player.active_skill}.resume") + + # Test MPRIS Resume + self.player.set_player_state.reset_mock() + self.player.now_playing.playback = PlaybackType.MPRIS + self.player.resume() + self.player.set_player_state.assert_called_once_with(PlayerState.PLAYING) + self.player.mpris.resume.assert_called_once() + + # TODO: Test Undefined playback + + self.player.audio_service.resume.assert_called_once() + + self.player.audio_service.resume = real_audio_resume + self.player.mpris.resume = real_player_resume + self.player.set_player_state = real_player_state + + def test_seek(self): + real_method = self.player.audio_service.set_track_position + mock_method = Mock() + self.player.audio_service.set_track_position = mock_method + + # Audio Service + self.player.now_playing.playback = PlaybackType.AUDIO_SERVICE + test_pos = 1234 + self.player.seek(test_pos) + mock_method.assert_called_once_with(1.234) + self.assertEqual(self.player.gui["position"], test_pos) + + # Audio + self.player.now_playing.playback = PlaybackType.AUDIO + test_pos = 10000 + self.player.seek(test_pos) + mock_method.assert_called_once_with(1.234) + self.assertEqual(self.player.gui["position"], test_pos) + + # Undefined + self.player.now_playing.playback = PlaybackType.UNDEFINED + test_pos = 999 + self.player.seek(test_pos) + mock_method.assert_called_with(0.999) + self.assertEqual(self.player.gui["position"], test_pos) + + self.player.audio_service.set_track_position = real_method + + def test_stop(self): + # TODO + pass + + def test_stop_gui_player(self): + self.player.stop_gui_player() + message = self.emitted_msgs[-1] + self.assertEqual(message.msg_type, "gui.player.media.service.stop") + + def test_stop_audio_skill(self): + self.player.stop_audio_skill() + message = self.emitted_msgs[-1] + self.assertEqual(message.msg_type, + f"ovos.common_play.{self.player.active_skill}.stop") + + def test_stop_audio_service(self): + real_stop = self.player.audio_service.stop + self.player.audio_service.stop = Mock() + self.player.stop_audio_service() + self.player.audio_service.stop.assert_called_once() + + self.player.audio_service.stop = real_stop + + def test_reset(self): + real_stop = self.player.stop + self.player.stop = Mock() + + self.player.reset() + self.player.stop.assert_called_once() + self.assertEqual(self.player.playlist.entries, list()) + self.assertIsNone(self.player.playlist.current_track) + self.assertEqual(self.player.media.search_playlist, list()) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + # TODO: Should this update player state? + # self.assertEqual(self.player.state, PlayerState.STOPPED) + self.assertFalse(self.player.shuffle) + self.assertEqual(self.player.loop_state, LoopState.NONE) + + self.player.stop = real_stop + + def test_shutdown(self): + # TODO + pass + + def test_handle_player_state_update(self): + real_view_timeout = self.player.gui.cancel_app_view_timeout + real_pause_timeout = self.player.gui.schedule_app_view_pause_timeout + real_update_props = self.player.mpris.update_props + view_timeout = Mock() + view_pause = Mock() + update_props = Mock() + self.player.gui.cancel_app_view_timeout = view_timeout + self.player.gui.schedule_app_view_pause_timeout = view_pause + self.player.mpris.update_props = update_props + + self.player.settings['app_view_timeout_mode'] = "pause" + self.player.settings['app_view_timeout_enabled'] = False + + # Invalid requests + with self.assertRaises(ValueError): + self.player.handle_player_state_update( + Message("", {"not_state": None})) + with self.assertRaises(ValueError): + self.player.handle_player_state_update( + Message("", {"state": None})) + with self.assertRaises(ValueError): + self.player.handle_player_state_update( + Message("", {"state": "Playing"})) + view_timeout.assert_not_called() + view_pause.assert_not_called() + update_props.assert_not_called() + + # State not changed + self.player.state = PlayerState.PLAYING + self.player.handle_player_state_update( + Message("", {"state": PlayerState.PLAYING})) + self.player.handle_player_state_update( + Message("", {"state": int(PlayerState.PLAYING)})) + view_timeout.assert_not_called() + view_pause.assert_not_called() + update_props.assert_not_called() + self.assertEqual(self.player.state, PlayerState.PLAYING) + + # Pause no GUI change + self.player.handle_player_state_update( + Message("", {"state": PlayerState.PAUSED})) + view_timeout.assert_not_called() + view_pause.assert_not_called() + update_props.assert_called_with({"CanPause": False, + "CanPlay": True, + "PlaybackStatus": "Paused"}) + self.assertEqual(self.player.state, PlayerState.PAUSED) + + # Play + self.player.handle_player_state_update( + Message("", {"state": PlayerState.PLAYING})) + view_timeout.assert_not_called() + view_pause.assert_not_called() + update_props.assert_called_with({"CanPause": True, + "CanPlay": False, + "PlaybackStatus": "Playing"}) + self.assertEqual(self.player.state, PlayerState.PLAYING) + + # Pause with GUI Change + self.player.settings['app_view_timeout_enabled'] = True + self.player.handle_player_state_update( + Message("", {"state": PlayerState.PAUSED})) + view_timeout.assert_called_once() + view_pause.assert_called_once() + update_props.assert_called_with({"CanPause": False, + "CanPlay": True, + "PlaybackStatus": "Paused"}) + self.assertEqual(self.player.state, PlayerState.PAUSED) + + # Stop + self.player.handle_player_state_update( + Message("", {"state": PlayerState.STOPPED})) + view_timeout.assert_called_once() + view_pause.assert_called_once() + update_props.assert_called_with({"CanPause": False, + "CanPlay": False, + "PlaybackStatus": "Stopped"}) + self.assertEqual(self.player.state, PlayerState.STOPPED) + + self.player.gui.cancel_app_view_timeout = real_view_timeout + self.player.gui.schedule_app_view_pause_timeout = real_pause_timeout + self.player.mpris.update_props = real_update_props + + def test_handle_player_media_update(self): + real_handle_playback_ended = self.player.handle_playback_ended + real_handle_invalid_media = self.player.handle_invalid_media + real_play_next = self.player.play_next + + self.player.play_next = Mock() + self.player.handle_playback_ended = Mock() + self.player.handle_invalid_media = Mock() + self.player.media_state = MediaState.UNKNOWN + + # Invalid requests + with self.assertRaises(ValueError): + self.player.handle_player_media_update( + Message("", {"not_state": None})) + with self.assertRaises(ValueError): + self.player.handle_player_media_update( + Message("", {"state": None})) + with self.assertRaises(ValueError): + self.player.handle_player_media_update( + Message("", {"state": "UNKNOWN"})) + self.player.handle_playback_ended.assert_not_called() + self.player.handle_invalid_media.assert_not_called() + self.assertEqual(self.player.media_state, MediaState.UNKNOWN) + + # State not changed + self.player.handle_player_media_update( + Message("", {"state": MediaState.UNKNOWN})) + self.assertEqual(self.player.media_state, MediaState.UNKNOWN) + self.player.handle_player_media_update( + Message("", {"state": 0})) + self.assertEqual(self.player.media_state, MediaState.UNKNOWN) + self.player.handle_playback_ended.assert_not_called() + self.player.handle_invalid_media.assert_not_called() + self.player.play_next.assert_not_called() + + # Valid state changes + self.player.handle_player_media_update( + Message("", {"state": MediaState.NO_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.NO_MEDIA) + self.player.handle_player_media_update( + Message("", {"state": MediaState.LOADING_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.LOADING_MEDIA) + self.player.handle_player_media_update( + Message("", {"state": MediaState.STALLED_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.STALLED_MEDIA) + self.player.handle_player_media_update( + Message("", {"state": MediaState.BUFFERING_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.BUFFERING_MEDIA) + self.player.handle_player_media_update( + Message("", {"state": MediaState.BUFFERED_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.BUFFERED_MEDIA) + self.player.handle_playback_ended.assert_not_called() + self.player.handle_invalid_media.assert_not_called() + self.player.play_next.assert_not_called() + + self.player.handle_player_media_update( + Message("", {"state": MediaState.END_OF_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.END_OF_MEDIA) + self.player.handle_playback_ended.assert_called_once() + self.player.handle_invalid_media.assert_not_called() + self.player.play_next.assert_not_called() + + self.player.handle_player_media_update( + Message("", {"state": MediaState.INVALID_MEDIA})) + self.assertEqual(self.player.media_state, MediaState.INVALID_MEDIA) + self.player.handle_playback_ended.assert_called_once() + self.player.handle_invalid_media.assert_called_once() + self.player.play_next.assert_called_once() + # TODO: Test without autoplay + + self.player.play_next = real_play_next + self.player.handle_playback_ended = real_handle_playback_ended + self.player.handle_invalid_media = real_handle_invalid_media + + def test_handle_invalid_media(self): + # TODO + pass + + def test_handle_playback_ended(self): + # TODO + pass + + def test_handle_play_request(self): + # TODO + pass + + def test_handle_pause_request(self): + # TODO + pass + + def test_handle_stop_request(self): + # TODO + pass + + def test_handle_resume_request(self): + # TODO + pass + + def test_handle_seek_request(self): + # TODO + pass + + def test_handle_next_request(self): + # TODO + pass + + def test_handle_prev_request(self): + # TODO + pass + + def test_handle_set_shuffle(self): + # TODO + pass + + def test_handle_unset_shuffle(self): + # TODO + pass + + def test_handle_set_repeat(self): + # TODO + pass + + def test_handle_unset_repeat(self): + # TODO + pass + + def test_handle_repeat_toggle_request(self): + # TODO + pass + + def test_handle_shuffle_toggle_request(self): + # TODO + pass + + def test_handle_playlist_set_request(self): + # TODO + pass + + def test_handle_playlist_queue_request(self): + # TODO + pass + + def test_handle_playlist_clear_request(self): + # TODO + pass + + def test_handle_duck_request(self): + real_pause = self.player.pause + self.player.pause = Mock() + + # Duck already paused + self.player._paused_on_duck = False + self.player.state = PlayerState.PAUSED + self.player.handle_duck_request(None) + self.player.pause.assert_not_called() + self.assertFalse(self.player._paused_on_duck) + + # Duck while stopped + self.player.state = PlayerState.STOPPED + self.player.handle_duck_request(None) + self.player.pause.assert_not_called() + self.assertFalse(self.player._paused_on_duck) + + # Duck while playing + self.player.state = PlayerState.PLAYING + self.player.handle_duck_request(None) + self.player.pause.assert_called_once() + self.assertTrue(self.player._paused_on_duck) + + self.player.pause = real_pause + + def test_handle_unduck_request(self): + real_resume = self.player.resume + self.player.resume = Mock() + self.player._paused_on_duck = False + + # Unduck already playing + self.player.state = PlayerState.PLAYING + self.player.handle_unduck_request(None) + self.player.resume.assert_not_called() + self.assertFalse(self.player._paused_on_duck) + + # Unduck while stopped + self.player.state = PlayerState.STOPPED + self.player.handle_unduck_request(None) + self.player.resume.assert_not_called() + self.assertFalse(self.player._paused_on_duck) + + # Unduck paused (not from duck) + self.player.state = PlayerState.PAUSED + self.player.handle_unduck_request(None) + self.player.resume.assert_not_called() + self.assertFalse(self.player._paused_on_duck) + + # Unduck paused on duck + self.player._paused_on_duck = True + self.player.state = PlayerState.PAUSED + self.player.handle_unduck_request(None) + self.player.resume.assert_called_once() + self.assertFalse(self.player._paused_on_duck) + + self.player.resume = real_resume + + def test_handle_track_length_request(self): + # TODO + pass + + def test_handle_track_position_request(self): + # TODO + pass + + def test_handle_set_track_position_request(self): + # TODO + pass + + def test_handle_track_info_request(self): + # TODO + pass + + def test_handle_list_backends_request(self): + # TODO + pass + + def test_handle_enable_app_timeout(self): + # TODO + pass + + def test_handle_set_app_timeout(self): + # TODO + pass + + def test_handle_set_app_timeout_mode(self): + # TODO + pass + + +if __name__ == "__main__": + unittest.main() From ed55658c9b23e3f9966c14ad078daf2dcc6d54b0 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 15:46:22 -0700 Subject: [PATCH 16/22] Ensure MediaEntry.playback is PlaybackType with unit tests Update logging --- ovos_plugin_common_play/ocp/media.py | 3 +- ovos_plugin_common_play/ocp/player.py | 2 +- test/unittests/test_ocp_media.py | 41 ++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/ovos_plugin_common_play/ocp/media.py b/ovos_plugin_common_play/ocp/media.py index d744792..fe6c80b 100644 --- a/ovos_plugin_common_play/ocp/media.py +++ b/ovos_plugin_common_play/ocp/media.py @@ -27,7 +27,8 @@ def __init__(self, title="", uri="", skill_id=OCP_ID, self.artist = artist self.skill_id = skill_id self.status = status - self.playback = playback + self.playback = PlaybackType(playback) if isinstance(playback, int) \ + else playback self.image = image or join(dirname(__file__), "res/ui/images/ocp_bg.png") self.position = position diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index 69a9021..fe50ae1 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -481,7 +481,7 @@ def play_next(self): PlaybackType.UNDEFINED]: # TODO: This is where Neon playback is failing LOG.debug(f"Defer playing next track to skill " - f"(Playback={self.active_backend}") + f"(Playback={self.active_backend})") self.bus.emit(Message( f'ovos.common_play.{self.now_playing.skill_id}.next')) return diff --git a/test/unittests/test_ocp_media.py b/test/unittests/test_ocp_media.py index 3ec3d62..ae7c73a 100644 --- a/test/unittests/test_ocp_media.py +++ b/test/unittests/test_ocp_media.py @@ -16,6 +16,7 @@ 'uri': 'https://freemusicarchive.org/track/07_-_Quantum_Jazz_-_Orbiting_A_Distant_Planet/stream/', 'title': 'Orbiting A Distant Planet', 'artist': 'Quantum Jazz', + 'skill_id': 'skill-free_music_archive.neongeckocom', 'match_confidence': 65}, {'media_type': MediaType.MUSIC, 'playback': PlaybackType.AUDIO, @@ -37,12 +38,45 @@ class TestMediaEntry(unittest.TestCase): + def test_init(self): + data = valid_search_results[0] + + # Test MediaEntry init + entry = MediaEntry(**data) + self.assertEqual(entry.title, data['title']) + self.assertEqual(entry.uri, data['uri']) + self.assertEqual(entry.artist, data['artist']) + self.assertEqual(entry.skill_id, data['skill_id']) + self.assertEqual(entry.status, TrackState.DISAMBIGUATION) + self.assertEqual(entry.playback, data['playback']) + self.assertEqual(entry.image, data['image']) + self.assertEqual(entry.position, 0) + self.assertIsNone(entry.phrase) + self.assertIsNone(entry.length) + self.assertEqual(entry.skill_icon, data['skill_icon']) + self.assertIsInstance(entry.bg_image, str) + self.assertFalse(entry.is_cps) + self.assertEqual(entry.data, {"media_type": data['media_type']}) + self.assertEqual(entry.cps_data, dict()) + self.assertEqual(entry.javascript, "") + + # Test playback passed as int + data['playback'] = int(data['playback']) + new_entry = MediaEntry(**data) + self.assertEqual(entry, new_entry) + + # TODO: Test file URI + # TODO: Test defined length + # TODO: Test defined background image + # TODO: Test defined cps_data + # TODO: Test defined javascript + def test_update(self): # TODO pass def test_from_dict(self): - dict_data = valid_search_results[0] + dict_data = valid_search_results[1] from_dict = MediaEntry.from_dict(dict_data) self.assertIsInstance(from_dict, MediaEntry) from_init = MediaEntry(dict_data["title"], dict_data["uri"], @@ -53,6 +87,11 @@ def test_from_dict(self): artist=dict_data["artist"]) self.assertEqual(from_init, from_dict) + # Test int playback + dict_data['playback'] = int(dict_data['playback']) + new_entry = MediaEntry.from_dict(dict_data) + self.assertEqual(from_dict, new_entry) + self.assertIsInstance(MediaEntry.from_dict({}), MediaEntry) def test_info(self): From b3e738c7b7f448ce9ba7ed5ed9f1326aeacbd467 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 15:57:21 -0700 Subject: [PATCH 17/22] Fix MessageBusClient import location --- ovos_plugin_common_play/ocp/media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ovos_plugin_common_play/ocp/media.py b/ovos_plugin_common_play/ocp/media.py index fe6c80b..cf3f7b5 100644 --- a/ovos_plugin_common_play/ocp/media.py +++ b/ovos_plugin_common_play/ocp/media.py @@ -1,6 +1,6 @@ from typing import Optional, Tuple, List, Union -from mycroft.messagebus import MessageBusClient +from mycroft_bus_client import MessageBusClient from ovos_plugin_common_play.ocp import OCP_ID from ovos_plugin_common_play.ocp.status import * from ovos_plugin_common_play.ocp.utils import ocp_plugins, find_mime From a6113758f069e8ad66ec7e51abfb39977c1294fe Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 16:26:24 -0700 Subject: [PATCH 18/22] Add logging to troubleshoot playback errors --- ovos_plugin_common_play/ocp/media.py | 2 +- ovos_plugin_common_play/ocp/player.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ovos_plugin_common_play/ocp/media.py b/ovos_plugin_common_play/ocp/media.py index cf3f7b5..350b462 100644 --- a/ovos_plugin_common_play/ocp/media.py +++ b/ovos_plugin_common_play/ocp/media.py @@ -508,7 +508,7 @@ def handle_track_state_change(self, message): if state == self.status: return self.status = state - LOG.info(f"TrackState changed: {state}") + LOG.info(f"TrackState changed: {repr(state)}") if state == TrackState.PLAYING_SKILL: # skill is handling playback internally diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index fe50ae1..382cfad 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -395,7 +395,7 @@ def play(self): self.track_history.setdefault(self.now_playing.uri, 0) self.track_history[self.now_playing.uri] += 1 - LOG.debug(f"Requesting playback: {self.active_backend}") + LOG.debug(f"Requesting playback: {repr(self.active_backend)}") if self.active_backend == PlaybackType.AUDIO and not is_gui_running(): LOG.warning("Requested Audio playback via GUI without GUI. " "Choosing Audio Service") @@ -452,6 +452,7 @@ def play(self): if self.mpris: self.mpris.update_props({"CanGoNext": self.can_next}) self.mpris.update_props({"CanGoPrevious": self.can_prev}) + LOG.debug(f"self.active_backend={repr(self.active_backend)}") def play_shuffle(self): """ @@ -716,7 +717,7 @@ def handle_player_media_update(self, message): raise ValueError(f"Expected int or MediaState, but got: {state}") if state == self.media_state: return - LOG.info(f"MediaState changed: {repr(state)}") + LOG.debug(f"MediaState changed: {repr(state)}") self.media_state = state if state == MediaState.END_OF_MEDIA: self.handle_playback_ended(message) @@ -729,13 +730,12 @@ def handle_invalid_media(self, message): self.gui.show_playback_error() def handle_playback_ended(self, message): - LOG.debug("Playback ended") if self.settings.get("autoplay", True) and \ self.active_backend != PlaybackType.MPRIS: - LOG.debug("Playing next track") + LOG.debug(f"Playing next (backend={repr(self.active_backend)}") self.play_next() return - + LOG.info("Playback ended") self.gui.handle_end_of_playback(message) # ovos common play bus api requests From aef12d2926a8e6dee6634c764babf396b8df79b5 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 16:38:55 -0700 Subject: [PATCH 19/22] Update logging to troubleshoot --- ovos_plugin_common_play/ocp/player.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index 382cfad..2a73fc4 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -480,9 +480,9 @@ def play_next(self): return elif self.active_backend in [PlaybackType.SKILL, PlaybackType.UNDEFINED]: - # TODO: This is where Neon playback is failing + # TODO: Should UNDEFINED be handled as a skill? LOG.debug(f"Defer playing next track to skill " - f"(Playback={self.active_backend})") + f"(Playback={repr(self.active_backend)})") self.bus.emit(Message( f'ovos.common_play.{self.now_playing.skill_id}.next')) return @@ -707,6 +707,7 @@ def handle_player_media_update(self, message): Handles 'ovos.common_play.media.state' messages with media state updates @param message: Message providing new "state" data """ + LOG.debug(f"backend={repr(self.active_backend)}") state = message.data.get("state") if state is None: raise ValueError(f"Got state update message with no state: " @@ -730,6 +731,7 @@ def handle_invalid_media(self, message): self.gui.show_playback_error() def handle_playback_ended(self, message): + # TODO: When we get here, self.active_backend has been reset! if self.settings.get("autoplay", True) and \ self.active_backend != PlaybackType.MPRIS: LOG.debug(f"Playing next (backend={repr(self.active_backend)}") From 434a28af104c090b0be628b8e7e5088604c5a96d Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 17:25:25 -0700 Subject: [PATCH 20/22] Refactor `NowPlaying` reset and update unit tests --- ovos_plugin_common_play/ocp/media.py | 8 +++++--- ovos_plugin_common_play/ocp/player.py | 4 +++- test/unittests/test_ocp_player.py | 10 ++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ovos_plugin_common_play/ocp/media.py b/ovos_plugin_common_play/ocp/media.py index 350b462..d6e2692 100644 --- a/ovos_plugin_common_play/ocp/media.py +++ b/ovos_plugin_common_play/ocp/media.py @@ -406,6 +406,7 @@ def reset(self): """ Reset the NowPlaying MediaEntry to default parameters """ + LOG.debug("Resetting NowPlaying") self.title = "" self.artist = None self.skill_icon = None @@ -545,9 +546,10 @@ def handle_media_state_change(self, message): state = MediaState(state) if not isinstance(state, MediaState): raise ValueError(f"Expected int or TrackState, but got: {state}") - if state == MediaState.END_OF_MEDIA: - # playback ended, allow next track to change metadata again - self.reset() + # Don't do anything. Let OCP manage this object's state + # if state == MediaState.END_OF_MEDIA: + # # playback ended, allow next track to change metadata again + # self.reset() def handle_sync_seekbar(self, message): """ diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index 2a73fc4..32ef753 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -243,6 +243,7 @@ def set_now_playing(self, track: Union[dict, MediaEntry]): track = MediaEntry.from_dict(track) if not isinstance(track, MediaEntry): raise ValueError(f"Expected MediaEntry, but got: {track}") + self.now_playing.reset() # reset now_playing to remove old metadata if track.uri: # single track entry (MediaEntry) self.now_playing.update(track) @@ -707,7 +708,8 @@ def handle_player_media_update(self, message): Handles 'ovos.common_play.media.state' messages with media state updates @param message: Message providing new "state" data """ - LOG.debug(f"backend={repr(self.active_backend)}") + LOG.debug(f"backend={repr(self.active_backend)}|" + f"msg_type={message.msg_type}") state = message.data.get("state") if state is None: raise ValueError(f"Got state update message with no state: " diff --git a/test/unittests/test_ocp_player.py b/test/unittests/test_ocp_player.py index 377f757..9af0697 100644 --- a/test/unittests/test_ocp_player.py +++ b/test/unittests/test_ocp_player.py @@ -215,9 +215,12 @@ def test_set_now_playing(self): real_update_props = self.player.mpris.update_props real_update_track = self.player.gui.update_current_track real_update_plist = self.player.gui.update_playlist + real_nowplaying_reset = self.player.now_playing.reset + self.player.mpris.update_props = Mock() self.player.gui.update_current_track = Mock() self.player.gui.update_playlist = Mock() + self.player.now_playing.reset = Mock() valid_dict = valid_search_results[0] valid_track = MediaEntry.from_dict(valid_search_results[1]) @@ -238,6 +241,8 @@ def test_set_now_playing(self): self.player.mpris.update_props.assert_called_once_with( {"Metadata": self.player.now_playing.mpris_metadata} ) + self.player.now_playing.reset.assert_called_once() + self.player.now_playing.reset.reset_mock() self.player.gui.update_current_track.reset_mock() self.player.gui.update_playlist.reset_mock() self.player.mpris.update_props.reset_mock() @@ -251,6 +256,8 @@ def test_set_now_playing(self): self.player.gui.update_playlist.assert_called_once() self.player.mpris.update_props.assert_called_once_with( {"Metadata": self.player.now_playing.mpris_metadata}) + self.player.now_playing.reset.assert_called_once() + self.player.now_playing.reset.reset_mock() self.player.gui.update_current_track.reset_mock() self.player.gui.update_playlist.reset_mock() self.player.mpris.update_props.reset_mock() @@ -258,6 +265,7 @@ def test_set_now_playing(self): # Play invalid string result with self.assertRaises(ValueError): self.player.set_now_playing(invalid_str) + self.player.now_playing.reset.assert_not_called() self.player.gui.update_current_track.assert_not_called() self.player.gui.update_playlist.assert_not_called() self.player.mpris.update_props.assert_not_called() @@ -268,10 +276,12 @@ def test_set_now_playing(self): self.player.gui.update_playlist.assert_called_once() self.player.mpris.update_props.assert_called_once_with( {"Metadata": self.player.now_playing.mpris_metadata}) + self.player.now_playing.reset.assert_called_once() self.player.mpris.update_props = real_update_props self.player.gui.update_current_track = real_update_track self.player.gui.update_playlist = real_update_plist + self.player.now_playing.reset = real_nowplaying_reset @patch("ovos_plugin_common_play.ocp.player.is_gui_running") def test_validate_stream(self, gui_running): From f2d1981cc0ebfc2a4f3e0fba73684e78d1aac04e Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 17:29:19 -0700 Subject: [PATCH 21/22] Update test to handle refactored reset calls --- test/unittests/test_ocp_media.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unittests/test_ocp_media.py b/test/unittests/test_ocp_media.py index ae7c73a..970cb58 100644 --- a/test/unittests/test_ocp_media.py +++ b/test/unittests/test_ocp_media.py @@ -353,11 +353,10 @@ def test_handle_media_state_change(self): Message("", {"state": MediaState.BUFFERED_MEDIA})) # Test END_OF_MEDIA - self.player.reset.assert_not_called() self.player.handle_media_state_change( Message("", {"state": MediaState.END_OF_MEDIA})) - self.player.reset.assert_called_once() + self.player.reset.assert_not_called() self.player.reset = real_reset def test_handle_sync_seekbar(self): From 17a104365050911b671cdcf71c8a65cfcfe136b6 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 7 Apr 2023 17:51:38 -0700 Subject: [PATCH 22/22] Update 'play_next' based on PR conversation --- ovos_plugin_common_play/ocp/player.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index 32ef753..91690f3 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -475,15 +475,14 @@ def play_next(self): End playback if there is no next track, accounting for repeat and shuffle settings. """ - if self.active_backend in [PlaybackType.MPRIS]: + if self.active_backend == PlaybackType.UNDEFINED: + LOG.error("self.active_backend is undefined") + elif self.active_backend in [PlaybackType.MPRIS]: if self.mpris: self.mpris.play_next() return - elif self.active_backend in [PlaybackType.SKILL, - PlaybackType.UNDEFINED]: - # TODO: Should UNDEFINED be handled as a skill? - LOG.debug(f"Defer playing next track to skill " - f"(Playback={repr(self.active_backend)})") + elif self.active_backend in [PlaybackType.SKILL]: + LOG.debug(f"Defer playing next track to skill") self.bus.emit(Message( f'ovos.common_play.{self.now_playing.skill_id}.next')) return