Geração de listas de reprodução de música com o MPD

Traduções: en
20/09/2020

Música é vida. Eu realmente amo ouvir música, apesar que não o mesmo tipo de música o tempo todo. Mas na maioria das vezes, vai de tudo: Eu gosto de ouvir qualquer uma das músicas que eu tenho aleatoriamente. Mas quando eu estou fazendo algo que precisa de concentração (como escrever esse texto) eu só posso ouvir música "de fundo", ou seja, música que não tem vocal. Por isso ter agrupamentos de músicas é bem útil para esse tipo de situação.

Um jeito de agrupar músicas é por meio de listas de reprodução. Mas os critérios que determinam quais músicas vão em uma lista é bastante subjetivo já que é determinado por mim, um ser humano. Foi isso que me motivou a criar algum jeito de gerar listas de reprodução baseado em critérios variados: nome de pasta, artista, álbum, etc.

Recentemente eu li o incrível livro Python Fluente (de autor brasileiro!) com o objetivo de aprofundar minhas habilidades em python, que se tornou minha linguagem de programação preferida já há algum tempo. Eu aprendi bastante lendo esse livro, mas ainda não tinha praticado nada do que aprendi. Uma das coisas mais interessantes que aprendi foi sobre os métodos especiais que fazem dos objetos em python tão flexíveis. Foi aí que percebi que usando métodos especiais, eu conseguiria criar objetos de lista de reprodução bem flexíveis e ao mesmo tempo praticar esse conceito mágico. Ganha-ganha.

O programa que eu uso para gerenciar minha biblioteca de músicas e também para tocar as músicas é o MPD. Ele suporta requisição de informações sobre as músicas por outros programas e também reproduzir músicas com base em uma lista de reprodução, então foi simples fazer o código de geração de listas de reprodução usando o MPD.

A classe MPDPlaylist

A ideia é a seguinte: Eu quero poder criar uma lista de reprodução especificando o que ela deve conter, e também o que ela não deve, e também poder combinar critérios de outras listas usando operações de E e OU. Isso provavelmente ficará mais claro mais à frente com os exemplos.

O código que implementa essa classe está em mpd_playlist.py, e contém o seguinte:

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!")

Aquele trecho com MPDClient() no começo é necessário para conectar ao banco de dados do MPD para posteriormente obter todas as informações sobre as músicas. Ele é provido pelo pacote python-mpd2.

O método __init__(), que é chamado quando um novo objeto é criado, pode receber outra lista de reprodução como seu parâmetro query e nesse caso a nova lista apenas as músicas da lista passada. Ele também pode receber um conjunto de músicas (o que eu ainda nem cheguei a usar, mas fazia sentido suportar). E finalmente, no caso mais comum, pode receber um dicionário contendo os parâmetros de busca que vão ser usados para filtrar as músicas do MPD. Para todos os casos, um nome pode opcionalmente ser passado (necessário para que a lista possa ser salva).

Então, por exemplo, músicas_paramore = MPDPlaylist({'artist': 'Paramore'}) criaria uma lista de reprodução apenas com músicas do Paramore, e músicas_paramore2 = MPDPlaylist(músicas_paramore) poderia ser usado para criar uma cópia dessa lista. Esse segundo caso não parece tão útil mas é a base para processar expressões, como veremos.

__repr__() só é usado para depurar. Ele especifica como o objeto é escrito na tela, nesse caso, mostrando quais músicas a lista de reprodução contém.

__or__() e __and__() são chamadas quando duas listas de reprodução são combinadas com OU (usando |) e com E (usando &), retornando uma lista que contém a união (músicas das duas listas) e a intersecção (apenas músicas presentes nas duas) respectivamente. __neg__() serve para negar a lista (usando -), fazendo com que os parâmetros de busca especifiquem o que ela não deve conter, e portanto que ela contenha tudo menos o que for indicado.

query_songs() obtém as músicas do MPD com base nos parâmetros de busca e as salva dentro do objeto.

write_to_file() salva a lista de reprodução em um arquivo .m3u para que possa posteriormente ser lido e as músicas reproduzidas pelo MPD. O nome do arquivo é o que foi fornecido em __init__.

Agora, alguns exemplos.

Minhas listas de reprodução

Em outro arquivo, playlists.py, eu tenho a definição de todas as minhas listas de reprodução usando a classe MPDPlaylist:

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")

As primeiras listas criadas são:

  • saved, que contém todas as músicas da pasta genres, essencialmente minha biblioteca de músicas;
  • buffer, com todas as músicas da pasta buffer, que são aquelas que ainda estou ouvindo para decidir se gosto ou não;
  • fvt com todas as músicas de pastas começando com %, que são meus álbuns favoritos

Em seguida são definidas listas pra cada um dos gêneros. Algumas delas, rock, electronic, pop, post_rock e rap são então combinadas usando o operador OU (|) para criar a lista common. Isso significa que essa lista de reprodução contém as músicas de todas essas listas combinadas.

Aí a lista not_soundtrack é criada negando a lista soundtrack, então aquela contém apenas as músicas não presentes nesta.

A lista tdg possui apenas músicas do artista Three Days Grace.

A última lista, background, combina várias listas definidas anteriormente como classical, e também listas anônimas como PL({'artist': "Balmorhea"}) (que são usadas apenas para criar essa lista, e não serão salvas como listas independentes, portanto não são armazenadas em variáveis e também não precisam de um nome como parâmetro), bem como remove músicas específicas como PL({'file': "genres/soundtrack/games/Portal - Still Alive.mp3"}).

Não é a sintaxe mais sucinta de todas, mas também não chega a ser extensa e é bastante flexível: serviu para tudo que eu precisava customizando as minhas listas.

Salvando as listas de reprodução

Talvez agora você esteja se perguntando como essas listas de reprodução são escritas em arquivos se elas são apenas criadas e armazenadas em variáveis. A resposta é: python é demais 😃. Esse é gen_playlists.py, o código que faz isso:

#!/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)

Como aquele comentário gentil em cima diz, playlists = (pl for pl in vars(playlists).values() if isinstance(pl, MPDPlaylist) and pl.name) obtém todas as variáveis do tipo MPDPlaylist de playlists.py, desde que tenham nome. Aí laço for itera sobre elas e salva cada uma em um arquivo com seu nome.

Por fim, eu adicionei uma linha no cron para executar esse script todo domingo, atualizando minhas listas de reprodução.

A lista de reprodução 'newest'

Como nada é perfeito, tem uma lista de reprodução que eu não consegui integrar nessa infraestrutura e portanto ficou como um script em bash separado 🤢: a lista newest. Ela contém as últimas 100 músicas adicionadas à minha biblioteca.

Existem quatro tipos de datas em arquivos de acordo com o stat:

  • data de criação do arquivo
  • data do último acesso
  • data da última modificação
  • data da última mudança de estado

Para obter as últimas músicas adicionadas, e não as últimas editadas (às vezes eu edito os metadados de uma música, e não quero que isso interfira nessa lista), eu precisava usar a data de criação, mas ela não é suportada pelo MPD, então é por isso que estou fadado a usar esse script, gen_playlist_newest.sh:

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