mpris2: Add an Mpris2 DBus Connection

And implement the MediaPlayer2 interface on top of it.

Implements: #7 ("Add MPRIS2 Support")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2022-10-20 16:50:17 -04:00
parent b1cd1706ed
commit 767f0c1584
12 changed files with 311 additions and 2 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@
*.patch
PKGBUILD
emmental.gresource*
emmental/mpris2/*.xml

3
.gitmodules vendored
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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."""

89
emmental/mpris2/dbus.py Normal file
View File

@ -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

1
mpris-spec Submodule

@ -0,0 +1 @@
Subproject commit 51e5848f9f763864568db233bffe98e3cb72bf13

View File

@ -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")

24
tests/mpris2/test_dbus.py Normal file
View File

@ -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")

View File

@ -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")

View File

@ -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)