Simple netcore F# app to control Spotify from the terminal on Linux

Aug 16, 2020 min read

I am addicted to Spotify, I have Linux, I L💖ve F# so I decided to give it a try to control Spotify on Linux using Terminal. Join the ride! We will add a nice feature that will allow us to download lyrics of the current song and we will meet D-bus and Argu - F# library which makes building CLI apps fun! I will also show you how to publish and use the app in Linux.

Introduction

Lately, I got inspired by the python program spotify-cli-linux written by my friend. I decided to write the port of this app in .net core in F#. If you can’t way to see the source code here it is.

D-bus

D-Bus is a message bus system, which allows inter-applications communication. Also, 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. Linux desktop environments take advantage of the D-Bus facilities by instantiating not one bus but many:

  • A single system bus, available to all users and processes of the system, that provides access to system services (i.e. services provided by the operating system and also by any system daemons).
  • A session bus for each user login session, that provides desktop services to user applications in the same desktop session, and allows the integration of the desktop session as a whole. Spotify belongs here.

The purpose of D-Bus should be clear - simplify:

You can read more about D-Bus on freedesktop.org [1]. We will focus on Spotify but The list of Desktop apps using D-Bus is long and may give you some inspiration [2]. Before we start to code let me introduce you D-Feet which is a nice GUI app which allows you to control and explore D-Bus:

It may help you to get to know the D-Bus interface of the application you want to integrate with. You can even send some signals and test the application behavior without writing any code.

Connecting to D-Bus with F# and interacting with Spotify.

How to connect to D-Bus using .NET? I googled a little bit and found Tmds.Dbus package by Tom Deseyn [3] which seems to be the easiest way (and the moment probably the only way if you don’t want to struggle with sockets, buffers, streams, etc). The samples are in C# but I did not see any obstacles to write the code in F# and hide the package object-oriented nature behind more functional friendly mechanisms.

According to documentation to model a D-Bus interface using Tmds.DBus we create a .NET interface with the DBusInterface attribute and inherit IDBusObject. We can do this in F# easily. Next, a D-Bus method is modeled by a method in the .NET interface. The method must to return Task for methods without a return value and Task for methods with a return value. Following async naming conventions, we add Async to the method name. In case a method has multiple out-arguments, these must be combined in a struct/class as public fields or a C# 7 tuple. The input arguments of the D-Bus method are the method parameters.

I don’t like some ideas in the library but it’s made in C# for C# devs for sure so let’s be happy that someone did the hard work for us [4]. Let me show you the code which creates the D-Bus connection and simple API which will allow us to use the module in a more F# friendly way.

 1namespace Spotify.Dbus
 2
 3open System
 4open System.Collections.Generic
 5open System.Threading.Tasks
 6open Tmds.DBus
 7
 8module SpotifyBus =
 9    type Signal = Play | Stop | Pause | NextSong | PreviousSong | PlayPause
10    type PlaybackStatus = Playing | Paused | Stopped
11    type Song = {
12        Title : string
13        Artists: string[]
14        Album: string
15        Url: Uri
16    }
17    
18    [<DBusInterface("org.mpris.MediaPlayer2.Player")>]
19    type IPlayer =
20        inherit IDBusObject 
21        abstract member NextAsync : unit -> Task
22        abstract member PreviousAsync : unit -> Task
23        abstract member PauseAsync : unit -> Task
24        abstract member PlayAsync : unit -> Task
25        abstract member StopAsync : unit -> Task
26        abstract member PlayPauseAsync : unit -> Task
27        abstract member GetAsync<'T> : string -> Task<'T>
28    let private player =
29        Connection.Session.CreateProxy<IPlayer>("org.mpris.MediaPlayer2.spotify",
30                                                             ObjectPath("/org/mpris/MediaPlayer2"))   

This should be simple enough;

  1. Signal is a union type that can be used to manipulate the player.
  2. PlaybackSatatys is a union type that will represent player playback status.
  3. Song is a record that will hold song data retrieved from the player.
  4. IPlayer is an interface that inherits the IDBusObject interface according to documentation. It has to be public, otherwise Tmds.Dbus will fail to do anything (including internal access modifier).
  5. IPlayer methods represent D-Bus operations - signals and method for data retrieval GetAsync<'T>.
  6. Finally, we create the proxy - player instance which will be used for actual operations. Let’s keep it private in the module - C# Tasks are not natural to F# and let’s hide data retrieval behind clean API. To Create the proxy we have to pass service name and object path. The low-level D-Bus protocol, and corresponding libdbus API provides this concept. The idea of an object path is that higher-level bindings can name native object instances, and allow remote applications to refer to them.

