Playlist generation with MPD

Translations: Português (pt-BR)
Publication date: Sep 20, 2020
Tags: mpd, python

Music is life. I really love listening to music, although not the same kind of music all the time. Most of the time though, anything goes: I like to listen to any of the songs I have at random. But when I'm doing something that needs concentrating (like writing this text) I can only listen to what I call "background" music, or music that doesn't have vocals. So having groupings of songs is very useful for this kind of situation.

One way of grouping songs is through playlists. But the criteria to determine which songs go into a playlist is very much subjective since, well, it's made up by me, a human being. This is what motivated me to create some way to generate playlists based on varied criteria: folder names, artists, albums, etc.

Recently, I read the incredible book Fluent Python as part of furthering my skills in python, which has easily become my favorite programming language for some time now. I learned a lot reading that book, but hadn't actually practiced any of it yet. One of the most interesting things that I learned was about the special methods that make python objects very flexible. I then realized that using special methods I could make playlist objects that were very flexible while also practicing this magic concept. Win-win.

The program that I use to track my music library and play the music is MPD. It supports querying data about songs and also playing playlists, so it was straight-forward to make my playlist generation script using MPD.

The MPDPlaylist class

This is the idea: I want to be able to create a playlist by specifying what should be in it, and also what shouldn't, and be able to combine criteria from other playlists using AND and OR operations. This will probably become clearer later with the examples.

The code implementing this class is in mpd_playlist.py, which is the following:

from mpd import MPDClient

client = MPDClient()
client.timeout = 60
client.idletimeout = None
client.connect("localhost", 6600)

class MPDPlaylist():

    def __init__(self, query, name=''):
        self.name = name
        if isinstance(query, self.__class__):
            self.songs = set(query.songs)
        elif isinstance(query, set):
            self.songs = set(query)
        else:
            self.songs = self.query_songs(query)


    def __repr__(self):
        return f"{self.__class__}({self.name}, {self.songs})"


    def __or__(self, other):
        return MPDPlaylist(self.songs.union(other.songs))


    def __and__(self, other):
        return MPDPlaylist(self.songs.intersection(other.songs))


    def __neg__(self):
        return MPDPlaylist(MPDPlaylist({}).songs.difference(self.songs))


    def query_songs(self, queries):
        if queries:
            args = []
            for k,v in queries.items():
                args.append(k)
                args.append(v)
            return set(song.get('file') for song in client.search(*args))
        else:
            return set(song.get('file') for song in client.listall() if
                       song.get('file'))


    def write_to_file(self, base):
        if self.name:
            with open(f"{base}/{self.name}.m3u", 'w') as f:
                f.write('\n'.join(self.songs) + '\n')
        else:
            print("Can't write playlist with no name!")

That MPDClient() thing in the beginning is needed to connect to the MPD database to later retrieve all music information. It is provided by the python-mpd2 package.

The __init__() method, which is called when creating a new playlist object, can accept another playlist as its query parameter, in which case the new playlist just copies the songs from the playlist passed. It can also receive a set of songs (which I never actually used, but it made sense to support). And finally, for the most common use, it can receive a dictionary containing the query that will be used to filter the music from MPD. For all cases, an optional name can also be passed for the playlist (needed for the playlist to be saved).

So, for example, paramore_playlist = MPDPlaylist({'artist': 'Paramore'}) would create a playlist only with songs from Paramore, and then paramore_playlist2 = MPDPlaylist(paramore_playlist) could be used to create a copy of that playlist. This second case doesn't sound as useful but is the base of parsing expressions, as we'll see.

__repr__() is just there for debugging. It specifies how the object is printed, showing the songs the playlist contains.

__or__() and __and__() are called when two playlists are OR'ed (using |) and AND'ed (using &) together, yielding a playlist that contains the union (songs from both playlists) and the intersection (only songs that were in both playlists) respectively. __neg__() is for negating the playlist (using -), making so that the query specifies what the playlist should not contain, so it will contain everything but that.

query_songs() uses the query to get all songs that match it from MPD and saves them in the object.

write_to_file() saves the playlist in a .m3u file so that it can be later read and played by MPD. The name of the file is the name given in __init__.

Now, to some examples.

My playlists

In another file, playlists.py, I have all of my playlists defined using the MPDPlaylist class:

from mpd_playlist import MPDPlaylist as PL

saved  = PL({'base': "genres"}, "0_Saved")
buffer = PL({'base': "buffer"}, "1_Buffer")
fvt    = PL({'file': "/%"}, "1_FvtAlbums")

