diff --git a/.gitignore b/.gitignore index f1d1daa..79e7e24 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ *.patch PKGBUILD emmental.gresource* +emmental/mpris2/*.xml diff --git a/.gitmodules b/.gitmodules index 71b0c47..4cff844 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "aur"] path = aur url = ssh://aur@aur.archlinux.org/emmental.git +[submodule "mpris-spec"] + path = mpris-spec + url = https://github.com/freedesktop/mpris-spec.git diff --git a/Makefile b/Makefile index e080595..4d54c71 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,12 @@ export EMMENTAL_LIB = ${PREFIX}/lib/emmental export EMMENTAL_BIN = ${PREFIX}/bin export EMMENTAL_SHARE = ${PREFIX}/share -all: emmental.gresource flake8 +all: emmental.gresource mpris2 flake8 clean: find . -type f -name "*gresource*" -exec rm {} \+ find . -type d -name __pycache__ -exec rm -r {} \+ + find emmental/mpris2/ -type f -name "*.xml" -exec rm {} \+ find data/ -type d -name "Test Album" -exec rm -r {} \+ find data/ -type d -name "Test Library" -exec rm -r {} \+ @@ -18,6 +19,16 @@ clean: flake8: flake8 emmental/ tests/ +mpris-spec/Makefile: + git submodule init mpris-spec + git submodule update + +emmental/mpris2/MediaPlayer2.xml: mpris-spec/Makefile + cp mpris-spec/spec/org.mpris.MediaPlayer2.xml emmental/mpris2/MediaPlayer2.xml + +.PHONY: mpris2 +mpris2: emmental/mpris2/MediaPlayer2.xml + .PHONY: emmental.gresource.xml emmental.gresource.xml: exec tools/find-resources.py @@ -49,7 +60,7 @@ pkgbuild: cd aur && makepkg --printsrcinfo > .SRCINFO .PHONY: pytest -pytest: emmental.gresource +pytest: emmental.gresource mpris2 pytest .PHONY: tests diff --git a/emmental/__init__.py b/emmental/__init__.py index 44ed9f7..2ae5e42 100644 --- a/emmental/__init__.py +++ b/emmental/__init__.py @@ -2,6 +2,7 @@ """Set up our Application.""" from . import gsetup from . import db +from . import mpris2 from . import options from gi.repository import GObject from gi.repository import GLib @@ -16,6 +17,7 @@ class Application(Adw.Application): """Our custom Adw.Application.""" db = GObject.Property(type=db.Connection) + mpris = GObject.Property(type=mpris2.Connection) def __init__(self): """Initialize an Application.""" @@ -35,6 +37,7 @@ class Application(Adw.Application): """Handle the Adw.Application::startup signal.""" Adw.Application.do_startup(self) self.db = db.Connection() + self.mpris = mpris2.Connection() gsetup.add_style() self.db.load() @@ -46,6 +49,9 @@ class Application(Adw.Application): def do_shutdown(self) -> None: """Handle the Adw.Application::shutdown signal.""" Adw.Application.do_shutdown(self) + if self.mpris is not None: + self.mpris.disconnect() + self.mpris = None if self.db is not None: self.db.close() self.db = None diff --git a/emmental/mpris2/__init__.py b/emmental/mpris2/__init__.py new file mode 100644 index 0000000..2bd418e --- /dev/null +++ b/emmental/mpris2/__init__.py @@ -0,0 +1,46 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Implement the MPRIS2 Specification.""" +from gi.repository import GObject +from gi.repository import Gio +from . import application + +MPRIS2_ID = f"org.mpris.MediaPlayer2.emmental{'-debug' if __debug__ else ''}" + + +class Connection(GObject.GObject): + """Our Mpris2 Object.""" + + dbus = GObject.Property(type=Gio.DBusConnection) + + def __init__(self): + """Initialize Mpris2.""" + super().__init__() + self.app = application.Application() + + self.bind_property("dbus", self.app, "dbus") + + self._busid = Gio.bus_own_name(Gio.BusType.SESSION, MPRIS2_ID, + Gio.BusNameOwnerFlags.NONE, + self.__on_bus_acquired, None, + self.__on_name_lost) + + def __del__(self): + """Clean up.""" + self.disconnect() + + def __on_bus_acquired(self, dbus: Gio.DBusConnection, name: str) -> None: + self.dbus = dbus + self.app.register(dbus) + + def __on_name_lost(self, dbus: Gio.DBusConnection, name: str) -> None: + self.app.unregister(dbus) + + def disconnect(self): + """Disconnect from dbus.""" + if self.dbus: + self.app.unregister(self.dbus) + self.dbus = None + + if self._busid: + Gio.bus_unown_name(self._busid) + self._busid = None diff --git a/emmental/mpris2/application.py b/emmental/mpris2/application.py new file mode 100644 index 0000000..441fa03 --- /dev/null +++ b/emmental/mpris2/application.py @@ -0,0 +1,49 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Our Mpris2 Application dbus Object.""" +import pathlib +from gi.repository import GObject +from gi.repository import GLib +from . import dbus + +MPRIS2_XML = pathlib.Path(__file__).parent / "MediaPlayer2.xml" + + +class Application(dbus.Object): + """The mpris2 Application dbus object.""" + + CanQuit = GObject.Property(type=bool, default=True) + Fullscreen = GObject.Property(type=bool, default=False) + CanSetFullscreen = GObject.Property(type=bool, default=True) + CanRaise = GObject.Property(type=bool, default=True) + HasTrackList = GObject.Property(type=bool, default=False) + Identity = GObject.Property(type=str, default="Emmental Music Player") + DesktopEntry = GObject.Property(type=str, default="emmental") + + def __init__(self): + """Initialize the mpris2 application object.""" + super().__init__(xml=MPRIS2_XML) + + def do_notify(self, property: str) -> None: + """Notify DBus when the Fullscreen property changes.""" + match property: + case "Fullscreen": + value = GLib.Variant("b", self.Fullscreen) + self.properties_changed({property: value}) + + @GObject.Property + def SupportedUriSchemes(self) -> list[str]: + """URI schemes supported by Emmental.""" + return ["file"] + + @GObject.Property + def SupportedMimeTypes(self) -> list[str]: + """Mime Types supported by Emmental.""" + return ["audio"] + + @GObject.Signal + def Raise(self) -> None: + """Raise the window.""" + + @GObject.Signal + def Quit(self) -> None: + """Quit Emmental.""" diff --git a/emmental/mpris2/dbus.py b/emmental/mpris2/dbus.py new file mode 100644 index 0000000..de14e68 --- /dev/null +++ b/emmental/mpris2/dbus.py @@ -0,0 +1,89 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Our generic dbus object.""" +import pathlib +from gi.repository import GObject +from gi.repository import GLib +from gi.repository import Gio + +OBJECT_PATH = "/org/mpris/MediaPlayer2" + + +class Object(GObject.GObject): + """A generic dbus object.""" + + dbus = GObject.Property(type=Gio.DBusConnection) + nodeinfo = GObject.Property(type=Gio.DBusNodeInfo) + interface = GObject.Property(type=Gio.DBusInterfaceInfo) + registration = GObject.Property(type=int) + + def __init__(self, xml: pathlib.Path = None): + """Initialize a dbus Object.""" + super().__init__() + if xml and xml.is_file(): + with open(xml, 'r') as f: + self.nodeinfo = Gio.DBusNodeInfo.new_for_xml(f.read()) + self.interface = self.nodeinfo.interfaces[0] + self.connect("notify", self.__on_notify) + + def __on_notify(self, object: GObject.GObject, param) -> None: + self.do_notify(param.name) + + def do_notify(self, property: str) -> None: + """Handle a property value changing.""" + + def link_property(self, property: str, object: GObject.GObject, + object_property: str) -> None: + """Link a dbus property to the object.""" + self.bind_property(property, object, object_property, + GObject.BindingFlags.BIDIRECTIONAL) + + def properties_changed(self, changed: dict) -> None: + """Emit the org.freedesktop.DBus.PropertiesChanged signal.""" + if self.dbus is None: + return + + args = GLib.Variant.new_tuple(GLib.Variant("s", self.interface.name), + GLib.Variant("a{sv}", changed), + GLib.Variant("as", [])) + self.dbus.emit_signal(None, OBJECT_PATH, + "org.freedesktop.DBus.Properties", + "PropertiesChanged", args) + + def register(self, dbus: Gio.DBusConnection): + """Register this dbus Object.""" + self.registration = dbus.register_object(OBJECT_PATH, self.interface, + self.__method_call, + self.__get_property, + self.__set_property) + + def unregister(self, dbus: Gio.DBusConnection): + """Unregister this Object from the bus.""" + dbus.unregister_object(self.registration) + self.registration = 0 + + def __method_call(self, dbus: Gio.DBusConnection, sender: str, object: str, + interface: str, method: str, parameters: GLib.Variant, + invocation: Gio.DBusMethodInvocation) -> None: + if object != OBJECT_PATH or interface != self.interface.name: + return None + + self.emit(method, *parameters.unpack()) + invocation.return_value(GLib.Variant.new_tuple()) + + def __get_property(self, dbus: Gio.DBusConnection, sender: str, + object: str, interface: str, + property: str) -> GLib.Variant | None: + if object != OBJECT_PATH or interface != self.interface.name: + return None + + if property_info := self.interface.lookup_property(property): + return GLib.Variant(property_info.signature, + self.get_property(property)) + + def __set_property(self, dbus: Gio.DBusConnection, sender: str, + object: str, interface: str, property: str, + value: GLib.Variant) -> bool: + if object != OBJECT_PATH or interface != self.interface.name: + return None + self.set_property(property, value.unpack()) + return True diff --git a/mpris-spec b/mpris-spec new file mode 160000 index 0000000..51e5848 --- /dev/null +++ b/mpris-spec @@ -0,0 +1 @@ +Subproject commit 51e5848f9f763864568db233bffe98e3cb72bf13 diff --git a/tests/mpris2/test_application.py b/tests/mpris2/test_application.py new file mode 100644 index 0000000..9d1c499 --- /dev/null +++ b/tests/mpris2/test_application.py @@ -0,0 +1,44 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Tests our Mpris Application object.""" +import pathlib +import unittest +import emmental.mpris2.application +from gi.repository import Gio + + +class TestRoot(unittest.TestCase): + """Test the mpris2 application object.""" + + def setUp(self): + """Set up common variables.""" + self.app = emmental.mpris2.application.Application() + + def test_xml_files(self): + """Test that the interface definition files exist.""" + mpris2 = pathlib.Path(emmental.mpris2.__file__).parent + self.assertEqual(emmental.mpris2.application.MPRIS2_XML, + mpris2 / "MediaPlayer2.xml") + self.assertTrue(emmental.mpris2.application.MPRIS2_XML.is_file()) + + def test_init(self): + """Test that the application object is configured correctly.""" + self.assertIsInstance(self.app, emmental.mpris2.dbus.Object) + self.assertIsInstance(self.app.nodeinfo, Gio.DBusNodeInfo) + self.assertIsInstance(self.app.interface, Gio.DBusInterfaceInfo) + + def test_properties(self): + """Test Application properties.""" + self.assertTrue(self.app.CanQuit) + self.assertFalse(self.app.Fullscreen) + self.assertTrue(self.app.CanSetFullscreen) + self.assertTrue(self.app.CanRaise) + self.assertFalse(self.app.HasTrackList) + self.assertEqual(self.app.Identity, "Emmental Music Player") + self.assertEqual(self.app.DesktopEntry, "emmental") + self.assertEqual(self.app.SupportedUriSchemes, ["file"]) + self.assertEqual(self.app.SupportedMimeTypes, ["audio"]) + + def test_signals(self): + """Test that Application signals exist.""" + self.app.emit("Raise") + self.app.emit("Quit") diff --git a/tests/mpris2/test_dbus.py b/tests/mpris2/test_dbus.py new file mode 100644 index 0000000..2224972 --- /dev/null +++ b/tests/mpris2/test_dbus.py @@ -0,0 +1,24 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Test our generic dbus object.""" +import unittest +import emmental.mpris2.dbus +from gi.repository import GObject + + +class TestDbusObject(unittest.TestCase): + """Tests our generic mpris2 object.""" + + def setUp(self): + """Set up common variables.""" + self.object = emmental.mpris2.dbus.Object() + + def test_init(self): + """Test that the object is set up properly.""" + self.assertIsInstance(self.object, GObject.GObject) + self.assertIsNone(self.object.dbus) + self.assertIsNone(self.object.nodeinfo) + self.assertIsNone(self.object.interface) + self.assertEqual(self.object.registration, 0) + + self.assertEqual(emmental.mpris2.dbus.OBJECT_PATH, + "/org/mpris/MediaPlayer2") diff --git a/tests/mpris2/test_mpris2.py b/tests/mpris2/test_mpris2.py new file mode 100644 index 0000000..5d8c63d --- /dev/null +++ b/tests/mpris2/test_mpris2.py @@ -0,0 +1,27 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Test our root mpris2 object.""" +import unittest +import emmental.mpris2 + + +class TestMpris2(unittest.TestCase): + """Test our Mpris2 Connection.""" + + def setUp(self): + """Set up common variables.""" + self.mpris2 = emmental.mpris2.Connection() + + def tearDown(self): + """Clean up.""" + self.mpris2.disconnect() + + def test_init(self): + """Test that the Mpris Object is created properly.""" + self.assertIsInstance(self.mpris2, emmental.mpris2.GObject.GObject) + self.assertIsInstance(self.mpris2.app, + emmental.mpris2.application.Application) + self.assertIsNone(self.mpris2.dbus) + self.assertGreater(self.mpris2._busid, 0) + + self.assertEqual(emmental.mpris2.MPRIS2_ID, + "org.mpris.MediaPlayer2.emmental-debug") diff --git a/tests/test_emmental.py b/tests/test_emmental.py index e5a867e..1bf1eb2 100644 --- a/tests/test_emmental.py +++ b/tests/test_emmental.py @@ -37,16 +37,24 @@ class TestEmmental(unittest.TestCase): mock_load: unittest.mock.Mock): """Test that the startup signal works as expected.""" self.assertIsNone(self.application.db) + self.assertIsNone(self.application.mpris) self.application.emit("startup") self.assertIsInstance(self.application.db, emmental.db.Connection) + self.assertIsInstance(self.application.mpris, + emmental.mpris2.Connection) + mock_startup.assert_called() mock_load.assert_called() def test_shutdown(self): """Test that the shutdown signal works as expected.""" db = self.application.db = emmental.db.Connection() + mpris = self.application.mpris = emmental.mpris2.Connection() self.application.emit("shutdown") self.assertIsNone(self.application.db) + self.assertIsNone(self.application.mpris) + + self.assertIsNone(mpris.dbus) self.assertFalse(db.connected)