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
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;
Signal
is a union type that can be used to manipulate the player.PlaybackSatatys
is a union type that will represent player playback status.Song
is a record that will hold song data retrieved from the player.IPlayer
is an interface that inherits theIDBusObject
interface according to documentation. It has to be public, otherwise Tmds.Dbus will fail to do anything (including internal access modifier).IPlayer
methods represent D-Bus operations - signals and method for data retrievalGetAsync<'T>
.- 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;
- First I have declared a few types to return the responses in a clear and readable way.
type String with member x...
is an extension method which will help to compare strings in current culture case insensitive way.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.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:
- We have an adapter to communicate with Spotify.
- 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:
- System.CommandLine [6] - This is my default when I do C# CLI.
- 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:
formatLyric
andretrieveLyrics
are helper-functions to format a list of lyrics into a string that can be printed to the screen in user-friendly form.errorHandler
is Argu function which executes when parsing error occurs. Thefunction
keyword is called “pattern matching function” and is equivalent to:
1let errorHandler2 = ProcessExiter (fun(code) -> match code with | ErrorCode.HelpText -> None | _ -> Some ConsoleColor.Red)
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).- In the first line of the main method we create the parser by passing our Arguments interface described in previous source code listening.
- 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