foobard: MPRIS support for foobar2000

2023-10-24

I've been using foobar2000 as my local audio player for several years, and I started using it on my Linux computer last year via Wine. I haven't found a native Linux player that can match its UI's simplicity and conciseness, and unfortunately the hard death of old habits keeps me using closed-source software.

However, I ran into a problem when I began using it on Linux: as a Windows program, foobar2000 does not support MPRIS, a D-Bus interface providing a unified way to interact with media players. This meant that my keyboard's media control keys (play/pause, next, previous) didn't work with foobar2000, and I had to alt-tab into it in order to do those.

This situation is unacceptable to me.

In February I decided to try my hand at adding MPRIS support to foobar2000. My first realisation was that interfacing with D-Bus from within Wine would be very difficult, if not impossible. Nearly every program that uses D-Bus seems to use a library for it, and obviously none exist for Windows. My second was that foobar2000 has support for "components" (think browser extensions) via an SDK. I decided to split my theoretical program into two:

I got to work, starting with the socket interface. Windows received support for Unix sockets in 2017, but I wanted to make sure their version didn't have any thorns I needed to know about beforehand (aside from the lack of abstract addresses), so I started writing a series of sanity checks.

#include <WinSock2.h>
#include <afunix.h>
#include <cstdio>

int main() {
    struct WSAData wsaData;
    (void)WSAStartup(MAKEWORD(2, 2), &wsaData);

    SOCKET sock = socket(AF_UNIX, SOCK_STREAM, 0);
    printf("%d\n", sock);
    if (sock == INVALID_SOCKET)
        printf("%d\n", WSAGetLastError());
    return 0;
}

One would expect this to print some positive integer, a file descriptor for our new socket.

-1
10047

...weird. Looking online, 10047 is the Winsock error code corresponding to... WSAEAFNOSUPPORT?

What?

I mean, it's possible Wine never received an implementation. A terrifying prospect, but certainly possible. After booting up my Windows VM again:

210

*sigh*

I was hoping to avoid another one of the yak shaves that have plagued me since I first started programming, but unfortunately I was stubborn and had too much free time.

Here's the merge request.

With that out of the way, six months later we can finally begin work on foobard itself. I decided to start with the Linux side first because D-Bus was entirely foreign to me, and I wanted to get familiar with it before structuring the rest of my code. I also decided to go with sd-bus for my D-Bus library rather than GDBus, mainly because I absolutely loathe and despise the GObject programming style. (Like seriously just use C++ if you want OOP that bad holy-)

I had the basic skeleton written quickly, but I was encountering an annoying problem. If I composed a D-Bus call manually, it worked fine, and my program received it:

$ busctl --user call org.mpris.MediaPlayer2.foobar2000 /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player PlayPause
foobar2000_player_PlayPause called

But if I tried to use playerctl, it complained:

$ playerctl play-pause
No player could handle this command

OK, what prints this error? Looking through playerctl's source, it seems to want to stat the interface of the player it selects before proceeding with the actual call... Actually, you know what, I'll spare you the long journey I made across multiple codebases to change a single character in my program. Here was the highlight:

$ gdbus call --session --dest org.mpris.MediaPlayer2.foobar2000 --object-path /org/mpris/MediaPlayer2 --method org.freedesktop.DBus.Properties.Get org.mpris.MediaPlayer2.Player Position
Error: GDBus.Error:System.Error.ENXIO: No such device or address

I eventually narrowed the cause of playerctl's failure to stat down to this monstrosity. How the fuck is ENXIO a reasonable error for this to throw? Well:

/* foobar2000_Position(), "returns" an int64_t */
    return sd_bus_message_append_basic(reply, 'd', &position);
}

See the problem? The type argument is actually supposed to be 'x', not 'd', and apparently this is considered "undefined" enough to throw ENXIO and cost me nearly two hours. Whatever. I'm not mad.

After fixing that, I moved on to the interface. I didn't want to just be passing raw strings as commands, because I knew I'd need a more structured method of sending data that could account for arbitrary-content strings and numeric types to send metadata. That said, I decided to start out with a very basic interface that did just send raw strings, so I could get the skeleton of foo_mpris laid out.

There are some interesting things to note with regards to foobar2000 components:

All of this I had to figure out myself, because there is no documentation whatsoever for this SDK, excepting some unhelpful doc-comments.

After getting play, pause, stop, next, and previous working, I decided on a protocol: UBJSON. It's simple and easily-implemented, and I wanted to implement what I could on my own[a]. I had a parser rather quickly, and a renderer soon after. After integrating it into both foobard and foo_mpris, I added support for commands requiring data, such as seeking to a position or by some offset, which went smoothly.

As an aside, I was very surprised by how fast my first-pass implementation was without going through again to improve performance. On my i5-1240P Linux system, it parses at 430MB/s and renders at 1.3GB/s.

And it works! Here I'm demoing it on my Windows machine in WSL2 because I think it's funny, but it does of course work in non-hypervisored Linux.

The source code for both programs is available at https://git.sr.ht/~dropbear/foobard. Enjoy!

a. ^ I didn't write a D-Bus library because that is, by all accounts, a very painful and lengthy process, and while I do generally like to write things myself, I don't feel the need to first invent the universe.