diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index d2575a2..5f7c38f 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -60,6 +60,10 @@ jobs: # NOTE: additional pytest invocations should also add the --cov-append flag # or they will overwrite previous invocations' coverage reports # (for an example, see OVOS Skill Manager's workflow) + - name: Run unittests with ovos-utils 0.0.38 + run: | + pip install ovos-utils==0.0.38 + pytest --cov=ovos_plugin_common_play --cov-report xml test/unittests - name: Upload coverage env: CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d07f9c..e92d28b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,160 +1,93 @@ # Changelog -## [V0.0.6a16](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a16) (2024-01-18) +## [V0.0.7a11](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a11) (2024-08-06) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a15...V0.0.6a16) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.7a10...V0.0.7a11) **Fixed bugs:** -- OCP GUI stays up until dismissed after playing a single track [\#98](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/issues/98) -- settings storage seems to be ambiguous [\#43](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/issues/43) +- Playlists don't work when progressing naturally, but skips are fine [\#123](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/issues/123) +- logs/end\_of\_track [\#124](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/124) ([JarbasAl](https://github.com/JarbasAl)) -**Merged pull requests:** - -- Add upper version pin for ovos-utils [\#106](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/106) ([NeonDaniel](https://github.com/NeonDaniel)) - -## [V0.0.6a15](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a15) (2024-01-10) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a14...V0.0.6a15) - -**Closed issues:** - -- `StreamHandler` initialized on import [\#54](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/issues/54) -- Emit messagebus event on Intent match [\#20](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/issues/20) - -**Merged pull requests:** - -- Update news.intent [\#83](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/83) ([timonvanhasselt](https://github.com/timonvanhasselt)) - -## [V0.0.6a14](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a14) (2024-01-08) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a13...V0.0.6a14) - -**Merged pull requests:** - -- allow alpha utils [\#105](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/105) ([JarbasAl](https://github.com/JarbasAl)) - -## [V0.0.6a13](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a13) (2024-01-02) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a12...V0.0.6a13) - -**Merged pull requests:** +## [V0.0.7a10](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a10) (2024-08-03) -- Update dependencies to stable versions with ovos-core 0.0.7 compat. [\#104](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/104) ([NeonDaniel](https://github.com/NeonDaniel)) - -## [V0.0.6a12](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a12) (2023-12-30) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a11...V0.0.6a12) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.7a9...V0.0.7a10) **Fixed bugs:** -- fix/OCP\_plugins\_loading [\#103](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/103) ([JarbasAl](https://github.com/JarbasAl)) - -## [V0.0.6a11](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a11) (2023-12-29) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a10...V0.0.6a11) - -**Merged pull requests:** - -- Update requirements.txt [\#102](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/102) ([JarbasAl](https://github.com/JarbasAl)) - -## [V0.0.6a10](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a10) (2023-12-29) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a9...V0.0.6a10) - -**Merged pull requests:** - -- bump requirements [\#101](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/101) ([JarbasAl](https://github.com/JarbasAl)) +- fix/playlist\_handling [\#122](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/122) ([JarbasAl](https://github.com/JarbasAl)) -## [V0.0.6a9](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a9) (2023-10-27) +## [V0.0.7a9](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a9) (2024-07-09) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a8...V0.0.6a9) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.7a8...V0.0.7a9) -**Merged pull requests:** +**Fixed bugs:** -- Update requirements to stable versions [\#100](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/100) ([NeonDaniel](https://github.com/NeonDaniel)) +- fix/play\_from\_gui [\#121](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/121) ([JarbasAl](https://github.com/JarbasAl)) -## [V0.0.6a8](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a8) (2023-09-12) +## [V0.0.7a8](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a8) (2024-06-06) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a7...V0.0.6a8) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.7a7...V0.0.7a8) -**Implemented enhancements:** +**Fixed bugs:** -- Add standalone launcher [\#97](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/97) ([Ramblurr](https://github.com/Ramblurr)) +- fix/native\_sources [\#120](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/120) ([JarbasAl](https://github.com/JarbasAl)) -## [V0.0.6a7](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a7) (2023-08-21) +## [V0.0.7a7](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a7) (2024-06-02) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a6...V0.0.6a7) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.7a6...V0.0.7a7) **Merged pull requests:** -- OCPQuery: Only check if the gui is connected once [\#96](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/96) ([Ramblurr](https://github.com/Ramblurr)) +- default to OCP pipeline + support for utils 0.0.38 [\#119](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/119) ([JarbasAl](https://github.com/JarbasAl)) -## [V0.0.6a6](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a6) (2023-08-21) +## [V0.0.7a6](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a6) (2024-05-28) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a5...V0.0.6a6) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.7a5...V0.0.7a6) **Fixed bugs:** -- Allow `PlaybackType.SKILL` search results to be played when there is no GUI [\#95](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/95) ([Ramblurr](https://github.com/Ramblurr)) - -**Closed issues:** - -- OCP search results are not processed fast enough because `is_gui_connected(..)` takes too long [\#93](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/issues/93) -- OCP skills supporting PlaybackType.SKILL cannot be played when there is no GUI [\#92](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/issues/92) +- fix/playlist\_again [\#117](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/117) ([JarbasAl](https://github.com/JarbasAl)) -**Merged pull requests:** - -- Handle race condition between `ovos.common_play.query.response` and `ovos.common_play.skill.search_end` [\#94](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/94) ([Ramblurr](https://github.com/Ramblurr)) - -## [V0.0.6a5](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a5) (2023-07-19) +## [V0.0.7a5](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a5) (2024-05-25) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a4...V0.0.6a5) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.7a4...V0.0.7a5) -**Merged pull requests:** +**Fixed bugs:** -- Update GUI to pass resource names instead of paths [\#90](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/90) ([NeonDaniel](https://github.com/NeonDaniel)) +- fix playlist handling [\#116](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/116) ([JarbasAl](https://github.com/JarbasAl)) -## [V0.0.6a4](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a4) (2023-07-13) +## [V0.0.7a4](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a4) (2024-05-25) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a3...V0.0.6a4) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.7a3...V0.0.7a4) **Fixed bugs:** -- fix/support for ui directories param [\#87](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/87) ([JarbasAl](https://github.com/JarbasAl)) - -## [V0.0.6a3](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a3) (2023-07-12) +- fix/coexistence\_with\_pipeline [\#115](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/115) ([JarbasAl](https://github.com/JarbasAl)) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a2...V0.0.6a3) +## [V0.0.7a3](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a3) (2024-05-10) -**Merged pull requests:** - -- Update requirements.txt [\#86](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/86) ([JarbasAl](https://github.com/JarbasAl)) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.7a2...V0.0.7a3) -## [V0.0.6a2](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a2) (2023-06-14) +**Implemented enhancements:** -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6a1...V0.0.6a2) +- feat/experimental\_ocp\_pipeline [\#112](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/112) ([JarbasAl](https://github.com/JarbasAl)) -**Closed issues:** +## [V0.0.7a2](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a2) (2024-01-25) -- All media play requests loop \(silently\) until ovos restart [\#84](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/issues/84) -- self.active\_backend is undefined when fallback is triggered [\#82](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/issues/82) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.7a1...V0.0.7a2) **Merged pull requests:** -- Refactor init to resolve deprecation warnings [\#85](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/85) ([NeonDaniel](https://github.com/NeonDaniel)) +- refactor/dry\_ocp\_imports [\#109](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/109) ([NeonJarbas](https://github.com/NeonJarbas)) -## [V0.0.6a1](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.6a1) (2023-04-23) - -[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.5...V0.0.6a1) - -**Implemented enhancements:** +## [V0.0.7a1](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/tree/V0.0.7a1) (2024-01-25) -- Replace mycroft\_bus\_client with ovos\_bus\_client [\#80](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/80) ([goldyfruit](https://github.com/goldyfruit)) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/compare/V0.0.6...V0.0.7a1) **Merged pull requests:** -- Fix automation typos [\#77](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/77) ([NeonDaniel](https://github.com/NeonDaniel)) +- refactor/utils\_0.1.0 [\#108](https://github.com/OpenVoiceOS/ovos-ocp-audio-plugin/pull/108) ([NeonJarbas](https://github.com/NeonJarbas)) diff --git a/README.md b/README.md index 40de732..c49d0ad 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ mycroft.conf ## Standalone Mode +> **DEPRECATED**: valid for ovos-core 0.0.7 only! + Normally OCP is initialized and started by [ovos-audio](https://github.com/OpenVoiceOS/ovos-audio). However, in some situations you may want to run OCP in standalone mode. diff --git a/ovos_plugin_common_play/__init__.py b/ovos_plugin_common_play/__init__.py index 5350962..36065aa 100644 --- a/ovos_plugin_common_play/__init__.py +++ b/ovos_plugin_common_play/__init__.py @@ -1,11 +1,12 @@ from pprint import pformat + from ovos_bus_client import Message from ovos_utils.log import LOG +from ovos_workshop.decorators.ocp import * -from ovos_plugin_common_play.ocp import OCP, OCP_ID -from ovos_plugin_common_play.ocp.status import * -from ovos_plugin_common_play.ocp.utils import extract_metadata +from ovos_plugin_common_play.ocp import OCP from ovos_plugin_common_play.ocp.base import OCPAudioPlayerBackend +from ovos_plugin_common_play.ocp.constants import OCP_ID class OCPAudioBackend(OCPAudioPlayerBackend): @@ -24,6 +25,10 @@ def __init__(self, config, bus=None, name='ovos.common_play'): self.handle_receive_meta) self.create_ocp(self.config) + @property + def player(self): + return self.ocp.player + def create_ocp(self, config: dict): self.config = config self.config.setdefault("mode", "auto") diff --git a/ovos_plugin_common_play/launcher.py b/ovos_plugin_common_play/launcher.py index ee27c35..ea16a1c 100644 --- a/ovos_plugin_common_play/launcher.py +++ b/ovos_plugin_common_play/launcher.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 from ovos_bus_client import MessageBusClient -from ovos_plugin_common_play.ocp import OCP, OCP_ID from ovos_utils import wait_for_exit_signal +from ovos_plugin_common_play.ocp import OCP +from ovos_plugin_common_play.ocp.constants import OCP_ID + def main(): """ diff --git a/ovos_plugin_common_play/ocp/__init__.py b/ovos_plugin_common_play/ocp/__init__.py index 7e0b6e1..04476f1 100644 --- a/ovos_plugin_common_play/ocp/__init__.py +++ b/ovos_plugin_common_play/ocp/__init__.py @@ -1,18 +1,20 @@ from os.path import join, dirname, isfile - -from ovos_plugin_common_play.ocp.constants import OCP_ID -from ovos_workshop.decorators.ocp import * +from threading import Event, Lock +from typing import Optional, List +from ovos_config import Configuration from ovos_plugin_common_play.ocp.gui import OCPMediaPlayerGUI from ovos_plugin_common_play.ocp.player import OCPMediaPlayer -from ovos_plugin_common_play.ocp.status import * from ovos_utils.gui import can_use_gui from ovos_utils.log import LOG -from ovos_plugin_common_play.ocp.utils import create_desktop_file -from ovos_bus_client.message import Message -from ovos_workshop import OVOSAbstractApplication +from ovos_utils.messagebus import Message + from padacioso import IntentContainer -from threading import Event, Lock -from ovos_plugin_common_play.ocp.utils import ocp_plugins + +from ovos_workshop import OVOSAbstractApplication +from ovos_workshop.decorators.ocp import * +from ovos_plugin_manager.ocp import load_stream_extractors + +from ovos_plugin_common_play.ocp.constants import OCP_ID class OCP(OVOSAbstractApplication): @@ -42,7 +44,9 @@ class OCP(OVOSAbstractApplication): "hentai": MediaType.HENTAI } - def __init__(self, bus=None, lang=None, settings=None, skill_id=OCP_ID): + def __init__(self, bus=None, lang=None, settings=None, skill_id=OCP_ID, + validate_source: bool = True, + native_sources: Optional[List[str]] = None): # settings = settings or OCPSettings() res_dir = join(dirname(__file__), "res") super().__init__(skill_id=skill_id, resources_dir=res_dir, @@ -57,26 +61,28 @@ def __init__(self, bus=None, lang=None, settings=None, skill_id=OCP_ID): settings=self.settings, resources_dir=res_dir, gui=self.gui, - skill_id=OCP_ID) + skill_id=OCP_ID, + validate_source=validate_source, + native_sources=native_sources) self.media_intents = IntentContainer() self.register_ocp_api_events() - self.register_media_intents() - self.add_event("mycroft.ready", self.replace_mycroft_cps, once=True) - skills_ready = self.bus.wait_for_response( - Message("mycroft.skills.is_ready", - context={"source": [self.skill_id], - "destination": ["skills"]})) - if skills_ready and skills_ready.data.get("status"): - 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 - ocp_plugins() # trigger a load + caching of OCP plugins + if self.using_new_pipeline: + LOG.info("Using Classic OCP with experimental OCP pipeline") + else: + self.register_media_intents() + + self.add_event("mycroft.ready", self.replace_mycroft_cps, once=True) + skills_ready = self.bus.wait_for_response( + Message("mycroft.skills.is_ready", + context={"source": [self.skill_id], + "destination": ["skills"]})) + if skills_ready and skills_ready.data.get("status"): + self.remove_event("mycroft.ready") + self.replace_mycroft_cps(skills_ready) + + # report available plugins to ovos-core pipeline + self.handle_get_SEIs(Message("ovos.common_play.SEI.get")) def handle_ping(self, message): """ @@ -89,11 +95,33 @@ def register_ocp_api_events(self): """ Register messagebus handlers for OCP events """ + self.add_event('ovos.common_play.SEI.get', self.handle_get_SEIs) 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_get_SEIs(self, message): + """report available StreamExtractorIds + + Ported from ovos-media to accommodate migration period + and making old OCP compatible with the new pipeline + + OCP plugins handle specific SEIs and return a real stream / extra metadata + + this moves parsing to playback time instead of search time + + SEIs are identifiers of the format "{SEI}//{uri}" + that might be present in media results + + seis are NOT uris, a uri comes after {SEI}// + + eg. for the youtube plugin a skill can return + "youtube//https://youtube.com/watch?v=wChqNkd6F24" + """ + xtract = load_stream_extractors() # @lru_cache, its a lazy loaded singleton + self.bus.emit(message.response({"SEI": xtract.supported_seis})) + def handle_home(self, message=None): """ Handle ovos.common_play.home Messages and show the homescreen @@ -102,7 +130,23 @@ def handle_home(self, message=None): # homescreen / launch from .desktop self.gui.show_home(app_mode=True) + @property + def using_new_pipeline(self) -> bool: + # this is no longer configurable, most of this repo is dead code + # keep this check to allow smooth updates from the couple alpha versions this was live + if Configuration().get("intents", {}).get("experimental_ocp_pipeline"): + return True + # check for min version for default ovos-config to contain OCP pipeline + from ovos_config.version import VERSION_BUILD, VERSION_ALPHA, VERSION_MAJOR, VERSION_MINOR + if VERSION_BUILD > 13 or VERSION_MAJOR >= 1 or VERSION_MINOR >= 1: + return True + return VERSION_BUILD == 13 and VERSION_ALPHA >= 14 + def register_ocp_intents(self, message=None): + if self.using_new_pipeline: + LOG.debug("skipping Classic OCP intent registration") + return + with self._intent_registration_lock: if not self._intents_event.is_set(): LOG.info(f"OCP intents missing, registering for {self}") @@ -175,7 +219,7 @@ def unload_mycroft_cps(message): # if skills service (re)loads (re)register OCP if ("mycroft.ready", self.replace_mycroft_cps) in self.events: LOG.warning("Method already registered!") - self.add_event("mycroft.ready", self.replace_mycroft_cps, once=True) + self.add_event("mycroft.ready", self.replace_mycroft_cps, once=True) def default_shutdown(self): self.player.shutdown() @@ -284,11 +328,11 @@ def _do_play(self, phrase, results, media_type=MediaType.GENERIC): if self.gui: if self.gui.active_extension == "smartspeaker": self.gui.display_notification("Sorry, no matches found", style="warning") - + self.speak_dialog("cant.play", data={"phrase": phrase, "media_type": media_type}) - + if self.gui: if "smartspeaker" not in self.gui.active_extension: if not self.gui.persist_home_display: @@ -302,14 +346,14 @@ def _do_play(self, phrase, results, media_type=MediaType.GENERIC): if self.gui: if self.gui.active_extension == "smartspeaker": self.gui.display_notification("Found a match", style="success") - + best = self.player.media.select_best(results) self.player.play_media(best, results) if self.gui: if self.gui.active_extension == "smartspeaker": self.gui.clear_notification() - + self.enclosure.mouth_reset() # TODO display music icon in mk1 self.set_context("Playing") diff --git a/ovos_plugin_common_play/ocp/base.py b/ovos_plugin_common_play/ocp/base.py index 8ce8e8e..b97d2db 100644 --- a/ovos_plugin_common_play/ocp/base.py +++ b/ovos_plugin_common_play/ocp/base.py @@ -1,11 +1,13 @@ +from os.path import join, isfile + +from ovos_bus_client.message import Message from ovos_config.locations import get_xdg_config_save_path -from ovos_plugin_common_play.ocp.constants import OCP_ID -from ovos_plugin_common_play.ocp.status import MediaState, PlayerState, TrackState from ovos_plugin_manager.templates.audio import AudioBackend -from ovos_ocp_files_plugin.plugin import OCPFilesMetadataExtractor from ovos_utils.log import LOG -from os.path import basename, join, isfile -from ovos_bus_client.message import Message +from ovos_workshop.decorators.ocp import MediaState, PlayerState, TrackState + +from ovos_plugin_common_play.ocp.constants import OCP_ID +from ovos_plugin_common_play.ocp.utils import extract_metadata class OCPAbstractComponent: @@ -220,30 +222,7 @@ def add_list(self, tracks): elif not isinstance(tracks, list): raise ValueError self.load_track(tracks[0]) - self._tracks = [_uri2meta(t) for t in tracks] + self._tracks = [extract_metadata(t) for t in tracks] self.bus.emit(Message('ovos.common_play.playlist.queue', - {'tracks': self._tracks})) + {'tracks': self._tracks})) self.track_info() # will trigger update in track data - - -def _uri2meta(uri): - if isinstance(uri, list): - uri = uri[0] - try: - # only works for local files - # audio only (?) - meta = OCPFilesMetadataExtractor.extract_metadata(uri) - except Exception as e: - LOG.exception(e) - # TODO let's try to dig for message and see if theres - # anything there, maybe set title / artist to skill_id ? - meta = {"uri": uri, - "title": basename(uri), - "artist": "ovos.common_play.plugin", - "album": "", - "image": "", - "playback": 2, # PlaybackType.AUDIO, # TODO mime type check - "status": 33 # TrackState.QUEUED_AUDIO - } - meta["skill_id"] = "mycroft.audio_interface" - return meta diff --git a/ovos_plugin_common_play/ocp/gui.py b/ovos_plugin_common_play/ocp/gui.py index 976682a..40a74cd 100644 --- a/ovos_plugin_common_play/ocp/gui.py +++ b/ovos_plugin_common_play/ocp/gui.py @@ -1,17 +1,17 @@ import enum from os.path import join, dirname - -from ovos_plugin_common_play.ocp import OCP_ID +from threading import Timer from time import sleep + +from ovos_bus_client.apis.gui import GUIInterface from ovos_bus_client.message import Message from ovos_config import Configuration from ovos_utils.events import EventSchedulerInterface -from ovos_bus_client.apis.gui import GUIInterface from ovos_utils.log import LOG +from ovos_workshop.backwards_compat import MediaType, Playlist, MediaEntry, PlayerState, LoopState, PlaybackType, PluginStream, dict2entry -from ovos_plugin_common_play.ocp.status import * +from ovos_plugin_common_play.ocp.constants import OCP_ID from ovos_plugin_common_play.ocp.utils import is_qtav_available -from threading import Timer class VideoPlayerBackend(str, enum.Enum): @@ -54,12 +54,12 @@ def bind(self, player): @property def video_backend(self): return self.player.settings.get("video_player_backend") or \ - VideoPlayerBackend.AUTO + VideoPlayerBackend.AUTO @property def home_screen_page(self): return "Home" - + @property def disambiguation_playlists_page(self): return "SuggestionsView" @@ -114,7 +114,7 @@ def update_ocp_skills(self): "title": skill["skill_name"], "image": skill["thumbnail"], "media_type": skill.get("media_type") or [MediaType.GENERIC] - } for skill in self.player.media.get_featured_skills()] + } for skill in self.player.media.get_featured_skills()] self["skillCards"] = skills_cards def update_seekbar_capabilities(self): @@ -143,10 +143,10 @@ def update_current_track(self): self["uri"] = self.player.now_playing.uri self["title"] = self.player.now_playing.title self["image"] = self.player.now_playing.image or \ - join(dirname(__file__), "res/ui/images/ocp.png") + join(dirname(__file__), "res/ui/images/ocp.png") self["artist"] = self.player.now_playing.artist self["bg_image"] = self.player.now_playing.bg_image or \ - join(dirname(__file__), "res/ui/images/ocp_bg.png") + join(dirname(__file__), "res/ui/images/ocp_bg.png") self["duration"] = self.player.now_playing.length self["position"] = self.player.now_playing.position # options below control the web player @@ -155,7 +155,7 @@ def update_current_track(self): # TODO default permissive or restrictive? self["javascript"] = self.player.now_playing.javascript self["javascriptCanOpenWindows"] = False # TODO allow to be defined per track - self["allowUrlChange"] = False # TODO allow to be defined per track + self["allowUrlChange"] = False # TODO allow to be defined per track def update_search_results(self): self["searchModel"] = { @@ -182,30 +182,30 @@ def manage_display(self, page_requested, timeout=None): # This is to ensure that the home is always available to the user # regardless of what other pages are currently open # Swiping from the player to the left will always show the home page - + # The home page will only be in view if the user is not currently playing an active track # If the user is playing a track, the player will be shown instead # This is to ensure that the user always returns to the player when they are playing a track - + # The search_spinner_page has been integrated into the home page as an overlay # It will be shown when the user is searching for a track and will be hidden when the search is complete # on platforms that don't support the notification system - + # Player: # Player loader will always be shown at Protocol level index 1 # The merged playlist and disambiguation pages will always be shown at Protocol level index 2 - + # If the user has just opened the ocp home page, and nothing was played previously, # the player and merged playlist/disambiguation page will not be shown - + # If the user has just opened the ocp home page, and a track was previously played, # the player and merged playlist/disambiguation page will always be shown - + # If the player is not paused or stopped, the player will be shown instead of the home page # when ocp is opened - + # Timeout is used to ensure that ocp is fully closed once the timeout has expired - + sleep(0.2) player_status = self.player.state state2str = {PlayerState.PLAYING: "Playing", PlayerState.PAUSED: "Paused", PlayerState.STOPPED: "Stopped"} @@ -216,11 +216,11 @@ def manage_display(self, page_requested, timeout=None): LOG.debug(f"manage_display: page_requested: {page_requested}") LOG.debug(f"manage_display: player_status: {player_status}") - + if page_requested == "home": self["homepage_index"] = 0 self["displayBottomBar"] = False - + # Check if the skills page has anything to show, only show it if it does if self["skillCards"]: self["displayBottomBar"] = True @@ -239,16 +239,16 @@ def manage_display(self, page_requested, timeout=None): elif page_requested == "playlist": self["displaySuggestionBar"] = False self._show_suggestion_playlist() - + if timeout is not None: self.show_page(self.disambiguation_playlists_page, override_idle=timeout, override_animations=True) else: self.show_page(self.disambiguation_playlists_page, override_idle=True, override_animations=True) - + elif page_requested == "disambiguation": self["displaySuggestionBar"] = False self._show_suggestion_disambiguation() - + if timeout is not None: self.show_page(self.disambiguation_playlists_page, override_idle=timeout, override_animations=True) else: @@ -265,7 +265,7 @@ def cancel_app_view_timeout(self, restart=False): def schedule_app_view_pause_timeout(self): if (self.player.app_view_timeout_enabled - and self.player.app_view_timeout_mode == "pause" + and self.player.app_view_timeout_mode == "pause" and self.player.state == PlayerState.PAUSED): self.schedule_app_view_timeout() @@ -333,19 +333,19 @@ def _get_pages_to_display(self): # determine pages to be shown self["playerBackend"] = self._get_player_page() LOG.debug(f"pages to display backend: {self['playerBackend']}") - + if len(self.player.disambiguation): self["displaySuggestionBar"] = False self._show_suggestion_disambiguation() - + if len(self.player.tracks): self["displaySuggestionBar"] = False self._show_suggestion_playlist() - if len(self.player.disambiguation) and len(self.player.tracks): + if len(self.player.disambiguation) and len(self.player.tracks): self["displaySuggestionBar"] = True self._show_suggestion_playlist() - + pages = [self.player_loader_page, self.disambiguation_playlists_page] return pages @@ -358,30 +358,66 @@ def _show_home_skills(self): def _show_suggestion_playlist(self): self.send_event("ocp.gui.show.suggestion.view.playlist") - + def _show_suggestion_disambiguation(self): self.send_event("ocp.gui.show.suggestion.view.disambiguation") # gui <-> playlists + def _gui2entry(self, gui_entry, from_playlist=True, from_search=True): + if isinstance(gui_entry, dict): + gui_entry = dict2entry(gui_entry) + # HACK: since the GUI sends incomplete data, + # we need to check the internal playlist.... + if from_playlist: + for track in self.player.playlist: + if isinstance(gui_entry, Playlist): + if not isinstance(track, Playlist): + continue + if track.title == gui_entry.title: + LOG.debug(f"gui data mapped to {track}") + return track + + elif not isinstance(track, Playlist): + if isinstance(track, PluginStream): + uri = f"{track.extractor_id}//{track.stream}" + else: + uri = track.uri + if uri == gui_entry.uri: + LOG.debug(f"gui data mapped to {track}") + return track + if from_search: + for track in self.player.disambiguation: + if isinstance(gui_entry, Playlist): + if not isinstance(track, Playlist): + continue + if track.title == gui_entry.title: + LOG.debug(f"gui data mapped to {track}") + return track + + elif not isinstance(track, Playlist): + if isinstance(track, PluginStream): + uri = f"{track.extractor_id}//{track.stream}" + else: + uri = track.uri + if uri == gui_entry.uri: + LOG.debug(f"gui data mapped to {track}") + return track + LOG.warning("malformed GUI request, track not in search results") + if gui_entry.playback == PlaybackType.UNDEFINED: + LOG.error("undefined playback type, assuming PlaybackType.AUDIO_SERVICE") + gui_entry.playback = PlaybackType.AUDIO_SERVICE + # either GUI issues got fixed or an error will be spoken + return gui_entry + def handle_play_from_playlist(self, message): LOG.debug("Playback requested from playlist results") - media = message.data["playlistData"] - for track in self.player.playlist: - if track == media: # found track - self.player.play_media(track) - break - else: - LOG.error("Track is not part of loaded playlist!") + media = self._gui2entry(message.data["playlistData"], from_playlist=True, from_search=False) + self.player.play_media(media) def handle_play_from_search(self, message): LOG.debug("Playback requested from search results") - media = message.data["playlistData"] - for track in self.player.disambiguation: - if track == media: # found track - self.player.play_media(track) - break - else: - LOG.error("Track is not part of search results!") + media = self._gui2entry(message.data["playlistData"], from_playlist=False, from_search=True) + self.player.play_media(media) def handle_play_skill_featured_media(self, message): skill_id = message.data["skill_id"] @@ -418,7 +454,7 @@ def handle_end_of_playback(self, message=None): # show search results, release screen after 60 seconds if show_results: self.manage_display("playlist", timeout=60) - + def display_notification(self, text, style="info"): """ Display a notification on the screen instead of spinner on platform that support it """ self.show_controlled_notification(text, style=style) @@ -448,7 +484,7 @@ def show_search_spinner(self, persist_home=False): def remove_search_spinner(self): self.send_event("ocp.gui.hide.busy.overlay") - + def remove_homescreen(self): self.release() @@ -468,7 +504,7 @@ def __init__(self, skill_id): # - Extra / Disambiguation / Playlist, this is the page that will be shown when the skill is launched and the skill is playing # - Custom, allow the skill to show any custom page it wants # Page management lifecycle will be handled by the skill itself - + def bind(self, player): self.player = player super().set_bus(self.bus) @@ -477,38 +513,38 @@ def register_screen_type(self, page_url, page_type): for page in self.ocp_registered_pages: if page["type"] == page_type: return - + page_to_register = { "page_url": page_url, "type": page_type } self.ocp_registered_pages[page_type] = page_to_register - + def get_screen_type(self, page_type): return self.ocp_registered_pages[page_type] - + def show_screen(self, page_type, override_idle=False, override_animations=False): page_to_show = self.get_screen_type(page_type) self.show_page(page_to_show["page_url"], override_idle=override_idle, override_animations=override_animations) - + def show_home(self, override_idle=False, override_animations=False): self.show_screen("home", override_idle, override_animations) - + def show_player(self, override_idle=False, override_animations=False): self.show_screen("player", override_idle, override_animations) - + def show_extra(self, override_idle=False, override_animations=False): self.show_screen("extra", override_idle, override_animations) - + def remove_screen(self, page_type): page_to_remove = self.get_screen_type(page_type) self.remove_page(page_to_remove["page_url"]) - + def remove_home(self): self.remove_screen("home") - + def remove_player(self): self.remove_screen("player") - + def remove_extra(self): self.remove_screen("extra") diff --git a/ovos_plugin_common_play/ocp/media.py b/ovos_plugin_common_play/ocp/media.py index 5b755f5..be6fb18 100644 --- a/ovos_plugin_common_play/ocp/media.py +++ b/ovos_plugin_common_play/ocp/media.py @@ -1,89 +1,59 @@ -from typing import Optional, Tuple, List, Union - +from os.path import join, dirname +from typing import Union +from dataclasses import dataclass from ovos_bus_client.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 ovos_bus_client.message import Message from ovos_utils.json_helper import merge_dict from ovos_utils.log import LOG -from ovos_bus_client.message import Message -from os.path import join, dirname -from dbus_next.service import Variant - +from ovos_workshop.backwards_compat import MediaState, TrackState, PlaybackType, MediaType, Playlist, PluginStream, MediaEntry as _ME + +from ovos_plugin_common_play.ocp.constants import OCP_ID +from ovos_plugin_common_play.ocp.utils import ocp_plugins + + +@dataclass +class MediaEntry(_ME): + uri: str = "" + title: str = "" + artist: str = "" + match_confidence: int = 0 # 0 - 100 + skill_id: str = OCP_ID + playback: PlaybackType = PlaybackType.UNDEFINED + status: TrackState = TrackState.DISAMBIGUATION + media_type: MediaType = MediaType.GENERIC + length: int = 0 # in seconds + image: str = "" + skill_icon: str = "" + javascript: str = "" # to execute once webview is loaded -# TODO subclass from dict (?) -class MediaEntry: def __init__(self, title="", uri="", skill_id=OCP_ID, image=None, match_confidence=0, playback=PlaybackType.UNDEFINED, status=TrackState.DISAMBIGUATION, phrase=None, - position=0, length=None, bg_image=None, skill_icon=None, + position=0, length=0, bg_image=None, skill_icon=None, artist=None, is_cps=False, cps_data=None, javascript="", **kwargs): - self.match_confidence = match_confidence - self.title = title - uri = uri or "" # handle None - self.uri = f'file://{uri}' if uri.startswith('/') else uri + uri = uri or "" + super().__init__( + title=title, + match_confidence=match_confidence, + playback=PlaybackType(playback) if isinstance(playback, int) else playback, + status=status, + length=length, + image=image or join(dirname(__file__), "res/ui/images/ocp_bg.png"), + skill_icon=skill_icon or join(dirname(__file__), "res/ui/images/ocp.png"), + javascript=javascript, + uri=f'file://{uri}' if uri.startswith('/') else uri, + skill_id=skill_id, + media_type=kwargs.get("media_type") or MediaType.GENERIC + ) self.artist = artist - self.skill_id = skill_id - self.status = status - 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 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.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: 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 - entry = entry or {} - for k, v in entry.items(): - if k not in skipkeys and hasattr(self, k): - if newonly and self.__getattribute__(k): - # skip, do not replace existing values - continue - self.__setattr__(k, v) - - @staticmethod - 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 - data["position"] = data.get("position", 0) - data["length"] = data.get("length") or \ - data.get("track_length") or \ - data.get("duration") # or get_duration_from_url(url) - data["skill_icon"] = data.get("skill_icon") or data.get("skill_logo") - data["status"] = data.get("status") or TrackState.DISAMBIGUATION - data["playback"] = data.get("playback", PlaybackType.UNDEFINED) - data["uri"] = data.get("stream") or data.get("uri") or data.get("url") - data["title"] = data.get("title") or data["uri"] - data["artist"] = data.get("artist") or data.get("author") - data["is_cps"] = data.get("is_old_style") or data.get("is_cps", False) - data["cps_data"] = data.get("cps_data") or {} - return MediaEntry(**data) @property def info(self) -> dict: @@ -92,261 +62,39 @@ def info(self) -> dict: """ return merge_dict(self.as_dict, self.infocard) - @property - def infocard(self) -> dict: - """ - Return dict data used for a UI display - """ - return { - "duration": self.length, - "track": self.title, - "image": self.image, - "album": self.skill_id, - "source": self.skill_icon, - "uri": self.uri - } - - @property - 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]) - if self.title: - meta['xesam:title'] = Variant('s', self.title) - if self.image: - meta['mpris:artUrl'] = Variant('s', self.image) - if self.length: - meta['mpris:length'] = Variant('d', self.length) - return meta - - @property - 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) -> 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) - - def __eq__(self, other): - if isinstance(other, MediaEntry): - other = other.infocard - # dict compatison - return other == self.infocard - - def __repr__(self): - return str(self.as_dict) + @staticmethod + def from_dict(track: dict) -> 'MediaEntry': + if "uri" not in track: # not valid in ovos-utils.ocp + track["uri"] = "" + return _ME.from_dict(track) - def __str__(self): - return str(self.as_dict) +@dataclass +class NowPlaying(MediaEntry): + uri: str = "" + title: str = "" + artist: str = "" + match_confidence: int = 0 # 0 - 100 + skill_id: str = OCP_ID + playback: PlaybackType = PlaybackType.UNDEFINED + status: TrackState = TrackState.DISAMBIGUATION + media_type: MediaType = MediaType.GENERIC + length: int = 0 # in seconds + image: str = "" + skill_icon: str = "" + javascript: str = "" # to execute once webview is loaded -class Playlist(list): def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._position = 0 - - @property - def position(self) -> int: - """ - Return the current position in the playlist - """ - return self._position - - @property - def entries(self) -> List[MediaEntry]: - """ - Return a list of MediaEntry objects in the playlist - """ - entries = [] - for e in self: - if isinstance(e, dict): - e = MediaEntry.from_dict(e) - if isinstance(e, MediaEntry): - 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 + MediaEntry.__init__(self, *args, **kwargs) + self._player = None @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: 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) - if index == -1: - index = len(self) - - if index < self.position: - self.set_position(self.position + 1) - - self.insert(index, 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 - if isinstance(entry, dict): - entry = MediaEntry.from_dict(entry) - assert isinstance(entry, MediaEntry) - for idx, e in self.entries: - if e == entry: - self.pop(idx) - break - else: - raise ValueError(f"entry not in playlist: {entry}") - - 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: + def as_dict(self) -> dict: """ - Make sure the current position is valid; default `position` to 0 + Return a dict representation of this MediaEntry """ - 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) - if not isinstance(item, MediaEntry): - return False - for e in self.entries: - if not e.uri and e.data.get("playlist"): - if e.title == item.title and not item.uri: - return True - # track in playlist - for t in e.data["playlist"]: - if t.get("uri") == item.uri: - return True - elif e.uri == item.uri: - return True - return False - - -class NowPlaying(MediaEntry): - def __init__(self, *args, **kwargs): - MediaEntry.__init__(self, *args, **kwargs) - self._player = None + return {k: v for k, v in self.__dict__.items() + if not k.startswith("_")} @property def bus(self) -> MessageBusClient: @@ -421,14 +169,14 @@ def reset(self): self.playback = PlaybackType.UNDEFINED self.status = TrackState.DISAMBIGUATION - def update(self, entry: dict, skipkeys: list = None, newonly: bool = False): + def update(self, entry: Union[dict, MediaEntry], 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): + if isinstance(entry, _ME): entry = entry.as_dict super().update(entry, skipkeys, newonly) # uri updates should not be skipped @@ -446,7 +194,7 @@ def update(self, entry: dict, skipkeys: list = None, newonly: bool = False): def extract_stream(self): """ - Get metadata from ocp_plugins and add it to this MediaEntry + DEPRECATED: Get metadata from ocp_plugins and add it to this MediaEntry """ uri = self.uri if not uri: @@ -528,8 +276,8 @@ def handle_track_state_change(self, message): # alternative results # TODO its this 1 track or a list ? pass elif state in [TrackState.QUEUED_SKILL, - TrackState.QUEUED_VIDEO, - TrackState.QUEUED_AUDIOSERVICE]: + TrackState.QUEUED_VIDEO, + TrackState.QUEUED_AUDIOSERVICE]: # audio service is handling playback and this is in playlist pass @@ -597,5 +345,3 @@ def handle_audio_service_play_start(self, message): self.update( {"status": TrackState.PLAYING_AUDIOSERVICE, "playback": PlaybackType.AUDIO_SERVICE}) - - diff --git a/ovos_plugin_common_play/ocp/mpris.py b/ovos_plugin_common_play/ocp/mpris.py index a3b0050..e018de2 100644 --- a/ovos_plugin_common_play/ocp/mpris.py +++ b/ovos_plugin_common_play/ocp/mpris.py @@ -7,10 +7,9 @@ from dbus_next.message import Message as DbusMessage, \ MessageType as DbusMessageType from dbus_next.service import ServiceInterface, method, dbus_property, PropertyAccess -from ovos_utils.log import LOG from ovos_bus_client.message import Message -from ovos_plugin_common_play.ocp.status import TrackState, PlaybackType, \ - PlayerState, LoopState +from ovos_utils.log import LOG +from ovos_workshop.decorators.ocp import TrackState, PlaybackType, PlayerState, LoopState class MprisPlayerCtl(Thread): @@ -571,7 +570,7 @@ def Rate(self) -> 'd': @dbus_property(access=PropertyAccess.READ) def Position(self) -> 'd': - return 1 # TODO from ocp_player + return 1 # TODO from ocp_player @dbus_property(access=PropertyAccess.READ) def CanPlay(self) -> 'b': @@ -623,6 +622,3 @@ def PlayPause(self): self._ocp_player.resume() else: self._ocp_player.pause() - - - diff --git a/ovos_plugin_common_play/ocp/mycroft_cps.py b/ovos_plugin_common_play/ocp/mycroft_cps.py index 19381b4..6ae76f1 100644 --- a/ovos_plugin_common_play/ocp/mycroft_cps.py +++ b/ovos_plugin_common_play/ocp/mycroft_cps.py @@ -2,14 +2,12 @@ from datetime import timedelta from os.path import abspath -from ovos_bus_client.message import dig_for_message - -from ovos_plugin_common_play.ocp.constants import OCP_ID -from ovos_bus_client.message import Message +from ovos_bus_client.message import Message, dig_for_message from ovos_bus_client.util import wait_for_reply +from ovos_workshop.decorators.ocp import MediaType, PlaybackType from ovos_plugin_common_play.ocp.base import OCPAbstractComponent -from ovos_plugin_common_play.ocp.status import * +from ovos_plugin_common_play.ocp.constants import OCP_ID def ensure_uri(s): @@ -22,12 +20,12 @@ def ensure_uri(s): if s is uri, s is returned otherwise file:// is prepended """ if isinstance(s, str): - if '://' not in s: + if ':' not in s: return 'file://' + abspath(s) else: return s elif isinstance(s, (tuple, list)): - if '://' not in s[0]: + if ':' not in s[0]: return 'file://' + abspath(s[0]), s[1] else: return s diff --git a/ovos_plugin_common_play/ocp/player.py b/ovos_plugin_common_play/ocp/player.py index 88e8c49..0abbe7d 100644 --- a/ovos_plugin_common_play/ocp/player.py +++ b/ovos_plugin_common_play/ocp/player.py @@ -1,30 +1,41 @@ import random from os.path import join, dirname -from typing import List, Union - from time import sleep +from typing import List, Union, Optional -from ovos_utils.gui import is_gui_connected, is_gui_running -from ovos_utils.log import LOG from ovos_bus_client.message import Message from ovos_config import Configuration +from ovos_utils.gui import is_gui_connected, is_gui_running +from ovos_utils.log import LOG +from ovos_utils.messagebus import Message +from ovos_workshop import OVOSAbstractApplication +from ovos_workshop.backwards_compat import (PluginStream, LoopState, MediaState, PlayerState, TrackState, + PlaybackType, MediaEntry, PlaybackMode, Playlist) +from ovos_plugin_common_play.ocp.constants import OCP_ID from ovos_plugin_common_play.ocp.gui import OCPMediaPlayerGUI -from ovos_plugin_common_play.ocp.media import Playlist, MediaEntry, NowPlaying +from ovos_plugin_common_play.ocp.media import NowPlaying from ovos_plugin_common_play.ocp.mpris import MprisPlayerCtl -from ovos_plugin_common_play.ocp.search import OCPSearch -from ovos_plugin_common_play.ocp.status import * from ovos_plugin_common_play.ocp.mycroft_cps import MycroftAudioService -from ovos_workshop import OVOSAbstractApplication -from ovos_plugin_common_play.ocp.constants import OCP_ID +from ovos_plugin_common_play.ocp.search import OCPSearch +from ovos_plugin_common_play.ocp.utils import require_native_source +try: + from ovos_utils.ocp import dict2entry +except ImportError: # older utils version + dict2entry = MediaEntry.from_dict class OCPMediaPlayer(OVOSAbstractApplication): def __init__(self, bus=None, settings=None, lang=None, gui=None, - resources_dir=None, skill_id=OCP_ID, **kwargs): + resources_dir=None, skill_id=OCP_ID, validate_source=True, + native_sources: Optional[List[str]] = None, + **kwargs): resources_dir = resources_dir or join(dirname(__file__), "res") gui = gui or OCPMediaPlayerGUI(bus=bus) + self.validate_source = validate_source + self.native_sources = native_sources or Configuration()["Audio"].\ + get("native_sources", ["debug_cli", "audio"]) # Define things referenced in `bind` self.now_playing: NowPlaying = NowPlaying() self.media: OCPSearch = OCPSearch() @@ -231,7 +242,7 @@ 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: Union[dict, MediaEntry]): + def set_now_playing(self, track: Union[dict, MediaEntry, Playlist, PluginStream]): """ Set `track` as the currently playing media, update the playlist, and notify any GUI or MPRIS clients. Adds `track` to `playlist` @@ -239,39 +250,55 @@ def set_now_playing(self, track: Union[dict, MediaEntry]): """ LOG.debug(f"Playing: {track}") 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}") - self.now_playing.reset() # reset now_playing to remove old metadata - if track.uri: + LOG.debug(f"Handling dict track: {track}") + if "uri" not in track: # TODO handle this better + track["uri"] = "external:" # when syncing from MPRIS uri is missing + track = dict2entry(track) + if not isinstance(track, (MediaEntry, Playlist, PluginStream)): + raise ValueError(f"Expected MediaEntry/Playlist, but got: {track}") + + try: + idx = self.playlist.index(track) # find the entry in "now playing" + except ValueError: + idx = -1 + if isinstance(track, PluginStream): + track = track.extract_media_entry(video=track.playback == PlaybackType.VIDEO) + LOG.info(f"PluginStream extracted: {track}") + if idx >= 0: + self.playlist[idx] = track # update extracted plugin stream + + if isinstance(track, MediaEntry): # single track entry (MediaEntry) self.now_playing.update(track) - # copy now_playing (without event handlers) to playlist - # entry = self.now_playing.as_entry() - if track not in self.playlist: # compared by uri + + # update playlist position + if idx > -1: + self.playlist.set_position(idx) + # add to "now playing" if it's a new track + elif track not in self.playlist: # compared by uri self.playlist.add_entry(track) - elif track.data.get("playlist"): + self.playlist.set_position(len(self.playlist) - 1) + # find equivalent track position in playlist + else: + self.playlist.goto_track(track) + + elif isinstance(track, Playlist): # this is a playlist result (list of dicts) - pl = track.data.get("playlist") - if pl: - self.playlist.clear() - for entry in pl: - self.playlist.add_entry(entry) + self.playlist.clear() + for entry in track: + self.playlist.add_entry(entry) + + # mew playlist -> reset playlist position to the start + self.playlist.set_position(0) + # update self.now_playing if len(self.playlist): - self.now_playing.update(self.playlist[0]) - else: - # If there's no URI, the skill might be handling playback so - # now_playing should still be updated - self.now_playing.update(track) - else: + track = self.playlist[0] + return self.set_now_playing(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) + self.now_playing.update(self.playlist.as_dict) # update gui values self.gui.update_current_track() @@ -293,16 +320,14 @@ def validate_stream(self) -> bool: if self.active_backend not in [PlaybackType.SKILL, PlaybackType.UNDEFINED, PlaybackType.MPRIS]: - try: - self.now_playing.extract_stream() - except Exception as e: - LOG.exception(e) - return False has_gui = is_gui_running() or is_gui_connected(self.bus) if not has_gui or self.settings.get("force_audioservice", False) or \ self.settings.get("playback_mode") == PlaybackMode.FORCE_AUDIOSERVICE: # No gui, so lets force playback to use audio only + LOG.debug("Casting to PlaybackType.AUDIO_SERVICE") self.now_playing.playback = PlaybackType.AUDIO_SERVICE + if not self.now_playing.uri: + return False self.gui["stream"] = self.now_playing.uri self.gui.update_current_track() @@ -317,7 +342,7 @@ def on_invalid_media(self): self.play_next() # media controls - def play_media(self, track: Union[dict, MediaEntry], + def play_media(self, track: Union[dict, MediaEntry, PluginStream, Playlist], disambiguation: List[Union[dict, MediaEntry]] = None, playlist: List[Union[dict, MediaEntry]] = None): """ @@ -327,9 +352,12 @@ def play_media(self, track: Union[dict, MediaEntry], @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}") + track = dict2entry(track) + if not isinstance(track, (MediaEntry, Playlist, PluginStream)): + raise TypeError(f"Expected MediaEntry/Playlist, got: {track}") + if isinstance(track, Playlist) and not playlist: + playlist = track + track = playlist[0] if self.mpris: self.mpris.stop() if self.state == PlayerState.PLAYING: @@ -363,12 +391,11 @@ def _get_preferred_audio_backend(self): 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"] + available = [k for k, v in cfg.items() + if v.get("type", "") != "ovos_common_play"] preferred = self.settings.get("preferred_audio_services") or \ - ["vlc", "mplayer", "simple"] + ["vlc", "simple"] for b in preferred: if b in available: return b @@ -398,9 +425,11 @@ def play(self): 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") - self.now_playing.playback = PlaybackType.AUDIO_SERVICE + # NOTE: this is already normalized in self.validate_stream, using messagebus + # if we get here the GUI probably crashed, or just isnt "mycroft-gui-app" or "ovos-shell" + # is_gui_running() can not be trusted, log a warning only + LOG.warning("Requested Audio playback via GUI, but GUI doesn't seem to be running?") + 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 @@ -603,6 +632,9 @@ def stop(self): # stop any search still happening self.bus.emit(Message("ovos.common_play.search.stop")) + LOG.debug("clearing playlist") + self.playlist.clear() # needed to ensure next track doesnt track due to autoplay + LOG.debug("Stopping playback") if self.active_backend in [PlaybackType.AUDIO_SERVICE, PlaybackType.UNDEFINED]: @@ -665,6 +697,7 @@ def shutdown(self): self.remove_event("gui.player.media.service.get.previous") # player -> common play + @require_native_source() def handle_player_state_update(self, message): """ Handles 'gui.player.media.service.sync.status' and @@ -702,6 +735,7 @@ def handle_player_state_update(self, message): "CanPlay": state == PlayerState.PAUSED, "PlaybackStatus": state2str[state]}) + @require_native_source() def handle_player_media_update(self, message): """ Handles 'ovos.common_play.media.state' messages with media state updates @@ -728,20 +762,28 @@ def handle_player_media_update(self, message): if self.settings.get("autoplay", True): self.play_next() + @require_native_source() def handle_invalid_media(self, message): self.gui.show_playback_error() + @require_native_source() 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)}") + LOG.info(f"END OF MEDIA - playlist pos: {self.playlist.position} " + f"total tracks: {len(self.playlist)} " + f"backend: {self.active_backend}") + go_next = self.settings.get("autoplay", True) and \ + self.active_backend != PlaybackType.MPRIS and \ + self.playlist.position + 1 < len(self.playlist) + LOG.debug(f"Go to Next track: {go_next}") + if go_next: self.play_next() return LOG.info("Playback ended") self.gui.handle_end_of_playback(message) # ovos common play bus api requests + @require_native_source() def handle_play_request(self, message): LOG.debug("Received external OVOS playback request") repeat = message.data.get("repeat", False) @@ -758,15 +800,19 @@ def handle_play_request(self, message): disambiguation = message.data.get("disambiguation") or [media] self.play_media(media, disambiguation, playlist) + @require_native_source() def handle_pause_request(self, message): self.pause() + @require_native_source() def handle_stop_request(self, message): self.stop() + @require_native_source() def handle_resume_request(self, message): self.resume() + @require_native_source() def handle_seek_request(self, message): # from bus api miliseconds = message.data.get("seconds", 0) * 1000 @@ -781,29 +827,36 @@ def handle_seek_request(self, message): position += miliseconds self.seek(position) + @require_native_source() def handle_next_request(self, message): self.play_next() + @require_native_source() def handle_prev_request(self, message): self.play_prev() + @require_native_source() def handle_set_shuffle(self, message): self.shuffle = True self.gui.update_seekbar_capabilities() + @require_native_source() def handle_unset_shuffle(self, message): self.shuffle = False self.gui.update_seekbar_capabilities() + @require_native_source() def handle_set_repeat(self, message): self.loop_state = LoopState.REPEAT self.gui.update_seekbar_capabilities() + @require_native_source() def handle_unset_repeat(self, message): self.loop_state = LoopState.NONE self.gui.update_seekbar_capabilities() # playlist control bus api + @require_native_source() def handle_repeat_toggle_request(self, message): if self.loop_state == LoopState.REPEAT_TRACK: self.loop_state = LoopState.NONE @@ -814,26 +867,31 @@ def handle_repeat_toggle_request(self, message): LOG.info(f"Repeat: {self.loop_state}") self.gui.update_seekbar_capabilities() + @require_native_source() def handle_shuffle_toggle_request(self, message): self.shuffle = not self.shuffle LOG.info(f"Shuffle: {self.shuffle}") self.gui.update_seekbar_capabilities() + @require_native_source() def handle_playlist_set_request(self, message): self.playlist.clear() self.handle_playlist_queue_request(message) + @require_native_source() def handle_playlist_queue_request(self, message): for track in message.data["tracks"]: self.playlist.add_entry(track) self.gui.update_playlist() + @require_native_source() def handle_playlist_clear_request(self, message): self.playlist.clear() self.set_media_state(MediaState.NO_MEDIA) self.gui.update_playlist() # audio ducking + @require_native_source() def handle_duck_request(self, message): """ Pause audio on 'recognizer_loop:record_begin' @@ -843,6 +901,7 @@ def handle_duck_request(self, message): self.pause() self._paused_on_duck = True + @require_native_source() def handle_unduck_request(self, message): """ Resume paused audio on 'recognizer_loop:record_begin' @@ -853,6 +912,7 @@ def handle_unduck_request(self, message): self._paused_on_duck = False # track data + @require_native_source() def handle_track_length_request(self, message): l = self.now_playing.length if self.active_backend == PlaybackType.AUDIO_SERVICE: @@ -860,6 +920,7 @@ def handle_track_length_request(self, message): data = {"length": l} self.bus.emit(message.response(data)) + @require_native_source() def handle_track_position_request(self, message): pos = self.now_playing.position if self.active_backend == PlaybackType.AUDIO_SERVICE: @@ -867,10 +928,12 @@ def handle_track_position_request(self, message): data = {"position": pos} self.bus.emit(message.response(data)) + @require_native_source() def handle_set_track_position_request(self, message): miliseconds = message.data.get("position") self.seek(miliseconds) + @require_native_source() def handle_track_info_request(self, message): data = self.now_playing.as_dict if self.active_backend == PlaybackType.AUDIO_SERVICE: @@ -878,12 +941,13 @@ def handle_track_info_request(self, message): self.bus.emit(message.response(data)) # internal info + @require_native_source() def handle_list_backends_request(self, message): data = self.audio_service.available_backends() self.bus.emit(message.response(data)) # app timeout - @property + @property def app_view_timeout_enabled(self): return self.settings.get("app_view_timeout_enabled", False) @@ -895,18 +959,21 @@ def app_view_timeout_value(self): def app_view_timeout_mode(self): return self.settings.get("app_view_timeout_mode", "all") + @require_native_source() def handle_enable_app_timeout(self, message): self.settings["app_view_timeout_enabled"] = message.data.get("enabled", False) self.settings.store() if not self.app_view_timeout_enabled: self.gui.cancel_app_view_timeout() + @require_native_source() def handle_set_app_timeout(self, message): # timeout in seconds: 15 | 30 | 45 | 60 self.settings["app_view_timeout"] = message.data.get("timeout", 30) self.settings.store() self.gui.cancel_app_view_timeout(restart=True) + @require_native_source() def handle_set_app_timeout_mode(self, message): # timeout modes: all | pause self.settings["app_view_timeout_mode"] = message.data.get("mode", "all") diff --git a/ovos_plugin_common_play/ocp/search.py b/ovos_plugin_common_play/ocp/search.py index 26fb9fe..7f5cdc6 100644 --- a/ovos_plugin_common_play/ocp/search.py +++ b/ovos_plugin_common_play/ocp/search.py @@ -1,23 +1,20 @@ import random import time - from os.path import join, isfile from threading import RLock, Lock from typing import List +from ovos_bus_client.message import Message +from ovos_bus_client.util import get_mycroft_bus from ovos_config.locations import get_xdg_config_save_path +from ovos_plugin_manager.ocp import available_extractors from ovos_utils.gui import is_gui_connected, is_gui_running from ovos_utils.log import LOG -from ovos_bus_client.message import Message -from ovos_bus_client.util import get_mycroft_bus +from ovos_workshop.decorators.ocp import MediaType, PlaybackType, PlaybackMode from ovos_plugin_common_play.ocp.base import OCPAbstractComponent -from ovos_plugin_common_play.ocp.media import Playlist -from ovos_plugin_common_play.ocp.mycroft_cps import \ - MycroftCommonPlayInterface -from ovos_plugin_common_play.ocp.status import * -from ovos_plugin_common_play.ocp.utils import available_extractors from ovos_plugin_common_play.ocp.constants import OCP_ID +from ovos_plugin_common_play.ocp.media import Playlist class OCPQuery: @@ -220,7 +217,7 @@ def handle_skill_response(self, message): # force allowed stream types to be played audio only if res.get("media_type", "") in self.cast2audio: LOG.debug("unable to use GUI, " - "forcing result to play audio only") + "forcing result to play audio only") res["playback"] = PlaybackType.AUDIO res["match_confidence"] -= 10 results[idx] = res @@ -254,8 +251,8 @@ def handle_skill_response(self, message): self.gui.display_notification("Parsing your results") else: self.gui["footer_text"] = "Timeout!\n " \ - "selecting best result\n" \ - " " + "selecting best result\n" \ + " " elif self.searching: for res in message.data.get("results", []): @@ -306,7 +303,7 @@ def handle_skill_search_end(self, message): self.gui.display_notification("Selecting best result") else: self.gui["footer_text"] = "Received search responses from all " \ - "skills!\nselecting best result" + "skills!\nselecting best result" self.searching = False if self.gui: @@ -326,8 +323,7 @@ def __init__(self, player=None): # OCPMediaPlayer def bind(self, player): # OCPMediaPlayer self._player = player - self.old_cps = MycroftCommonPlayInterface() if \ - self.settings.get("backwards_compatibility", True) else None + self.old_cps = None if self.old_cps: self.old_cps.bind(player) self.add_event("ovos.common_play.skills.detach", diff --git a/ovos_plugin_common_play/ocp/status.py b/ovos_plugin_common_play/ocp/status.py index 925e4e7..ad36991 100644 --- a/ovos_plugin_common_play/ocp/status.py +++ b/ovos_plugin_common_play/ocp/status.py @@ -1,118 +1,2 @@ -from enum import IntEnum - - -class MatchConfidence(IntEnum): - EXACT = 95 - VERY_HIGH = 90 - HIGH = 80 - AVERAGE_HIGH = 70 - AVERAGE = 50 - AVERAGE_LOW = 30 - LOW = 15 - VERY_LOW = 1 - - -class TrackState(IntEnum): - DISAMBIGUATION = 1 # media result, not queued for playback - - PLAYING_SKILL = 20 # Skill is handling playback internally - PLAYING_AUDIOSERVICE = 21 # Skill forwarded playback to audio service - PLAYING_VIDEO = 22 # Skill forwarded playback to gui player - PLAYING_AUDIO = 23 # Skill forwarded audio playback to gui player - PLAYING_MPRIS = 24 # External media player is handling playback - PLAYING_WEBVIEW = 25 # Media playback handled in browser (eg. javascript) - - QUEUED_SKILL = 30 # Waiting playback to be handled inside skill - QUEUED_AUDIOSERVICE = 31 # Waiting playback in audio service - QUEUED_VIDEO = 32 # Waiting playback in gui - QUEUED_AUDIO = 33 # Waiting playback in gui - QUEUED_WEBVIEW = 34 # Waiting playback in gui - - -class MediaState(IntEnum): - # https://doc.qt.io/qt-5/qmediaplayer.html#MediaStatus-enum - # The status of the media cannot be determined. - UNKNOWN = 0 - # There is no current media. PlayerState == STOPPED - NO_MEDIA = 1 - # The current media is being loaded. The player may be in any state. - LOADING_MEDIA = 2 - # The current media has been loaded. PlayerState== STOPPED - LOADED_MEDIA = 3 - # Playback of the current media has stalled due to - # insufficient buffering or some other temporary interruption. - # PlayerState != STOPPED - STALLED_MEDIA = 4 - # The player is buffering data but has enough data buffered - # for playback to continue for the immediate future. - # PlayerState != STOPPED - BUFFERING_MEDIA = 5 - # The player has fully buffered the current media. PlayerState != STOPPED - BUFFERED_MEDIA = 6 - # Playback has reached the end of the current media. PlayerState == STOPPED - END_OF_MEDIA = 7 - # The current media cannot be played. PlayerState == STOPPED - INVALID_MEDIA = 8 - - -class PlayerState(IntEnum): - # https://doc.qt.io/qt-5/qmediaplayer.html#State-enum - STOPPED = 0 - PLAYING = 1 - PAUSED = 2 - - -class LoopState(IntEnum): - NONE = 0 - REPEAT = 1 - REPEAT_TRACK = 2 - - -class PlaybackType(IntEnum): - SKILL = 0 # skills handle playback whatever way they see fit, - # eg spotify / mycroft common play - VIDEO = 1 # Video results - AUDIO = 2 # Results should be played audio only - AUDIO_SERVICE = 3 # Results should be played without using the GUI - MPRIS = 4 # External MPRIS compliant player - WEBVIEW = 5 # GUI webview, render a url instead of media player - UNDEFINED = 100 # data not available, hopefully status will be updated soon.. - - -class PlaybackMode(IntEnum): - AUTO = 0 # play each entry as considered appropriate, - # ie, make it happen the best way possible - AUDIO_ONLY = 10 # only consider audio entries - VIDEO_ONLY = 20 # only consider video entries - FORCE_AUDIO = 30 # cast video to audio unconditionally - # (audio can still play in mycroft-gui) - FORCE_AUDIOSERVICE = 40 # cast everything to audio service backend, - # mycroft-gui will not be used - EVENTS_ONLY = 50 # only emit ocp events, do not display or play anything. - # allows integration with external interfaces - - -class MediaType(IntEnum): - GENERIC = 0 - AUDIO = 1 - MUSIC = 2 - VIDEO = 3 - AUDIOBOOK = 4 - GAME = 5 - PODCAST = 6 - RADIO = 7 - NEWS = 8 - TV = 9 - MOVIE = 10 - TRAILER = 11 - VISUAL_STORY = 13 - BEHIND_THE_SCENES = 14 - DOCUMENTARY = 15 - RADIO_THEATRE = 16 - SHORT_FILM = 17 - SILENT_MOVIE = 18 - BLACK_WHITE_MOVIE = 20 - CARTOON = 21 - - ADULT = 69 - HENTAI = 70 +from ovos_workshop.decorators.ocp import * +from ovos_plugin_common_play.ocp.constants import OCP_ID \ No newline at end of file diff --git a/ovos_plugin_common_play/ocp/utils.py b/ovos_plugin_common_play/ocp/utils.py index f4a7860..bb2b367 100644 --- a/ovos_plugin_common_play/ocp/utils.py +++ b/ovos_plugin_common_play/ocp/utils.py @@ -1,49 +1,57 @@ -import mimetypes import shutil from os import makedirs from os.path import expanduser, isfile, join, dirname, exists -from typing import List +from ovos_config import Configuration +from ovos_utils.log import LOG +from ovos_plugin_manager.ocp import load_stream_extractors, available_extractors +from functools import wraps -from ovos_plugin_manager.ocp import StreamHandler -from ovos_plugin_common_play.ocp.status import TrackState, PlaybackType -from ovos_ocp_files_plugin.plugin import OCPFilesMetadataExtractor -_plugins = None +def validate_message_context(message, native_sources=None): + destination = message.context.get("destination") + if destination: + native_sources = native_sources or Configuration()["Audio"].get( + "native_sources", ["debug_cli", "audio"]) or [] + if any(s in destination for s in native_sources): + # request from device + return True + # external request, do not handle + return False + # broadcast for everyone + return True -def ocp_plugins(): - global _plugins - _plugins = _plugins or StreamHandler() - return _plugins +def require_native_source(): + def _decorator(func): + @wraps(func) + def func_wrapper(self, message=None): + validated = message is None or \ + not self.validate_source or \ + validate_message_context(message, self.native_sources) + if validated: + return func(self, message) + LOG.debug("ignoring OCP bus message, not from a native audio source") + return None -def is_qtav_available(): - return exists("/usr/include/qt/QtAV") or \ - exists("/usr/lib/qt/qml/QtAV") or \ - exists("/usr/lib/libQtAV.so") + return func_wrapper + return _decorator -def find_mime(uri): - """ Determine mime type. """ - mime = mimetypes.guess_type(uri) - if mime: - return mime - else: - return None +def ocp_plugins(): + return load_stream_extractors() -def available_extractors() -> List[str]: - """ - Get a list of supported Stream Extractor Identifiers. Note that these look - like but are not URI schemes. - @return: List of supported SEI prefixes - """ - return ["/", "http:", "https:", "file:"] + \ - [f"{sei}//" for sei in ocp_plugins().supported_seis] + +def is_qtav_available(): + return exists("/usr/include/qt/QtAV") or \ + exists("/usr/lib/qt/qml/QtAV") or \ + exists("/usr/lib/libQtAV.so") def extract_metadata(uri): # backwards compat + from ovos_ocp_files_plugin.plugin import OCPFilesMetadataExtractor return OCPFilesMetadataExtractor.extract_metadata(uri) diff --git a/ovos_plugin_common_play/version.py b/ovos_plugin_common_play/version.py index c5aed19..8687daf 100644 --- a/ovos_plugin_common_play/version.py +++ b/ovos_plugin_common_play/version.py @@ -2,6 +2,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 0 VERSION_MINOR = 0 -VERSION_BUILD = 6 +VERSION_BUILD = 7 VERSION_ALPHA = 0 # END_VERSION_BLOCK diff --git a/requirements/requirements.txt b/requirements/requirements.txt index f227f11..92674e1 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,7 +1,6 @@ -ovos-plugin-manager >= 0.0.25, < 0.1.0 +ovos-plugin-manager >=0.0.26a5, < 0.1.0 ovos-bus-client>=0.0.7, < 0.1.0 -ovos-utils>=0.0.38,<1.0.0 -ovos_workshop >=0.0.15, < 0.1.0 -ovos-ocp-files-plugin~=0.13 +ovos-utils>=0.0.38, < 1.0.0 +ovos-workshop>=0.0.16a48, < 0.1.0 padacioso~=0.1, >=0.1.1 dbus-next diff --git a/requirements/requirements_extra.txt b/requirements/requirements_extra.txt index 2114711..94a38e0 100644 --- a/requirements/requirements_extra.txt +++ b/requirements/requirements_extra.txt @@ -5,3 +5,4 @@ ovos-ocp-bandcamp-plugin~=0.0, >=0.0.1 ovos-ocp-m3u-plugin>0.0, >=0.0.1 ovos-ocp-news-plugin~=0.0, >=0.0.3 ovos_audio_plugin_simple>=0.0.2a3, < 0.1.0 +ovos-ocp-files-plugin~=0.13 \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt index e5dacff..600fe03 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -5,12 +5,11 @@ pytest-timeout~=2.1 pytest-cov~=4.1 ovos-core~=0.0.7 ovos-audio - +ovos-utils>=0.1.0a17, < 1.0.0 # TODO: Below patching skills_lgpl pyyaml dependency per https://github.com/yaml/pyyaml/issues/724 adapt-parser~=0.5 padacioso~=0.1.2 ovos-lingua-franca~=0.4, >=0.4.2 PyYAML~=6.0 -ovos_workshop~=0.0.10 padatious~=0.4.8 fann2==1.0.7 \ No newline at end of file diff --git a/test/unittests/test_audio_backends.py b/test/unittests/test_audio_backends.py index cc02106..e00d2b0 100644 --- a/test/unittests/test_audio_backends.py +++ b/test/unittests/test_audio_backends.py @@ -51,26 +51,6 @@ def get_msg(msg): self.audio = AudioService(self.bus) - def test_native_ocp(self): - # assert that OCP is the selected default backend - self.assertTrue(isinstance(self.audio.default, OCPAudioBackend)) - - # assert that OCP is in "auto" mode - self.assertEqual(self.audio.default.config["mode"], "auto") - - # assert that OCP is loaded - self.assertTrue(self.audio.default.ocp is not None) - self.assertTrue(isinstance(self.audio.default.ocp, OCP)) - - # assert that test backends also loaded - # NOTE: "service" is a list, should be named "services" - # not renamed for backwards compat but its a typo! - loaded_services = [s.name for s in self.audio.service] - self.assertIn("OCP", loaded_services) - # TODO fix me, add dummy plugins - #self.assertIn("mycroft_test", loaded_services) - #self.assertIn("ovos_test", loaded_services) - def tearDown(self) -> None: self.audio.shutdown() diff --git a/test/unittests/test_external_ocp.py b/test/unittests/test_external_ocp.py index a75017d..7df6480 100644 --- a/test/unittests/test_external_ocp.py +++ b/test/unittests/test_external_ocp.py @@ -47,17 +47,6 @@ def get_msg(msg): cls.bus.on("message", get_msg) - @patch.object(Configuration, 'load_all_configs') - def test_external_ocp(self, mock): - mock.return_value = BASE_CONF - audio = AudioService(self.bus) - self.assertEqual(audio.config, BASE_CONF["Audio"]) - # assert that ocp is in external mode - self.assertEqual(audio.default.config["mode"], "external") - # assert that OCP is not loaded - self.assertTrue(audio.default.ocp is None) - audio.shutdown() - if __name__ == '__main__': unittest.main() diff --git a/test/unittests/test_ocp.py b/test/unittests/test_ocp.py index 2902880..348ce0f 100644 --- a/test/unittests/test_ocp.py +++ b/test/unittests/test_ocp.py @@ -41,9 +41,6 @@ def _handle_skills_check(msg): self.bus.once('mycroft.skills.is_ready', _handle_skills_check) self.bus.emit(Message('mycroft.ready')) - 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"), @@ -78,7 +75,7 @@ def test_classify_media(self): movie = "play a movie" news = "play the latest news" unknown = "play something" - + self.ocp.register_media_intents() 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) @@ -142,8 +139,27 @@ def test_handle_read(self): pass def test_do_play(self): - # TODO - pass + called = False + + def play_media(*args, **kwargs): + nonlocal called + called = True + + self.ocp.player.play_media = play_media + + msg = Message("") + self.ocp.player.handle_play_request(msg) + self.assertTrue(called) # no message.context -> broadcast for everyone + + msg = Message("", {}, {"destination": "audio"}) + called = False + self.ocp.player.handle_play_request(msg) + self.assertTrue(called) # "audio" is a native source + + msg = Message("", {}, {"destination": "hive"}) + called = False + self.ocp.player.handle_play_request(msg) + self.assertFalse(called) # ignored playback for remote client def test_search(self): # TODO diff --git a/test/unittests/test_ocp_media.py b/test/unittests/test_ocp_media.py index ec9dd21..1b0639f 100644 --- a/test/unittests/test_ocp_media.py +++ b/test/unittests/test_ocp_media.py @@ -3,7 +3,7 @@ from ovos_bus_client import Message -from ovos_plugin_common_play.ocp.media import MediaEntry, Playlist, NowPlaying +from ovos_plugin_common_play.ocp.media import MediaEntry, NowPlaying, Playlist, _ME as RealMediaEntry from ovos_plugin_common_play.ocp.status import MediaType, PlaybackType, TrackState, MediaState from ovos_utils.messagebus import FakeBus @@ -52,7 +52,6 @@ def test_init(self): 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) @@ -78,12 +77,13 @@ def test_update(self): def test_from_dict(self): dict_data = valid_search_results[1] from_dict = MediaEntry.from_dict(dict_data) - self.assertIsInstance(from_dict, MediaEntry) + self.assertIsInstance(from_dict, RealMediaEntry) 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"], + media_type=dict_data["media_type"], artist=dict_data["artist"]) self.assertEqual(from_init, from_dict) @@ -92,7 +92,7 @@ def test_from_dict(self): new_entry = MediaEntry.from_dict(dict_data) self.assertEqual(from_dict, new_entry) - self.assertIsInstance(MediaEntry.from_dict({}), MediaEntry) + self.assertIsInstance(MediaEntry.from_dict({"uri": "test"}), RealMediaEntry) def test_info(self): # TODO @@ -128,10 +128,11 @@ def test_properties(self): # Playlist of dicts pl = Playlist(valid_search_results) self.assertEqual(pl.position, 0) + self.assertEqual(len(pl), len(valid_search_results)) 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.assertIsInstance(entry, RealMediaEntry) + self.assertIsInstance(pl.current_track, RealMediaEntry) self.assertTrue(pl.is_first_track) self.assertFalse(pl.is_last_track) @@ -178,34 +179,34 @@ def test_prev_track(self): def test_validate_position(self): # Test empty playlist pl = Playlist() - pl._position = 0 + pl.position = 0 pl._validate_position() self.assertEqual(pl.position, 0) - pl._position = -1 + pl.position = -1 pl._validate_position() self.assertEqual(pl.position, 0) - pl._position = 1 + 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.position = 0 pl._validate_position() self.assertEqual(pl.position, 0) - pl._position = 1 + 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.position = 0 pl._validate_position() self.assertEqual(pl.position, 0) - pl._position = 1 + pl.position = 1 pl._validate_position() self.assertEqual(pl.position, 1) - pl._position = 10 + pl.position = 10 pl._validate_position() self.assertEqual(pl.position, 0) @@ -258,7 +259,7 @@ def test_as_entry(self): player = NowPlaying() player.update(entry) self.assertNotIsInstance(player.as_entry(), NowPlaying) - self.assertIsInstance(player.as_entry(), MediaEntry) + self.assertIsInstance(player.as_entry(), RealMediaEntry) self.assertEqual(player.as_entry(), entry) def test_reset(self): @@ -274,7 +275,7 @@ def test_reset(self): # 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.data, dict()) # self.assertNotEqual(self.player.phrase, None) # self.assertNotEqual(self.player.javascript, "") self.assertNotEqual(self.player.playback, PlaybackType.UNDEFINED) diff --git a/test/unittests/test_ocp_player.py b/test/unittests/test_ocp_player.py index 8a65d83..b80166d 100644 --- a/test/unittests/test_ocp_player.py +++ b/test/unittests/test_ocp_player.py @@ -41,7 +41,7 @@ class TestOCPPlayer(unittest.TestCase): bus = FakeBus() - player = OCPMediaPlayer(bus) + player = OCPMediaPlayer(bus, settings={'disable_mpris': False}) emitted_msgs = [] @classmethod @@ -93,7 +93,7 @@ def test_00_player_init(self): 'ovos.common_play.play', 'ovos.common_play.pause', 'ovos.common_play.resume', - 'ovos.common_play.stop', + #'ovos.common_play.stop', 'ovos.common_play.next', 'ovos.common_play.previous', 'ovos.common_play.seek', @@ -215,12 +215,10 @@ 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]) @@ -241,8 +239,6 @@ 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() @@ -256,8 +252,6 @@ 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() @@ -265,7 +259,6 @@ 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() @@ -276,12 +269,10 @@ 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): @@ -423,7 +414,7 @@ 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"]) + ["mpv", "ovos_common_play", "vlc", "mplayer", "simple"]) @patch("ovos_plugin_common_play.ocp.player.is_gui_running") def test_play(self, gui_running): @@ -507,15 +498,6 @@ def test_play(self, gui_running): 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 diff --git a/test/unittests/test_utils.py b/test/unittests/test_utils.py index 3c9a10d..5f316c1 100644 --- a/test/unittests/test_utils.py +++ b/test/unittests/test_utils.py @@ -4,7 +4,7 @@ class TestUtils(unittest.TestCase): def test_available_extractors(self): - from ovos_plugin_common_play.ocp.utils import available_extractors + from ovos_plugin_manager.ocp import available_extractors extractors = available_extractors() self.assertIsInstance(extractors, list) for ex in extractors: