Make a playlist quickly

Last week my sister married, and she asked me to provide some background music for the lunch and afternoon party. As I did not want to spend the whole event DJing, I decided it would be simpler to just generate a nice playlist and let it run via shuffle. Instead of actively thinking about which songs I should choose, I wrote a small script that goes through all my MP3 and prompts me if I want to add it to the playlist.

But how much work is this?

domm@t430:~/media/mp3$ find . -name *.mp3 | wc -l
12169

Hm, seems I have 12.169 MP3 files. If I take a second to decide on each on, it would still take me more than 3 hours. Of course I know that a lot of my music is not suitable for weddings, so I need to skip a whole album, or even a whole artist. This should bring the time down to a reasonable measure.

What is a playlist, anyway?

For just playing MP3s, I use audacious. Audacious uses M3U for playlists, which is nice, because M3U is basically just a long list of filenames. No fuzz!

Getting all the MP3s!

First step: Collect a list of all MP3s. When iterating through directory trees, I like to use Path::Class::Iterator

my $root = dir('/home/domm/media/mp3');
my $iterator = Path::Class::Iterator->new(
    root  => $root,
);
my %all;
until ( $iterator->done ) {
    my $f = $iterator->next;
    next unless $f =~ /\.mp3$/;
    $all{ $f->stringify } = $f;
}

This code will iterate through all my MP3s, and generate a hash of filenames mapped to Path::Class file objects.

Skipping

To skip albums and artists, I need to come up with a way of remembering which artist (or album) I'm currently looking at. If I decide to skip, I just need to ignore all files until the current artist (or album...) does not match the one I want to skip. Here's a rather simple solution (simple enough for my problem, and I wanted to get things done...)

my $cur_artist  = '';
my $cur_album   = '';
my $skip_artist = '';
my $skip_album  = '';

foreach my $k ( sort keys %all ) {
    my $f     = $all{$k};
    my $rel   = $f->relative($root);
    my @parts = $rel->components;

    if ($skip_album) {
        next if $cur_album eq $parts[-2];
    }
    if ($skip_artist) {
        next if $cur_artist eq $parts[-3];
    }

    if ( $parts[-3] && $cur_artist ne $parts[-3] ) {
        $cur_artist = $parts[-3];
        say $cur_artist;
        $skip_artist = '';
    }
    if ( $cur_album ne $parts[-2] ) {
        $cur_album = $parts[-2];
        say "\t$cur_album";
        $skip_album = '';
    }

Now, this code assumes a few things, mostly that the second-to-last part of each path represents the album name, and the third-to-last the artist name. As I control the data, I know that this is always true (I'm quite pedantic about naming my MP3 files!)

To break it down a bit more:

    my @parts = $rel->components;

given a filename like file:///media/mp3/vampire_weekend/modern_vampires_of_the_city/03_step.mp3, components will return an array like

qw( media mp3 vampire_weekend modern_vampires_of_the_city 03_step.mp3 )
                       ^                     ^                   ^
                       |                     |                   |
                    $parts[-3]            $parts[-2]          $parts[-1]

The first two if blocks check if we're still in skip-mode and skip if we are. The second two if blocks set the current artist (and/or album), and tell me where I am in the MP3 tree.

Select something!

Now I can actually decide what I want to do. So I need the script to prompt me. Perl has a million ways to prompt, but I wanted a prompt where I did not have to press enter. A single key press has to be enough (If I have to hit ENTER for a few thousand times, I will get crazy..). So I could either roll my own using Term::ReadKey. Or search a bit on CPAN and find Prompt::ReadKey:

   my $action = $p->prompt(
        prompt  => "\t\t" . $rel->basename,
        options => [
            { name => 'playlist' },
            { name => 'skip_album', keys => ['s'] },
            { name => 'skip_artist', keys => ['a'] },
            { name => 'ignore' },
        ]
    );

prompt takes a bunch of options which (in my case) define the commands I want to run. If you do not define which keys will trigger an options, Prompt::ReadKey defaults to lc(name). I choose i and p because they are nearly next to each other on the keyboard, and a and s because they are next to each other on the other side of the keyboard (and thus my other hand), to avoid mixing up the actions..

So I now just have to press i to ignore the current song. etc.

And do something!

   if ( $action eq 'playlist' ) {
        say $playlist 'file://' . $f;
    }
    elsif ( $action eq 'skip_album' ) {
        $skip_album = $cur_album;
    }
    elsif ( $action eq 'skip_artist' ) {
        $skip_artist = $cur_artist;
    }

prompt returns the name of the selected options, which I store in $action. The I just if/else through the possible options (again, good enough for now).

If I select a file for the playlist, I just write a file:// URI into the playlist. When I skip something, I store the value in the correct skip-var. And I can ignore handling ignore, because it does nothing!

We have a playlist!

Using this quickly hacked together script, I could very quickly page through my MP3s, skip stuff like The Bug or König Leopold, and come up with a very nice playlist which was enjoyed by all (or nobody dared to complain..)

The whole code

Here's the whole code, with one more feature: If I hit quit, the current position in the tree of MP3s is stored in a file, and if I start the script again, it will fast-forward to that location. This was helpful when I decided to add a song right after ignoring it (because my fingers were faster than my (music) brain).

I guess another good improvement would be to allow me to listen to each track (eg by forking of to mplayer). Or to add a back-button (easy: instead of going over keys %all, store them in an array and iterate via a counter). Oh, and of course: Port it to Perl 6!

#!/usr/bin/env perl
use 5.020;
use strict;
use warnings;
use Path::Class;
use Path::Class::Iterator;
use Prompt::ReadKey;

my $root = dir('/home/domm/media/mp3');

my $iterator = Path::Class::Iterator->new(
    root            => $root,
    depth=>4,
);

my $cur_artist='';
my $cur_album='';
my $skip_artist='';
my $skip_album='';

my $p = Prompt::ReadKey->new;

open(my $playlist, ">>", 'playlist.m3u');

my $old_pointer;
if (-e 'playlist.pointer') {
    $old_pointer = file('playlist.pointer')->slurp;
    chomp($old_pointer);
}

my %all;
until ($iterator->done) {
    my $f = $iterator->next;
    next unless $f=~/\.mp3$/;
    $all{$f->stringify} = $f;
}
say "mp3s: ".keys %all;

foreach my $k (sort keys %all) {
    my $f = $all{$k};
    my $rel = $f->relative($root);
    my @parts = $rel->components;

    if ($old_pointer) {
        next unless $f eq $old_pointer;
        $old_pointer=undef;
    }

    if ($skip_album) {
        next if $cur_album eq $parts[-2];
    }
    if ($skip_artist) {
        next if $cur_artist eq $parts[-3];
    }

    if ($parts[-3] && $cur_artist ne $parts[-3]) {
        $cur_artist = $parts[-3];
        say $cur_artist;
        $skip_artist='';
    }
    if ($cur_album ne $parts[-2]) {
        $cur_album = $parts[-2];
        say "\t$cur_album";
        $skip_album='';
    }

    my $action = $p->prompt(prompt => "\t\t".$rel->basename,options=>[{name=>'playlist'},{name=>'skip_album',keys => ['s']},{name=>'skip_artist',keys=>['a']},{ name=>'ignore' }, {name=>'quit'} ]);
    if ($action eq 'playlist') {
        say $playlist 'file://'.$f;
    }
    elsif ($action eq 'skip_album') {
        $skip_album = $cur_album;
    }
    elsif ($action eq 'skip_artist') {
        $skip_artist = $cur_artist;
    }
    elsif ($action eq 'quit') {
        open(my $pointer, ">", 'playlist.pointer');
        print $pointer $f;
        exit;
    }
}

(and maybe put my ~/bin on github...)