Time to retrieve the song and playback status:

 1let retrieveCurrentSong =
 2    async {
 3        let! metadata = player.GetAsync<IDictionary<string, Object>> "Metadata" |> Async.AwaitTask
 4        return {
 5            Title = string metadata.["xesam:title"]
 6            Artists = metadata.["xesam:artist"] :?> string[]
 7            Album = string metadata.["xesam:album"]
 8            Url = Uri(string metadata.["xesam:url"])
 9        }
10    }
11
12let getStatus =
13    async {
14        let! status = (player.GetAsync<string>("PlaybackStatus") |> Async.AwaitTask)
15        return match status with
16                    | "Playing" -> Playing
17                    | "Paused" -> Paused
18                    | _ -> Stopped
19    }

If you wonder how I knew about the Dictionary keys - well I simply used the Mentions D-Feet app to examine the contents. The test below demonstrates module usage:

 1open System
 2open Spotify.Dbus
 3open Xunit
 4open SpotifyBus
 5open FsUnit
 6
 7[<Fact>]
 8[<Trait("Category","SpotifyIntegration")>]
 9let ``GIVEN retrieveCurrentSong WHEN Song is selected in Spotify THEN the title, artist, url, album are retrieved`` () =
10    // Act
11    let song = retrieveCurrentSong |> Async.RunSynchronously
12    // Assert
13    song.Title |> should not' (be EmptyString)
14    song.Artists |> Seq.head |> should not' (be EmptyString)
15    song.Album |> should not' (be EmptyString)
16    string song.Url |> should not' (be EmptyString)
17    sprintf "%A" song |> Console.WriteLine

Manipulating the player

The hard work is done (actually it wasn’t that was it?) Let’s implement play/pause/next/previous, etc signals:

1let send signal =
2    match signal with
3    | Play -> player.PlayAsync()
4    | Stop -> player.StopAsync()
5    | Pause -> player.PauseAsync()
6    | PlayPause -> player.PlayPauseAsync()
7    | PreviousSong -> player.PreviousAsync()
8    | NextSong -> player.NextAsync()
9    |> Async.AwaitTask

Oh yeas, pattern matching |> I Love. That’s it! The tests;

 1(* 200ms seems to work well. This interval is required to make the tests pass because it takes some time to accept the
 2D-Bus message and perform actual actions by Spotify. Remember to turn on Spotify ;) *)
 3
 4[<Fact>]
 5[<Trait("Category","SpotifyIntegration")>]
 6let ``GIVEN send NextSong WHEN Song is changed THEN it is different then previous song`` () =
 7    // Arrange
 8    let songBeforeNext = retrieveCurrentSong |> Async.RunSynchronously
 9    // Act
10    NextSong |> send |> Async.RunSynchronously
11    // Assert
12    Async.Sleep 200 |> Async.RunSynchronously
13    let actualSong = retrieveCurrentSong |> Async.RunSynchronously
14    songBeforeNext |> should not' (equal actualSong)
15
16[<Fact>]
17[<Trait("Category","SpotifyIntegration")>]
18let ``GIVEN send Play WHEN Song is Paused THEN the resulting status is Playing`` () =
19    // Arrange
20    Pause |> send |> Async.RunSynchronously
21    // Act
22    Play |> send |> Async.RunSynchronously
23    // Assert
24    Async.Sleep 200 |> Async.RunSynchronously
25    getStatus |> Async.RunSynchronously |> should equal Playing
26
27[<Fact>]
28[<Trait("Category","SpotifyIntegration")>]
29let ``GIVEN send Pause WHEN Song is Playing THEN the resulting status is Paused`` () =
30    // Arrange
31    Play |> send |> Async.RunSynchronously
32    // Act
33    Pause |> send |> Async.RunSynchronously
34    // Assert
35    Async.Sleep 200 |> Async.RunSynchronously
36    getStatus |> Async.RunSynchronously |> should equal Paused

This works like a charm. I have the tests skipped in Github’s actions for the obvious reason - Spotify is not installed on GitHub agents.

Downloading lyrics

There was a time that Spotify offered this feature but it was removed for unknown reasons. I miss it so let’s add this feature to our CLI app. I’ve created a separate project for this so I can change the API easily without touching D-Bus. I’ve found a simple and free API named Canarado that allows us to search for lyrics by song name. Let’s do this and filter out the matching artist. If our filtering will cause an empty result let’s return the original set of lyrics. I’ve started with learning tests [4] that can be found in the repository if you are interested. The code is simple;

 1namespace Lyrics.CanaradoApi
 2
 3open System
 4open System.Text
 5open FSharp.Data
 6open FSharp.Json
 7
 8module CanaradoApi =
 9    type Status =
