Integrating a music library: an example with Beets
Liquidsoap’s native sources can read from files and folders, but if your radio uses an important music library (more than a thousand tracks) sorting this library by folders may not be enough. In that case you would better maintain a music library queried by Liquidsoap. In this section we’ll do this with Beets. Beets holds your music catalog, cleans tracks’ tags before importing, and most importantly has a command-line interface we can leverage from Liquidsoap.
The following examples may also inspire you to integrate another
library program or your own scripts. If you’re going with Beets, you’ll
need an installation having the random
plug-in
enabled. We’ll examine how you can pick Beets tracks (local files)
from Liquidsoap, using 3 techniques fitting different Liquidsoap sources
:
- The most intuitive is an infite track source, that queries Beets for
each track via
request.dynamic.list
. - We can also add Beets as a protocol so you can query beets from requests
- Finally we use Beets to generate a file suitable for
playlist
.
Before that, let’s see Beets queries that are the most interesting for a radio.
Beet queries
Queries are parameters that you usually append to the
beet ls
command : Beets will use them to find matching
tracks. The random
plug-in works the same, except that it
returns only one track matching the query (see the
plug-in’s documentation). Once your library is imported, you can try
the following queries on the command line by typing
beet ls [query]
or beet random [query]
. To
test quickly, add the -t 60
option to
beet random
so it will select an hour worth of tracks
matching your query.
Without selectors, queries search in a track’s title, artist, album
name, album artist, genre and comments. Typing an artist name or a
complete title usually match the exact track, by you could do a lovely
playlist just by querying love
.
But in a radio you’ll usually query on other fields. You can select
tracks by genre with the genre:
selector. Be careful that
genre:Rock
also matches Indie Rock
,
Punk Rock
, etc. To select songs having english lyrics, use
language:eng
. Or pick 80s songs with
year:1980..1990
.
Beets also holds internal meta-data, like added
: the
date and time when you imported each song. You can use it to query
tracks inserted over the past month with added:-1m..
. Or
you can query track imported more than a year ago with
added:..-1y
. Beets also lets you set
your own tags.
You can use the info
plug-in to see everything Beets
knows about title(s) matching a query by typing
beet info -l [query]
. See also the
Beets’ documentation for more details on queries operators. All
these options should allow you to create both general and specialiazed
sources.
To use the following examples, replace the beet
path by
the complete path on your own installation (on UNIX systems, find it
with which beet
). We also always add the
-f '$path'
option, so beets only returns the matching
track’s path.
A source querying each next track from Beets
As of Liquidsoap 1.4.2 we can use the
request.dynamic.list
to create a source calling Beets every
time it needs to prepare its the next track:
def beets() =
list.map(fun(item) -> request.create(item),
process.read.lines(
"/home/me/path/to/beet random -f '$path' my_own_query"
)
)end
from_beets = request.dynamic.list(beets)
process.read.lines
returns the command’s output as a
list of strings. Each item in this list (so, each line returned by
beet random
) is processed (list.map
) by an
anonymous function that turns it into a request
. Thus our
function returns a list of requests, as needed by
request.dynamic.list
.
A more re-usable implementation would store beet
’s path
in a constant and make a function returning a function, so you can
re-use it for all queries to Beets:
BEET = "/home/me/path/to/beet"
def beets(arg="") =
fun() -> list.map(fun(item) -> request.create(item),
process.read.lines(
"#{BEET} random -f '$path' #{arg}"
)
)end
music = request.dynamic.list(beets())
recent_music = request.dynamic.list(beets("added:-1m.."))
rock_music = request.dynamic.list(beets("genre:Rock"))
Beets as a requests protocol
If you’re queueing tracks with request.equeue
, you may
prefer to integrate Beets as a protocol. In that case, the list of paths
returned by beet random -f '$path'
fits directly what’s
needed by protocol resolution:
def beets_protocol(~rlog,~maxtime,arg) =
process.read.lines(
"/home/me/path/to/beet random -f '$path' #{arg}"
)end
Then declare the beets
protocol with
add_protocol("beets", beets_protocol,
syntax = "Beets queries, see https://beets.readthedocs.io/en/stable/reference/query.html"
)
Once this is done, you can push a beets query from the telnet server : if you created
request.equeue(id="userrequested")
, the server command
userrequested.push beets:All along the watchtower
will push
the Jimi Hendrix’s song.
Generate playlists with Beets
For Liquidsoap versions prior to 1.4.2, or if you don’t want to call
Beets before every track, you can ask beet random
to return
a track list and save this to a temporary file suitable for
playlist
sources. Note that this example uses a redirection
that is only supported by UNIX systems. Also it’s hacky and error-prone,
we just leave it here as an example.
To do this we will again integrate Beets as a protocol ; the
difference with the above protocol resolution is that our function will
only return the path to the playlist’s temporary file. To use both
protocols, be careful to give different protocol names (the first
argument in add_protocol
).
def beets_protocol(~rlog,~maxtime,arg) =
tmp_playlist = file.temp("beetsplaylist", ".m3u8")
ignore(process.read.lines(
"/home/me/path/to/beet random -f '$path' #{arg} > #{tmp_playlist}"
))
[tmp_playlist]end
add_protocol("beets", beets_protocol)
Before cleaning files created by file.temp
:
beet random
returns only one track matching the query,
remember ? We can add -t 60
to the query, so it will return
at most one hour of music matching the query. Sources would look
like:
music = playlist("beets:-t 60", mode="normal", reload=3600)
recent_music = playlist("beets:-t 60 added:-1m..", mode="normal", reload=3600)
rock_music = playlist("beets:-t 60 genre:Rock", mode="normal", reload=3600)
We use mode="normal"
because we don’t need Liquidsoap to
re-randomize. reload=3600
will cause Liquidsoap to ask
Beets for a new playlist every hour.
If you’re playing from only one playlist
, be careful
that beet random -t 60
returns a list of 60 minutes at
most but it’s usually less : it’s likely that the first song will
play again at the end of the hour. Also Beets tends to fill the last
minutes with short songs, therefore short songs are picked more
frequently than others. You can mitigate these two problems by reloading
before the playlist end, by increasing 60
decreasing
3600
.
Another downside of this technique is that it creates a different
playlist file each time we reload the playlist. After a while, your
/tmp
will be filled of files like
beetsplaylistXXXX123.m3u8
. On UNIX we can add a cleanup
function that regularly calls find -delete
:
exec_at(freq=3600., pred={ true },
fun () -> list.iter(fun(msg) -> log(msg, label="playlists_cleaner"),
process.read.lines("find /tmp -iname beetsplaylist*m3u8 -mtime +0 -delete"),
) )