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 thegenres
folder, which is essentially my music library;buffer
, with all songs that are inside thebuffer
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