10        { Code: int
11          Message: string
12          Failed: bool }
13    type Lyric =
14        { Title: string
15          Lyrics: string
16          Artist: string }
17    type CanadaroSuccessResponse =
18        { Content: Lyric list
19          Status: Status }
20    type CanadaroErrorResponse =
21        { Status: Status }
22    type String with
23        member x.Equivalent(other) = String.Equals(x, other, System.StringComparison.CurrentCultureIgnoreCase)
24
25    let fetchByTitle title =
26        let config = JsonConfig.create (jsonFieldNaming = Json.lowerCamelCase)
27        let response = Http.Request(sprintf "https://api.canarado.xyz/lyrics/%s" title, silentHttpErrors = true)
28        let responseText =
29            match response.Body with
30            | Text jsonText -> jsonText
31            | Binary binary -> Encoding.UTF8.GetString binary
32        match response.StatusCode with
33        | 200 -> Some((Json.deserializeEx<CanadaroSuccessResponse> config responseText).Content)
34        | _ -> None
35
36    let private applyArtistFilter artist lyrics =
37        let filteredLyrics = lyrics |> List.filter (fun lyric -> lyric.Artist.Equivalent artist)
38        match filteredLyrics with
39        | [] -> Some lyrics
40        | _ -> Some filteredLyrics
41
42    let fetch title (artist: string) =
43        (fetchByTitle title) |> Option.bind (applyArtistFilter artist)

Few comments here;

  1. First I have declared a few types to return the responses in a clear and readable way.
  2. type String with member x... is an extension method which will help to compare strings in current culture case insensitive way.
  3. fetchByTitle does the actual work with Canarado API. First we grab the response body to responseText field and in case of success we deserialize the response to our CanadaroSuccessResponse Type. The function returns the lyrics list and is public so the client can decide to retrieve lyrics by title only.
  4. let fetch title (artist: string) filters the lyrics by the artist.

The test:

 1module Tests
 2
 3open Lyrics.CanaradoApi
 4open Xunit
 5open FsUnit
 6
 7[<Fact>]
 8let ``GIVEN title and artist WHEN fetchLyrics matches lyrics THEN list of matching lyrics is returned`` () =
 9    let (artist, title) = ("Rammstein", "Ohne Dich")
10    let lyricsResult = CanaradoApi.fetch title artist
11    let ``Ohne dich by Rammstein`` = lyricsResult.Value |> List.head
12    ``Ohne dich by Rammstein``.Artist |> should equal artist
13    ``Ohne dich by Rammstein``.Title |> should contain title
14    ``Ohne dich by Rammstein``.Lyrics.Length |> should be (greaterThan 100)

That was simple, wasn’t it?

Canarado stopped to return lyrics.

At the time of writing the blog post something happened to Canarado and it stopped to return the lyrics (its empty string now). I’ve created the GitHub issue here: https://github.com/canarado/node-lyrics/issues/1. If the situation won’t change in a week or two I will write an update to the blog post with chapter 3.2 with an alternative.

Creating the CLI with Argu

Let’s do a quick recap:

  1. We have an adapter to communicate with Spotify.
  2. We have an adapter to retrieve lyrics.

Let’s host these functionalities now by command-line app. To help with arguments parsing I was looking at two libraries:

  1. System.CommandLine [6] - This is my default when I do C# CLI.
  2. Argu [5]- something new writing in F# for F# CLI.

I decided to give it a try with Argu. I have started with arguments specification;

 1module Spotify.Dbus.Arguments
 2
 3open Argu
 4
 5type Arguments =
 6    | [<First>] Play
 7    | [<First>] Pause
 8    | [<First>] Prev
 9    | [<First>] Next
10    | [<First>] Status
11    | [<First>] Lyrics
12
13    interface IArgParserTemplate with
14        member arg.Usage =
15            match arg with
16            | Play -> "Spotify play"
17            | Pause -> "Spotify pause"
18            | Prev -> "Previous song"
19            | Next -> "Next song"
20            | Status -> "Shows song name and artist"
21            | Lyrics -> "Prints the song lyrics"

I am not sure if I’ve modeled the requirement that you can specify only one argument when running the app - If you know Argu better than me let me know in the comments I will be happy to change this. I couldn’t find a better way in docs or examined examples. All in all the [<First>] attribute means that the argument has to be in the first place - in another case Argu will return an error during command argument parsing. The interface with the Usage member helps to generate usage instructions:

 1spot --help
 2USAGE: dotnet [--help] [--play] [--pause] [--prev] [--next] [--status] [--lyrics]
 3
 4OPTIONS:
 5
 6    --play                Spotify play
 7    --pause               Spotify pause
 8    --prev                Previous song
 9    --next                Next song
10    --status              Shows song name and artist
11    --lyrics              Prints the song lyrics
12    --help                display this list of options.