br           = PL({'base': "genres/br"}           , "2_Br")
classical    = PL({'base': "genres/classical"}    , "2_Classical")
electronic   = PL({'base': "genres/electronic"}   , "2_Electronic")
etc          = PL({'base': "genres/etc"}          , "2_ETC")
instrumental = PL({'base': "genres/instrumental"} , "2_Instrumental")
jazz         = PL({'base': "genres/jazz"}         , "2_Jazz")
pop          = PL({'base': "genres/pop"}          , "2_Pop")
post_rock    = PL({'base': "genres/post-rock"}    , "2_Post-rock")
rap          = PL({'base': "genres/rap"}          , "2_Rap")
rock         = PL({'base': "genres/rock"}         , "2_Rock")
soundtrack   = PL({'base': "genres/soundtrack"}   , "2_Sountrack")

common         = PL(rock | electronic | pop | post_rock | rap, "1_Common")
not_soundtrack = PL(-soundtrack, "2_NotSoundtrack")

tdg       = PL({'artist': "Three Days Grace"}, "3_TDG")
minecraft = PL({'base': "genres/soundtrack/games/%minecraft"}, "3_Minecraft")
lotr      = PL({'base': "genres/soundtrack/movies/lotr"}, "3_LOTR")

background = PL(classical
                | jazz
                | instrumental
                | PL({'artist': "Balmorhea"})
                | PL({'artist': "Tycho"})
                | PL({'artist': "MASTER BOOT RECORD"})
                | PL({'artist': "The Album Leaf"})
                | PL({'base': "genres/soundtrack/animes/tatami_galaxy/%ost"})
                | PL({'base': "genres/soundtrack/games"})
                & -PL({'file': "genres/soundtrack/games/Portal - Still Alive.mp3"})
                & -PL({'file': "genres/soundtrack/games/portal_2/3-13_want_you_gone.mp3"})
                & -PL({'title': "Here Comes Vi"}),
                "1_Background")

The first playlists created are:

  • saved, containing all songs that are in the genres folder, which is essentially my music library;
  • buffer, with all songs that are inside the buffer folder, which are the ones I'm still listening to decide if I like or not;
  • fvt with all songs from folders beginning with a %, which are my favorite albums.

Then there are playlists for each of the genres. Some of those, namely rock, electronic, pop, post_rock and rap are then combined with the OR (|) operator to create the common playlist. This means that this playlist contains the music from all of those playlists combined.

Next, the not_soundtrack playlist is created by negating the soundtrack playlist, so the former only contains the music not present in the latter.

The tdg playlist only has music from the Three Days Grace artist.

The last playlist, background, combines several previously defined playlists like classical, and also anonymous playlists like PL({'artist': "Balmorhea"}) (which are just used to create this playlist, and won't be saved as playlists themselves, therefore they aren't saved in a variable and also don't need a name parameter), while also removing specific songs like PL({'file': "genres/soundtrack/games/Portal - Still Alive.mp3"}).

This isn't the most succinct syntax but it also isn't bad and is very flexible: It served all my needs for customizing my playlists.

Saving the playlists

Now, you might be wondering how those playlists are written to the files if they are only created and saved in variables. The answer is: python is cool 😃. This is gen_playlists.py, the cool script that does it:

#!/bin/python
from os.path import expanduser

from mpd_playlist import MPDPlaylist
import playlists

PLD = expanduser("~/.config/mpd/playlists")

# All variables defined locally with type MPDPlaylist and with a name will be
# contained here
playlists = (pl for pl in vars(playlists).values()
             if isinstance(pl, MPDPlaylist) and pl.name)

for playlist in playlists:
    playlist.write_to_file(PLD)

As that nice comment above it says, playlists = (pl for pl in vars(playlists).values() if isinstance(pl, MPDPlaylist) and pl.name) gets all variables of type MPDPlaylist from playlists.py, as long as they have a name. Then the for loop iterates over them and saves each playlist in a file with its name.

Finally, I added a line in cron to run this script every Sunday, updating my playlists.

The 'newest' playlist

Since nothing is perfect, there's one playlist that I couldn't integrate in that framework and was left as its own shell script 🤢: the newest playlist. It contains the 100 latest songs added to my music library.

There are four different timestamps for files according to stat:

  • time of file birth
  • time of last access
  • time of last data modification
  • time of last status change

To get the latest songs added, and not the ones most recently edited (sometimes I edit song metadata, and don't want it to interfere with this playlist), I needed to use time of birth, but it isn't supported by MPD, so that's why I'm stuck with this gen_playlist_newest.sh shell script:

PLD=$HOME/.config/mpd/playlists
find ~/ark/mus/genres -type f -regextype posix-extended -regex '.*\.(flac|mp3)' -exec stat --format '%W : %n' "{}" \; \
    | sort -nr | head -n 100 | cut -d: -f2 | sed -e 's|.*mus/\(.*\)|\1|' > \
    $PLD/1_Newest.m3u