Today we'll cover how to use the dbus interprocess communication (IPC) mechanism to integrate the Clementine music player into i3status.  We'll write a small script in Python that determines Clementine's state using the dbus library, and then appends that state to the i3status bar.  This tutorial is really a corollary to the Linux Environment Setup 0x05: Integrating mpd into i3status using i3bar post we wrote a few months back, and will make use of some of the same code.  Save swapping mpd for Clementine, the final result will be nearly identical:

Clementine Integrated in i3status Final

In addition to the final result however, in this article we'll go into some depth concerning dbus, and explore how to interact with it from both the command line and from Python.

To start, let's introduce our music player of the day, and dbus test candidate, Clementine.

Clementine:

Clementine is a feature-rich music player and organizer available for most platforms, including Linux.  It integrates with a host of sources, both networked and local, and includes one of the most effective track-information fetching systems I've encountered.  Out of the box, it also supports a couple of different notification systems.

We can install Clementine in the usual way:

$ sudo apt update
$ sudo apt install clementine

Now we're ready to programatically get some information out of it!

D-Bus:

D-Bus, often referred to simply as 'dbus' is:

".. a message bus system, a simple way for applications to talk to one another. In addition to interprocess communication, D-Bus helps coordinate process lifecycle; it makes it simple and reliable to code a "single instance" application or daemon, and to launch applications and daemons on demand when their services are needed." [Freedesktop's dbus page]

Simply put, dbus is a software bus which implements a sturdy specification for IPC.  To view a list of running applications that implement the spec to some degree, and are registered with the per-user 'session' bus, we can do the following:

$ dbus-send --session --dest=org.freedesktop.DBus --type=method_call --print-reply /org/freedesktop/DBus org.freedesktop.DBus.ListNames
method return time=1583003870.789483 sender=org.freedesktop.DBus -> destination=:1.473 serial=3 reply_serial=2
   array [
      string "org.freedesktop.DBus"
      string "org.freedesktop.PowerManagement"
      string ":1.326"
      string "org.freedesktop.Notifications"
      string "org.freedesktop.network-manager-applet"
      ...
      string "org.mpris.MediaPlayer2.clementine"
      ...
]

Here, we've used the dbus-send utility to send a message on the bus:

  • --session selects the 'session' bus, as opposed to the 'system' bus (largely hardware-event oriented)
  • --dest=org.freedesktop.DBus specifies the bus connection which should receive the message
  • --type=method_call specifies the type of message - in this case we're requesting the destination run a method
  • --print-reply tells dbus-send to block for a response, and print it on stdout.
    • Accoring to the docs, this implies --type=method_call and so its presence in the above command is redundant.  However as we'll see, this isn't entirely the case.
  • /org/freedesktop/DBus is the 'object path' at the destination, which essentially contains a specific collection of interfaces (often per a standard)
  • org.freedesktop.DBus.ListNames is the interface name, appended with the method name
    • ie. Interface : "org.freedesktop.DBus", Method : "ListNames"

By just sending messages from the command line, it's a little tedious to enumerate a dbus connection's objects (though some shell's autocomplete might help a lot).  An example of enumerating Clementine's objects via the command line might proceed as follows:

$ dbus-send --session --print-reply --dest=org.mpris.MediaPlayer2.clementine / org.freedesktop.DBus.Introspectable.Introspect
<node>
  <interface name="org.freedesktop.DBus.Introspectable">
    ...
  </interface>
  <node name="org"/>
</node>
$ dbus-send --session --print-reply --dest=org.mpris.MediaPlayer2.clementine /org org.freedesktop.DBus.Introspectable.Introspect
<node>
  <interface name="org.freedesktop.DBus.Introspectable">
    ...
  </interface>
  <node name="mpris"/>
</node>
$ dbus-send --session --print-reply --dest=org.mpris.MediaPlayer2.clementine /org/mpris org.freedesktop.DBus.Introspectable.Introspect
<node>
  <interface name="org.freedesktop.DBus.Introspectable">
    ...
  </interface>
  <node name="MediaPlayer2"/>
</node>
$ dbus-send --session --print-reply --dest=org.mpris.MediaPlayer2.clementine /org/mpris/MediaPlayer2 org.freedesktop.DBus.Introspectable.Introspect
<node>
  <interface name="org.mpris.MediaPlayer2">
    <method name="Raise"/>
    <property access="read" type="b" name="CanQuit"/>
    ...
  </interface>
  <interface name="org.mpris.MediaPlayer2.Player">
    <method name="Next"/>
    <method name="Previous"/>
    ...
  </interface>
  <interface name="org.mpris.MediaPlayer2.Playlists">
    <property access="read" type="u" name="PlaylistCount"/>
    ...
  </interface>
  <interface name="org.mpris.MediaPlayer2.TrackList">
    <method name="GetTracksMetadata">
      <arg direction="in" type="ao" name="TrackIds"/>
      <arg direction="out" type="aa{sv}" name="Metadata"/>
      <annotation value="TrackMetadata" name="org.qtproject.QtDBus.QtTypeName.Out0"/>
    </method>
    ...
  </interface>
  <interface name="org.freedesktop.DBus.Properties">
    ...
  </interface>
  ...
</node>

While the first three objects aren't too exciting, the last finally exposed a whole host of interesting methods and properties.

Before moving on to actually interacting with that interface I'd like to mention D-Feet, which is a simple, graphical dbus explorer.  Here's how to install it:

$ sudo apt install d-feet

And here's how exploring Clementine's dbus connection looks in it:

D-Feet on Linux showing Clementine Connection

Getting Clementine Information via dbus:

Having discovered the MediaPlayer2 object and its 'Player' interface, let's begin by trying to call its PlayPause method:

$ dbus-send --session --type=method_call --dest=org.mpris.MediaPlayer2.clementine /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player.PlayPause

And the music stops (or starts)!  That was pretty easy.

Now let's try retrieving the title of a song.  Exploring the API, there's no obvious method like GetTitle, or GetTrackInfo.  However there is a GetAll under the org.freedesktop.DBus.Properties interface Clementine implements.  From our earlier exploration, we know a little bit about the method:

<method name="GetAll">
  <arg name="interface_name" type="s" direction="in"/>
  <arg name="values" type="a{sv}" direction="out"/>
  <annotation name="org.qtproject.QtDBus.QtTypeName.Out0" value="QVariantMap"/>
</method>

Looks like GetAll expects to be passed an interface name as a string, and will return an array.  Let's try passing it the name of the Player interface:

$ dbus-send --session --type=method_call --print-reply --dest=org.mpris.MediaPlayer2.clementine /org/mpris/MediaPlayer2 org.freedesktop.DBus.Properties.GetAll string:"org.mpris.MediaPlayer2.Player"
method return time=1583007135.073517 sender=:1.492 -> destination=:1.559 serial=2072 reply_serial=2
   array [
      dict entry(
         string "CanControl"
         variant             boolean true
      )
      ...
      dict entry(
         string "Metadata"
         variant             array [
               ...
               dict entry(
                  string "xesam:album"
                  variant                      string "Room On Fire"
               )
               dict entry(
                  string "xesam:artist"
                  variant                      array [
                        string "The Strokes"
                     ]
               )
               ...
               dict entry(
                  string "xesam:title"
                  variant                      string "Meet Me In The Bathroom"
               )
               ...
            ]
      )
     ...
      dict entry(
         string "PlaybackStatus"
         variant             string "Playing"
      )
      ...
   ]

Okay!  Seems that if we're after the title of the track playing, we're going to want to access that Metadata array, and then access "xesam:title".  To get a single property, let's try out that Get method:

<method name="Get">
  <arg name="interface_name" type="s" direction="in"/>
  <arg name="property_name" type="s" direction="in"/>
  <arg name="value" type="v" direction="out"/>
</method>

In addition to the interface name we passed to GetAll, we'll also need to pass a property name:

$ dbus-send --session --type=method_call --print-reply --dest=org.mpris.MediaPlayer2.clementine /org/mpris/MediaPlayer2 org.freedesktop.DBus.Properties.Get string:"org.mpris.MediaPlayer2.Player" string:"Metadata"
method return time=1583007726.393493 sender=:1.492 -> destination=:1.560 serial=2685 reply_serial=2
   variant       array [
         ...
         dict entry(
            string "xesam:album"
            variant                string "Room On Fire"
         )
         dict entry(
            string "xesam:artist"
            variant                array [
                  string "The Strokes"
               ]
         )
         ...
         dict entry(
            string "xesam:title"
            variant                string "Under Control"
         )
        ...
      ]

Looks like we've got a handle on the interface!  However that output is not exactly user friendly, and though with shell utilities we could surely extract the strings we're after, it would be much easier if we didn't have to create some brittle, regex-based system.  Enter Python!

dbus via Python:

We know what we're after, and we already understand the way dbus connections are structured, so all that's left is to re implement what we did above in Python.  First, let's list all names on the session bus:

>>> import dbus
>>> bus = dbus.SessionBus()
>>> for name in bus.list_names():
...     print name
... 
org.freedesktop.DBus
:1.568
org.freedesktop.PowerManagement
:1.326
org.freedesktop.Notifications
org.freedesktop.network-manager-applet
...
org.mpris.MediaPlayer2.clementine
...
>>> 

Okay!  Since we've already discovered Clementine's objects and interfaces, let's skip to trying to call a method.  Let's see if we can call PlayPause:

## Get the clementine object by name:
>>> clementine = bus.get_object('org.mpris.MediaPlayer2.clementine', '/org/mpris/MediaPlayer2')
## Get the 'Player' interface:
>>> clemPlayer = dbus.Interface(clementine, dbus_interface='org.mpris.MediaPlayer2.Player')
>>> clemPlayer.PlayPause()
## Music starts (or stops)!
>>>

Now let's try extracting some information:

## Get the clementine object by name:
>>> clementine = bus.get_object('org.mpris.MediaPlayer2.clementine', '/org/mpris/MediaPlayer2')
>>> clemPlayer = dbus.Interface(clementine, dbus_interface='org.mpris.MediaPlayer2.Player')
## Get the Properties interface by name:
>>> clemProps = dbus.Interface(clemPlayer, dbus_interface='org.freedesktop.DBus.Properties')
>>> type(clemProps.GetAll('org.mpris.MediaPlayer2.Player'))
<type 'dbus.Dictionary'>
>>> for key in clemProps.GetAll('org.mpris.MediaPlayer2.Player').keys():
...     print key
... 
CanGoNext
CanPause
Shuffle
CanControl
LoopStatus
PlaybackStatus
Volume
MinimumRate
Rate
CanPlay
Position
CanSeek
CanGoPrevious
Metadata
MaximumRate
>>>

Lo and behold, the data is in a dictionary!  Just like before, let's get the Metadata collection:

>>> for i in clemProps.Get('org.mpris.MediaPlayer2.Player', 'Metadata'):
...     print i
... 
xesam:album
xesam:useCount
xesam:title
...
xesam:albumArtist
xesam:url
>>>

Now let's get the title and artist, as that's the information we're really after:

>>> str(clemProps.Get('org.mpris.MediaPlayer2.Player', 'Metadata')['xesam:title'])
'Hard to Explain'
## Artist is actually the only member in an array:
>>> str(clemProps.Get('org.mpris.MediaPlayer2.Player', 'Metadata')['xesam:artist'][0])
'The Strokes'
>>>

One last little bit that would be useful to have is the player state, i.e. playing, paused, etc:

>>> str(clemProps.Get('org.mpris.MediaPlayer2.Player', 'PlaybackStatus'))
'Playing'
>>>

Okay!  We've now collected all the necessary information to achieve the original goal of integrating Clementine's status into i3status.

Integration into i3status:

The following is boilerplate for how to modify the i3status stream and inject arbitrary information.  It comes from here.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

# This script is a simple wrapper which prefixes each i3status line with custom
# information. It is a python reimplementation of:
# http://code.stapelberg.de/git/i3status/tree/contrib/wrapper.pl
#
# To use it, ensure your ~/.i3status.conf contains this line:
#     output_format = "i3bar"
# in the 'general' section.
# Then, in your ~/.i3/config, use:
#     status_command i3status | ~/i3status/contrib/wrapper.py
# In the 'bar' section.
#
# In its current version it will display the cpu frequency governor, but you
# are free to change it to display whatever you like, see the comment in the
# source code below.
#
# © 2012 Valentin Haenel <valentin.haenel@gmx.de>
#
# This program is free software. It comes without any warranty, to the extent
# permitted by applicable law. You can redistribute it and/or modify it under
# the terms of the Do What The Fuck You Want To Public License (WTFPL), Version
# 2, as published by Sam Hocevar. See http://sam.zoy.org/wtfpl/COPYING for more
# details.

import sys
import json

def get_governor():
    """ Get the current governor for cpu0, assuming all CPUs use the same. """
    with open('/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor') as fp:
        return fp.readlines()[0].strip()

def print_line(message):
    """ Non-buffered printing to stdout. """
    sys.stdout.write(message + '\n')
    sys.stdout.flush()

def read_line():
    """ Interrupted respecting reader for stdin. """
    # try reading a line, removing any extra whitespace
    try:
        line = sys.stdin.readline().strip()
        # i3status sends EOF, or an empty line
        if not line:
            sys.exit(3)
        return line
    # exit on ctrl-c
    except KeyboardInterrupt:
        sys.exit()

if __name__ == '__main__':
    # Skip the first line which contains the version header.
    print_line(read_line())

    # The second line contains the start of the infinite array.
    print_line(read_line())

    while True:
        line, prefix = read_line(), ''
        # ignore comma at start of lines
        if line.startswith(','):
            line, prefix = line[1:], ','

        j = json.loads(line)
        # insert information into the start of the json, but could be anywhere
        # CHANGE THIS LINE TO INSERT SOMETHING ELSE
        j.insert(0, {'full_text' : '%s' % get_governor(), 'name' : 'gov'})
        # and echo back new encoded json
        print_line(prefix+json.dumps(j))

If we wrap our previously created Python in some trys and factor them a bit, we might end up with the following two functions:

def setup_clementine_dbus_interface():
    # Setup dbus infos:
    bus = dbus.SessionBus()
    
    try:
        clem = bus.get_object('org.mpris.MediaPlayer2.clementine', '/org/mpris/MediaPlayer2')
        clemPlayer = dbus.Interface(clem, dbus_interface='org.mpris.MediaPlayer2.Player')
        clemProps = dbus.Interface(clemPlayer, dbus_interface='org.freedesktop.DBus.Properties')
        return clem, clemPlayer, clemProps
    except:
        # print "Failed to get player and interface!"
        # print sys.exc_info()[0]
        return None, None, None
    

def get_clementine_info():
    color = "#FFFFFF"
    if (clemProps is None):
        # Will be nones if clementine is down
        return color, str("Clementine Down!")
    else:
        try:
            title = str(clemProps.Get('org.mpris.MediaPlayer2.Player', 'Metadata')['xesam:title'])
            artist = str(clemProps.Get('org.mpris.MediaPlayer2.Player', 'Metadata')['xesam:artist'][0])
            status = str(clemProps.Get('org.mpris.MediaPlayer2.Player', 'PlaybackStatus'))
            if status == "Playing":
                color = "#00FF00"
            return color, str(artist) + " - " + str(title)
        except:
            # accessing metadata arrays fail if clementine up, but nothing playing:
            return color, "Nothing Playing"

To use them, modify main in the script as follows:

if __name__ == '__main__':
    # Skip the first line which contains the version header.
    print_line(read_line())

    # The second line contains the start of the infinite array.
    print_line(read_line())
    
    # Try to get dbus proxies, or set Nones:
    clem, clemPlayer, clemProps = setup_clementine_dbus_interface()

    while True:
        line, prefix = read_line(), ''
        # ignore comma at start of lines
        if line.startswith(','):
            line, prefix = line[1:], ','
    

        j = json.loads(line)
        # insert information into the start of the json, but could be anywhere
        # CHANGE THIS LINE TO INSERT SOMETHING ELSE
        # j.insert(0, {'full_text' : '%s' % get_governor(), 'name' : 'gov'})
        
        # Get player info, or status messages
        color, s = get_clementine_info()

        # If s indicates no clementine dbus proxies, try to get them:
        if (s == "Clementine Down!"):
            clem, clemPlayer, clemProps = setup_clementine_dbus_interface()
        
        j.insert(0, {'full_text' : '%s' % s, 'color' : '%s' % color, 'name' : 'gov'})
        # and echo back new encoded json
        print_line(prefix+json.dumps(j))

Lastly, don't forget to import dbus at the top of the file(!):

import dbus

Voila!

Finishing Touches:

As before, to finish implementing the project we need to save the completed script (mine is in ~/Scripts/), make it executable, and then modify our i3 config's 'bar' block to pipe the output of our script into i3status.  The complete script can be found here.

Make it executable:

$ chmod +x ~/Scripts/i3bar/wrapper.py

Next, modify i3's configuration file as suggested in Valentin's script.  Change the bar { ... } block so that i3status is piped into our wrapper:

bar {
    status_command i3status | ~/Scripts/i3bar/wrapper.py
    ...
}

Next, modify i3status' config file.  Add the following line to the general { ... } block at the top of the file:

output_format = "i3bar"

Okay, we're all set!  To see the effects of our changes, hit <shift> + <mod> + <r> to reload your i3 config.

References:

  • A great dbus-python tutorial is available here - hosted by freedesktop.org
# Reads: 2242