Finally, let’s look into Program.cs:

 1open System
 2open Argu
 3open Lyrics.CanaradoApi
 4open Spotify.Dbus
 5open Arguments
 6
 7let formatLyric (lyric: CanaradoApi.Lyric) =
 8    sprintf "%s - %s %s%s %s" lyric.Artist lyric.Title Environment.NewLine lyric.Lyrics Environment.NewLine
 9
10let retrieveLyrics title artist =
11    let lyrics = CanaradoApi.fetch title artist
12    match lyrics with
13    | Some lyrics -> ("", lyrics) ||> List.fold (fun state lyric -> state + formatLyric lyric)
14    | None -> "Lyrics were not found :("
15
16let errorHandler = ProcessExiter (colorizer = function | ErrorCode.HelpText -> None | _ -> Some ConsoleColor.Red)
17
18let execute command =
19    async {
20        match command with
21        | Play ->
22            do! SpotifyBus.Play |> SpotifyBus.send
23            return None
24        | Pause ->
25            do! SpotifyBus.Pause |> SpotifyBus.send
26            return None
27        | Next ->
28            do! SpotifyBus.NextSong |> SpotifyBus.send
29            return None
30        | Prev ->
31            do! SpotifyBus.PreviousSong |> SpotifyBus.send
32            return None
33        | Status ->
34            let! status = SpotifyBus.retrieveCurrentSong
35            return Some(sprintf "%s - %s" (status.Artists |> String.concat " feat ") status.Title)
36        | Lyrics ->
37            let! status = SpotifyBus.retrieveCurrentSong
38            return Some(retrieveLyrics status.Title status.Artists.[0])
39    }
40
41[<EntryPoint>]
42let main argv =
43    let parser = ArgumentParser.Create<Arguments>(errorHandler = errorHandler)
44    let command = (parser.Parse argv).GetAllResults() |> List.tryHead
45    match command with
46    | Some command -> try 
47                        match execute command |> Async.RunSynchronously with
48                        | Some text -> printfn "%s" text
49                        | None -> ()
50                      with | ex -> printfn "Couldn't connect to Spotify, is it running?"
51    | None -> printfn "%s" <| parser.PrintUsage()
52    0

Some comments to the code:

  1. formatLyric and retrieveLyrics are helper-functions to format a list of lyrics into a string that can be printed to the screen in user-friendly form.
  2. errorHandler is Argu function which executes when parsing error occurs. The function keyword is called “pattern matching function” and is equivalent to:
1let errorHandler2 = ProcessExiter (fun(code) -> match code with | ErrorCode.HelpText -> None | _ -> Some ConsoleColor.Red)
  1. execute = command -> Async<string option> is a function which takes the parsed by Argu’s argument and handles it (uses spotifyBus or asks for lyrics).
  2. In the first line of the main method we create the parser by passing our Arguments interface described in previous source code listening.
  3. Finally, we parse the command the execute the action or we print the text with instructions if no argument was passed.

Done!

Publishing the app, adding aliases.

To publish the app let’s navigate in the terminal to our project with the CLI project and use command dotnet publish -c Release -r linux-x64. We should get Spotify.Console.dll. Now we can navigate to something like ~/src/Spotify.Console/bin/Release/netcoreapp3.1/linux-x64 and run our app dotnet Spotify.Console.dll --help. Or we can write a full “dll” path and stay in the terminal where we are. This isn’t comfortable at all, is it? Let’s create an alias by typing in the terminal:

1alias spot='dotnet ~/projects/spotify-linux-published/Spotify.Console.dll'

Now we can use spot --help, spot --next and so on easily! Remember that the alias will vanish upon reboot. To make it live longer we have to put the alias here: /home/[user]/.bash/.bash_aliases. Simply add the same line at the end of the file (create the file if it doesn’t exist). Save and close the file, a reboot is not required, just run this command source ~/.bash_aliases and you are good to go! Have fun.

Conclusions

We have covered a lot!

  • D-Bus - what is it, how to establish the communication in dotnet.
  • Argu - CLI argument parsing made easy.
  • Publishing dotnet app on Linux, creating aliases.
  • Some F# modeling, cool tests, fun functions!

I use my new commands daily. It’s easier to open terminal (I use Guake Terminal so ctrl +`) and type spot –next instead opening Spotify, look for the control and press it. Printing the lyrics is equally fun. Hear you next time!


Footnotes:
[4] To be honest I really don’t like the Async postfix in methods names - I understand that they are needed in libraries which have to support both synchronous and asynchronous model but besides I see them obsolete. And interfaces… well… the library is written in C#, samples are in C# so It is written for C# developers, let’s just do what we have to do and let’s be happy that F# supports interfaces.

References:
Websites:
[1] Freedesktop site with D-Bus description
[2] Not-complete list of desktop apps using D-Bus
[3] Tmds.Dbus package project github
[4] My article about learning tests
[5] Argu page on fsprojects
[6] System.CommandLine netcore package