diff --git a/mixtape/__init__.py b/mixtape/__init__.py index 6bf7c45..3ee3775 100644 --- a/mixtape/__init__.py +++ b/mixtape/__init__.py @@ -1,3 +1,5 @@ -from .players import AsyncPlayer +from .players import Player +from .boombox import BoomBox, hookspec -__all__ = ["AsyncPlayer"] + +__all__ = ["Player", "BoomBox", "hookspec"] diff --git a/mixtape/base.py b/mixtape/base.py deleted file mode 100644 index 2171d7b..0000000 --- a/mixtape/base.py +++ /dev/null @@ -1,114 +0,0 @@ -import logging -from typing import Tuple, Type, TypeVar - -import attr -import gi - -gi.require_version("Gst", "1.0") -from gi.repository import Gst - -from .exceptions import PlayerSetStateError - - -logger = logging.getLogger(__name__) - -BasePlayerType = TypeVar("BasePlayerType", bound="BasePlayer") - - -@attr.s -class BasePlayer: - """Player base player""" - - # TODO: configuration for set_auto_flush_bus - # as the application depends on async bus messages - # we might want to handle flushing the bus ourselves, - # otherwise setting the pipeline to `Gst.State.NULL` - # flushes the bus including the state change messages - # self.pipeline.set_auto_flush_bus(False) - - pipeline: Gst.Pipeline = attr.ib() - init: bool = attr.ib(init=False, default=False) - - def __del__(self) -> None: - """ - Make sure that the gstreamer pipeline is always cleaned up - """ - if self.state is not Gst.State.NULL: - logger.warning("Player cleanup on destructor") - self.teardown() - - @property - def bus(self) -> Gst.Bus: - """Convenience property for the pipeline Gst.Bus""" - return self.pipeline.get_bus() - - @property - def state(self) -> Gst.State: - """Convenience property for the current pipeline Gst.State""" - return self.pipeline.get_state(0)[1] - - def set_state(self, state: Gst.State) -> Tuple[Gst.StateChangeReturn, Gst.State, Gst.State]: - """Set pipeline state""" - if not self.init: - logger.warning("Calling set_state without calling setup. Trying to do this now.") - self.setup() - ret = self.pipeline.set_state(state) - if ret == Gst.StateChangeReturn.FAILURE: - raise PlayerSetStateError - return ret - - def setup(self) -> None: - """ - Player setup: meant to be used with hooks or subclassed - Call super() after custom code. - """ - self.init = True - - def teardown(self) -> None: - """Player teardown: by default sets the pipeline to Gst.State.NULL""" - if self.state is not Gst.State.NULL: - self.set_state(Gst.State.NULL) - - def ready(self) -> Tuple[Gst.StateChangeReturn, Gst.State, Gst.State]: - """Set pipeline to state to Gst.State.READY""" - return self.set_state(Gst.State.READY) - - def play(self) -> Tuple[Gst.StateChangeReturn, Gst.State, Gst.State]: - """Set pipeline to state to Gst.State.PLAY""" - return self.set_state(Gst.State.PLAYING) - - def pause(self) -> Tuple[Gst.StateChangeReturn, Gst.State, Gst.State]: - """Set pipeline to state to Gst.State.PAUSED""" - return self.set_state(Gst.State.PAUSED) - - # fmt: off - def stop(self, send_eos: bool = False, teardown: bool = False) -> Tuple[Gst.StateChangeReturn, Gst.State, Gst.State]: - """Set pipeline to state to Gst.State.NULL, with the option of sending eos and teardown""" - # fmt: on - if send_eos: - self.send_eos() - - ret = self.set_state(Gst.State.NULL) - - if teardown: - self.teardown() - - return ret - - def send_eos(self) -> bool: - """Send a eos event to the pipeline""" - return self.pipeline.send_event(Gst.Event.new_eos()) - - @classmethod - def create(cls: Type[BasePlayerType], pipeline: Gst.Pipeline) -> BasePlayerType: - """Player factory from a given pipeline that calls setup by default""" - player = cls(pipeline) - player.setup() - return player - - @classmethod - def from_description(cls: Type[BasePlayerType], description: str) -> BasePlayerType: - """Player factory from a pipeline description""" - pipeline = Gst.parse_launch(description) - assert isinstance(pipeline, Gst.Pipeline) - return cls.create(pipeline=pipeline) diff --git a/mixtape/boombox.py b/mixtape/boombox.py new file mode 100644 index 0000000..f86b3c5 --- /dev/null +++ b/mixtape/boombox.py @@ -0,0 +1,210 @@ +import logging + +from collections import ChainMap, UserDict +from typing import Any, List, Mapping, Optional, Tuple + +import attr +import gi +import simplug + +gi.require_version("Gst", "1.0") +from gi.repository import Gst + +from .players import Player +from .exceptions import BoomBoxNotConfigured + +logger = logging.getLogger(__name__) + + +hookspec = simplug.Simplug("mixtape") + + +class Context(UserDict[Any, Any]): + """ + Application state object + """ + + +class PluginSpec: + """Mixtape plugin namespace""" + + @hookspec.spec + def mixtape_plugin_init(self, ctx: Context) -> None: + """Called""" + + @hookspec.spec + def mixtape_add_pipelines(self, ctx: Context) -> Mapping[str, Any]: + """ + Hook allowing a plugin to return a pipeline + """ + + # player init and teardown + + @hookspec.spec + def mixtape_player_setup(self, ctx: Context, player: Player) -> None: + """ + Hook called on player setup + """ + + @hookspec.spec + def mixtape_player_teardown(self, ctx: Context, player: Player) -> None: + """ + Hook called on player teardown + """ + + # pipeline control and event hooks + + @hookspec.spec + async def mixtape_on_message(self, ctx: Context, player: Player, message: Gst.Message) -> None: + """ + Generic hook for all bus messages + """ + + @hookspec.spec + async def mixtape_before_state_changed( + self, ctx: Context, player: Player, state: Gst.State + ) -> None: + """ + Hook called before a `set_state` call. + """ + + @hookspec.spec + async def mixtape_on_ready(self, ctx: Context, player: Player) -> None: + """ + Shortcut Hook called on state changed to READY + """ + + @hookspec.spec + async def mixtape_on_pause(self, ctx: Context, player: Player) -> None: + """ + Shortcut Hook called on state changed to PAUSED + """ + + @hookspec.spec + async def mixtape_on_play(self, ctx: Context, player: Player) -> None: + """ + Shortcut Hook called on state changed to PLAYING + """ + + @hookspec.spec + async def mixtape_on_stop(self, ctx: Context, player: Player) -> None: + """ + Hook called on state changed to NULL + """ + + # asyncio player events + + @hookspec.spec + async def mixtape_on_eos(self, ctx: Context, player: Player) -> None: + """ + Hook called on eos + """ + + @hookspec.spec + async def mixtape_on_error(self, ctx: Context, player: Player) -> None: + """ + Hook called on bus message error + """ + + +@attr.s +class BoomBox: + """ + Facade object that orchestrates plugin callbacks + and exposes plugin events and properties. + """ + + player: Player = attr.ib() + context: Context = attr.ib(repr=False) + _hookspec: simplug.Simplug = attr.ib(repr=False) + + @property + def _hooks(self) -> simplug.SimplugHooks: + """Shortcut property for plugin hooks""" + return self._hookspec.hooks + + def setup(self) -> None: + """Wrapper for player setup""" + self.player.setup() + self._hooks.mixtape_player_setup(ctx=self.context, player=self.player) + + def teardown(self) -> None: + """wrapper for player teardown""" + self._hooks.mixtape_player_teardown(ctx=self.context, player=self.player) + self.player.teardown() + + async def ready(self) -> Tuple[Gst.StateChangeReturn, Gst.State, Gst.State]: + """Wrapper for player ready""" + await self._hooks.mixtape_before_state_changed( + ctx=self.context, player=self.player, state=Gst.State.READY + ) + ret = await self.player.ready() + await self._hooks.mixtape_on_ready(ctx=self.context, player=self.player) + return ret + + async def pause(self) -> Tuple[Gst.StateChangeReturn, Gst.State, Gst.State]: + """Wrapper for player pause""" + await self._hooks.mixtape_before_state_changed( + ctx=self.context, player=self.player, state=Gst.State.PAUSED + ) + ret = await self.player.pause() + await self._hooks.mixtape_on_pause(ctx=self.context, player=self.player) + return ret + + async def play(self) -> Tuple[Gst.StateChangeReturn, Gst.State, Gst.State]: + """Wrapper for player play""" + await self._hooks.mixtape_before_state_changed( + ctx=self.context, player=self.player, state=Gst.State.PLAYING + ) + ret = await self.player.play() + await self._hooks.mixtape_on_play(ctx=self.context, player=self.player) + return ret + + async def stop(self) -> Tuple[Gst.StateChangeReturn, Gst.State, Gst.State]: + """Wrapper for player stop""" + await self._hooks.mixtape_before_state_changed( + ctx=self.context, player=self.player, state=Gst.State.NULL + ) + ret = await self.player.stop() + await self._hooks.mixtape_on_stop(ctx=self.context, player=self.player) + return ret + + @classmethod + async def init( + cls, + player: Optional[Any] = None, + context: Optional[Context] = None, + plugins: Optional[List[Any]] = None, + **settings: Any, + ) -> "BoomBox": + """Boombox async init method""" + + # load plugins + if plugins: + for plugin in plugins: + hookspec.register(plugin) + else: + hookspec.load_entrypoints("mixtape") + + # init context + + if context is None: + context = Context() + + # init plugins + + hookspec.hooks.mixtape_plugin_init(ctx=Context) + + # init player + + if not player: + context["pipelines"] = ChainMap(*hookspec.hooks.mixtape_add_pipelines(ctx=context)) + + try: + description = context["pipelines"][settings["name"]] + except AttributeError: + raise BoomBoxNotConfigured("Pipeline needed explicitly or provided by hook") + else: + player = await Player.from_description(description) + + return cls(player, context, hookspec) diff --git a/mixtape/events.py b/mixtape/events.py index de0879f..79e85b8 100644 --- a/mixtape/events.py +++ b/mixtape/events.py @@ -10,6 +10,8 @@ class States(enum.Enum): + """Python enum representing the Gst.Pipeline states""" + VOID_PENDING = 0 NULL = 1 READY = 2 @@ -36,10 +38,10 @@ class TearDownEvent(asyncio.Event): @attr.s(auto_attribs=True, slots=True, frozen=True) class PlayerEvents: """ - Async event syncronization flags + Player async event synchronization flags """ - # simple events + # core events setup: asyncio.Event = attr.Factory(SetupEvent) eos: asyncio.Event = attr.Factory(EosEvent) error: asyncio.Event = attr.Factory(ErrorEvent) diff --git a/mixtape/exceptions.py b/mixtape/exceptions.py index df08c2f..8cfcf27 100644 --- a/mixtape/exceptions.py +++ b/mixtape/exceptions.py @@ -1,5 +1,5 @@ class MixTapeError(Exception): - """Mixtape exception base clase""" + """Mixtape exception base class""" class PlayerNotConfigured(MixTapeError): @@ -16,3 +16,7 @@ class PlayerSetStateError(MixTapeError): class PlayerPipelineError(MixTapeError): """Error originating from Gst Pipeline""" + + +class BoomBoxNotConfigured(MixTapeError): + """Boombox improperly configured""" diff --git a/mixtape/features/__init__.py b/mixtape/features/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/mixtape/features/cmdline.py b/mixtape/features/cmdline.py deleted file mode 100644 index e69de29..0000000 diff --git a/mixtape/features/console.py b/mixtape/features/console.py deleted file mode 100644 index e69de29..0000000 diff --git a/mixtape/features/dbus.py b/mixtape/features/dbus.py deleted file mode 100644 index e69de29..0000000 diff --git a/mixtape/features/http.py b/mixtape/features/http.py deleted file mode 100644 index e69de29..0000000 diff --git a/mixtape/players.py b/mixtape/players.py index ed2dc8a..3a2ef2b 100644 --- a/mixtape/players.py +++ b/mixtape/players.py @@ -1,20 +1,20 @@ import asyncio import logging -from typing import Any, Type, TypeVar, Tuple, List, Callable, MutableMapping -import attr import warnings +from typing import Any, Callable, List, MutableMapping, Tuple, Type, TypeVar + +import attr import gi gi.require_version("Gst", "1.0") from gi.repository import Gst -from .exceptions import PlayerSetStateError, PlayerPipelineError, PlayerNotConfigured from .events import PlayerEvents +from .exceptions import PlayerNotConfigured, PlayerPipelineError, PlayerSetStateError logger = logging.getLogger(__name__) -PlayerType = TypeVar("PlayerType", bound="Player") -AsyncPlayerType = TypeVar("AsyncPlayerType", bound="AsyncPlayer") +P = TypeVar("P", bound="Player") @attr.s @@ -41,8 +41,6 @@ def __del__(self) -> None: """ Make sure that the gstreamer pipeline is always cleaned up """ - if self.state is not Gst.State.NULL: - self.teardown() @property def bus(self) -> Gst.Bus: @@ -190,7 +188,7 @@ def _on_eos(self, bus: Gst.Bus, message: Gst.Message) -> None: Handler for eos messages By default it sets the eos event """ - logger.info("EOS message: %s received from pipeline on %s", message, bus) + logger.info("Received EOS message on bus") self.events.eos.set() def _on_async_done(self, bus: Gst.Bus, message: Gst.Message) -> None: @@ -228,18 +226,19 @@ def _on_qos(self, bus: Gst.Bus, message: Gst.Message) -> None: ) @classmethod - async def create(cls: Type[PlayerType], pipeline: Gst.Pipeline) -> PlayerType: + async def create(cls: Type[P], pipeline: Gst.Pipeline) -> P: """Player factory from a given pipeline that calls setup by default""" player = cls(pipeline) player.setup() return player @classmethod - async def from_description(cls: Type[PlayerType], description: str) -> PlayerType: + async def from_description(cls: Type[P], description: str) -> P: """Player factory from a pipeline description""" pipeline = Gst.parse_launch(description) - assert isinstance(pipeline, Gst.Pipeline) - return await cls.create(pipeline=pipeline) + if not isinstance(pipeline, Gst.Pipeline): + raise ValueError("Invalid pipeline description") + return await cls.create(pipeline) class AsyncPlayer(Player): diff --git a/mypy.ini b/mypy.ini index 999eff9..5cd063b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -24,7 +24,7 @@ disallow_any_generics=True disallow_incomplete_defs = True disallow_subclassing_any = True -disallow_untyped_decorators = True +disallow_untyped_decorators = False disallow_untyped_defs = True check_untyped_defs=True @@ -50,5 +50,5 @@ ignore_missing_imports = True [mypy-beppu.*] ignore_missing_imports = True -[mypy-pampy.*] -ignore_missing_imports = True \ No newline at end of file +[mypy-simplug.*] +ignore_missing_imports = True diff --git a/pytest.ini b/pytest.ini index f3f947b..a44ebac 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,4 @@ [pytest] addopts = -ra --tb=short --cov-report=xml --cov --mypy --flake8 --pylint --black timeout = 20 -timeout_method = thread \ No newline at end of file +timeout_method = thread diff --git a/req-install.txt b/req-install.txt index 87cbba5..d29117c 100644 --- a/req-install.txt +++ b/req-install.txt @@ -1,2 +1,5 @@ attrs -beppu \ No newline at end of file +beppu +click==7.1.2 +simplug==0.0.6 +prompt-toolkit==3.0.6 diff --git a/setup.py b/setup.py index 03f1e66..1901447 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ def parse_requirements(filename): tests_require=TEST_DEPS, extras_require=EXTRAS, install_requires=INSTALL_DEPS, + entry_points={"console_scripts": ["mixtape = mixtape.cli:play"]}, classifiers=[ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", diff --git a/tests/conftest.py b/tests/conftest.py index f7b24b0..9269057 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,7 @@ # flake8 plugin is way too verbose def pytest_configure(config): - logging.getLogger("flake8").setLevel(logging.WARN) + logging.getLogger("flake8").setLevel(logging.ERROR) logging.getLogger("bandit").setLevel(logging.WARN) logging.getLogger("blib2to3").setLevel(logging.WARN) logging.getLogger("stevedore").setLevel(logging.WARN) @@ -48,17 +48,8 @@ def pipeline(Gst): return pipeline -@pytest.fixture -def error_pipeline(Gst): - """Error pipeline""" - ERROR_PIPELINE_DESCRIPTION = "filesrc ! queue ! fakesink" - pipeline = Gst.parse_launch(ERROR_PIPELINE_DESCRIPTION) - assert isinstance(pipeline, Gst.Pipeline) - return pipeline - - @pytest.fixture def player(Gst): - from mixtape.players import AsyncPlayer + from mixtape import Player - return AsyncPlayer + return Player diff --git a/tests/test_boombox.py b/tests/test_boombox.py new file mode 100644 index 0000000..5f159ce --- /dev/null +++ b/tests/test_boombox.py @@ -0,0 +1,22 @@ +# type: ignore +import pytest +import asyncio +from mixtape import BoomBox, hookspec + + +@pytest.mark.asyncio +async def test_boombox_usage(Gst): + class PluginA: + @hookspec.impl + def mixtape_add_pipelines(self, ctx): + return {"simple": "videotestsrc ! queue ! fakesink"} + + class PluginB: + @hookspec.impl + def mixtape_add_pipelines(self, ctx): + return {"error": "filesink ! queue ! fakesink"} + + b = await BoomBox.init(plugins=[PluginA, PluginB], name="simple") + await b.play() + await asyncio.sleep(5) + await b.stop() diff --git a/tests/test_events.py b/tests/test_events.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_player.py b/tests/test_player.py index a5611ff..2e13987 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -7,7 +7,7 @@ gi.require_version("Gst", "1.0") from gi.repository import Gst -from mixtape.players import Player +from mixtape import Player from mixtape.exceptions import PlayerSetStateError, PlayerNotConfigured @@ -32,8 +32,9 @@ async def test_error_state_change_before_setup(pipeline): @pytest.mark.asyncio -async def test_gst_error_on_start_exception(error_pipeline): - player = Player(error_pipeline) +async def test_gst_error_on_start_exception(Gst): + pipeline = Gst.parse_launch("filesrc ! fakesink") + player = Player(pipeline) player.setup() with pytest.raises(PlayerSetStateError):