Ultimative Jukebox (Linux-Magazin, August 2003)

Das Modul Apache::MP3 schlingt eine komfortable Web-Jukebox um lose MP3-Sammlungen. Wer's gerne ordentlich mag, zieht mit einem Perl-Skript noch eine Hierarchiestufe ein.

Während der letzten Monate habe ich meine Drohung aus [2] wahr gemacht und alle meine CDs zu MP3s gerippt, um sie auf einer nagelneuen Riesenfestplatte (120 Gig!) zu speichern. Dann stöpselte ich ein Kabel in meine Soundkarte, verband es mit der Stereoanalage und knipste mit dem Browser fortan fröhlich in tausenden von Titeln herum. Erstaunlich, was ich bislang zwar besessen aber nicht wahrgenommen hatte! Als der erste Rausch vorbei war, begann ich, Perl-Skripts zu schreiben, um den Wahnsinn noch eine Stufe weiter zu treiben.

Abbildung 1: Die Jukebox bietet alle Songs der CD "Shenanigans" von "Green Day" zum Streamen an.

Ja, ja, ich weiss, es war eine Schweinearbeit, 200 CDs einzulesen. Wer den hohen Aufwand scheut, dem schleudere ich entgegen: Schon mal daran gedacht, innerhalb von Sekunden einfach irgendeinen Song anzuknipsen, der einem gerade im Kopf herumgeistert? Songs per Stichwort zu suchen und sofort abzuspielen? Playlists zu erstellen, zu verwalten und bei Bedarf abzuhören? Den Musikserver übers Ethernet von mehreren Servern/Stereoanlagen in der Wohnung zu nutzen? Oder Songs nach Schmoop-Faktor zu kategorisieren und dem Anlass entsprechend einfach ein Dutzend auszuwählen? Und das alles völlig legal? Hat man sich erst einmal daran gewöhnt, nicht mehr mit Silberscheiben zu hantieren und lernt die erweiterten Such- und Sortiermechanismen zu schätzen, kann man sich kaum mehr vorstellen, welch absurde Tätigkeiten in der Steinzeit der CD-Technologie noch notwendig waren, um Musik zu hören.

Der Ripper schlägt zu

Zum Rippen nutzte ich das Perl-Skript crip, das es unter [3] als freie Software gibt. Man legt einfach eine CD ins Laufwerk ein, und crip kontaktiert die CDDB-Datenbank, um Interpreten, Album- und Titelinformationen einzuholen, die es dann zu jedem Song zusammen mit der Musik in einer *.mp3 Datei ablegt. Zuerst wollte ich ja auf Ogg Vorbis umsteigen (was crip neuerdings ausschließlich unterstützt, wer MP3 will, muss auf Version 1.0 zurückgreifen!), musste aber diesen Plan streichen, da der tragbare MP3-Player meiner Frau das Format nicht unterstützt. C'est la vie!

Abbildung 2: Die Jukebox zeigt alle CDs an, die ich von "Green Day" besitze.

MP3-Partitionen

Um die gigantische Datenmenge besser partitionieren zu können (immerhin 25 Gigabyte), verteilte ich die MP3s auf sogenannte ``Pods'', dreistellig durchnumerierte Unterverzeichnisse (001, 002, ...) mit jeweils 700 MB Daten. Woher der Wert? Passt bequem auf eine CDROM, um die mühevolle Ripp-Arbeit zu sichern. Um zum Beispiel Pod 027 zu sichern, muss ich nur einen Rohling einlegen und

    cdr 027

tippen, schon springt der Brenner an. cdr is natürlich ein einfaches Shell-Skript, das nur die Zeile

    mkisofs -R $* | cdrecord -v speed=4 dev=0,0 -

enthält und [8] zeigt ein paar nützliche Tricks, um handelsübliche CD-Brenner unter Linux anzusprechen.

In einen Pod passen etwa 150 bis 200 MP3-Dateien, 33 Pod-Verzeichnisse legte ich insgesamt an, 001 bis 033.

Die Pods kann ich auf verschiedene Partitionen einer oder mehrerer Festplatten legen, und von einer zentralen Dateistruktur aus mittels symbolischer Links zu den einzelnen MP3-Dateien weisen. Auch können so mehrere Ansichten der CD-Sammlung koexistieren (nach Album sortiert, nach Interpret, nach Musikrichtung, nach Schmusigkeit), ohne dass man die zentnerschweren MP3-Dateien herumkopieren muss, die bleiben ewig im gleichen Pod.

Vom einem temporären Verzeichnis, in dem crip die CD rippt, schiebt das Skript topod die entstandenen *.mp3-Dateien ins nächste verfügbare Pod. Es benutzt das Modul Algorithm::Bucketizer vom CPAN, um die verschiedenen ``Eimer'' der Pod-Kette jeweils bis zur 700MB-Grenze zu füllen und, falls nötig, einen neuen Eimer hinten anzuhängen. Es hält sich außerdem einen Hash %seen, um bestehende Duplikate in der Sammlung aufzuspüren und neue Doubletten gar nicht entstehen zu lassen.

Listing 1: topod

    01 #!/usr/local/bin/perl
    02 ###########################################
    03 # topod
    04 # Mike Schilli, 2003 (m@perlmeister.com)
    05 ###########################################
    06 use warnings;
    07 use strict;
    08 
    09 my $POD_DIR = "/ms1/SONGS/pods";
    10 
    11 use File::Basename;
    12 use Algorithm::Bucketizer;
    13 use File::Copy;
    14 
    15 my %seen = ();
    16 
    17     # Init buckets
    18 my $b = Algorithm::Bucketizer->new(
    19     bucketsize => 700_000_000,
    20     algorithm  => 'simple',
    21 );
    22 
    23     # Prefill buckets with existing Pods
    24 while(<$POD_DIR/*>) {
    25     my($idx) = /(\d{3})/;
    26     next unless $idx;
    27 
    28     while(<$POD_DIR/$idx/*.mp3>) {
    29         my $base = basename($_);
    30         if(exists $seen{$base}) {
    31             print "Dupe detected: $_\n";
    32         }
    33         $seen{$base}++;
    34         $b->prefill_bucket($idx - 1, 
    35                            $_, -s $_);
    36     }
    37 }
    38 
    39 while(<*.mp3>) {
    40     my $diff = time() - (stat($_))[9];
    41     print "diff=$diff\n";
    42     if($diff < 60) {
    43         warn "$_: not old enough";
    44         next;
    45     }
    46     if(exists $seen{$_}) {
    47         print "Not adding dupe: $_\n";
    48         next;
    49     }
    50 
    51     $seen{$_}++;
    52 
    53     my $bucket = $b->add_item($_, -s $_);
    54 
    55     my $path = sprintf "$POD_DIR/%03d/$_", 
    56                       $bucket->serial();
    57 
    58     unless(-d dirname($path)) {
    59         mkdir dirname($path) or 
    60             die "Cannot mkdir " . 
    61                 dirname($path);
    62     }
    63 
    64     move($_, $path) or 
    65         die "Cannot move $_ to $path";
    66 }

Zeile 18 in topod initialisiert ein Algorithm::Bucketizer-Objekt mit 700.000.000 Bytes Eimergrösse, das den simple-Algorithmus verinnerlicht, also stur nur den letzten Eimer auffüllt, bevor es einen neuen anlegt. So ist sicher gestellt, dass sich außer dem letzten Pod keiner mehr ändert, sodass wir alle anderen bei Bedarf getrost auf CDROMs sichern können.

Falls das Skript feststellt, dass schon Pods auf der Platte existieren, muss es diese zunächst in virtuelle Eimer umwandeln und dem Algorithm::Bucketizer-Objekt mittels der prefill_bucket-Methode in Zeile 33 unterjubeln.

So prepariert kann Algorithm::Bucketizer in der while-Schleife ab Zeile 38 mittels der add_item-Methode neue MP3s entgegennehmen und in den letzten bestehenden Eimer platzieren oder einen neuen Behälter anlegen. Entsprechend legt es in der realen Welt neue Unterverzeichnisse an (Zeile 52) und schiebt neue MP3s hinein (Zeile 57).

Algorithm::Bucketizer numeriert die Eimer von 0 an aufsteigend durch, die Unterverzeichnisse in der realen Welt heissen 001, 002, undsoweiter. Die add_item()-Methode in Zeile 46 nimmt jeweils den Namen der MP3-Datei und deren mit -s ermittelte Größe entgegen und gibt das glückliche Eimer-Objekt zurück, das die MP3-Datei in Empfang nahm. Dessen serial()-Methode liefert die Indexnummer des Eimers (0, 1, 2, ...). Addiert man eins dazu und formatiert sie mit führenden Nullen (z.B. 007) erhält man das dazugehörige Pod-Verzeichnis.

Ansichten

Um Songs auszuwählen, will ich aber nicht im Durcheinander der Pods herumwühlen. Vielmehr möchte ich drei Hierarchiestufen: Ein Top-Verzeichnis mit allen Interpreten, darunter jeweils deren Alben, und darunter jeweils die Songs eines Albums in der richtigen Reihenfolge.

crip hat schon dafür gesorgt, dass die einzelnen MP3-Dateien die korrekten Tag-Informationen enthalten. Außerdem zeigen auch die Dateinamen an, woher der Wind weht, zum Beispiel:

    The_Strokes_-_ITI02_The_Modern_Age.mp3

Vor dem Querstrich steht der Interpret (The_Strokes), dahinter die ersten Buchstaben der Wörter des Albumtitels (ITI = Is This It), gefolgt von der Tracknummer (02), gefolgt vom Songtitel (``The_Modern_Age'').

In der von mir geforderten by_artist-Hierarchie soll dieser Song wie folgt landen:

    by_artist
        Strokes,_The
            Is This_It
                01 ..................
                02_The_Modern_Age.mp3
                03 ..................

Listing mktree iteriert hierzu durch alle Pods, liest mittels des Moduls MP3::Info die eingebetteten Info-Tags der dort liegenden MP3-Dateien, erzeugt die notwendigen Unterverzeichnisse im by_artist-Baum (``Strokes,_The/Is_This_It'') und legt, da der Song ``The Modern Age'' in Pod 017 liegt, anschließend folgenden symbolischen Link an:

    ln -s /.../pods/018/The_Strokes_-_ITI02_The_Modern_Age.mp3 \
    by_artist/Strokes,_The/Is_This_It/02_The_Modern_Age.mp3

Bei den frei auf freedb.org erhältlichen CD-Daten spielt freilich der menschliche Faktor rein: Da vertippt sich schon mal einer, vergisst das zweite ``z'' in ``Eros Ramazzotti'' und schwupps, steht's falsch in der Datenbank. Oder es steht ``Tom Waits'' drin, während man in einem alphabetischen Listing doch lieber ``Waits, Tom'' hätte.

Gehirnschmalz zur Rettung

Allerdings lässt sich das schwer automatisieren: Wo ist der Unterschied zwischen ``John Cale'', den wir lieber als ``Cale, John'' in der Sammlung hätten und der famosen Gruppe ``Judas Priest'', die genau so bleiben soll?

Menschliche Intelligenz einschalten! mktree macht zu jedem neu gefundenen Interpreten ein paar sinnvolle Vorschläge und lässt den Benutzer entscheiden:

    [1] Judas Priest
    [2] Priest, Judas
    [1]>

Für den ersten Vorschlag muss der Benutzer einfach auf ``Enter'' hämmern, tippt er eine Zahl ein, wählt mktree den entsprechend numerierten Eintrag. Liegen alle Vorschläge daneben, nimmt mktree an dieser Stelle auch gerne wörtlichen Text entgegen. Einmal bestätigte Entscheidungen speichert es persistent bis zum nächsten Aufruf in einem GDBM-Datenbänklein.

Listing 2: mktree

    001 #!/usr/bin/perl
    002 ###########################################
    003 # mktree
    004 # Mike Schilli, 2003 (m@perlmeister.com)
    005 ###########################################
    006 use warnings;
    007 use strict;
    008 
    009 my $POD_ROOT    = "/ms1/SONGS/pods";
    010 my $TREE_ROOT   = "/ms1/SONGS/by_artist";
    011 my $MP3_PATTERN = qr/\.mp3$/;
    012 my %ARTIST_MAP  = ();
    013 my $ARTIST_FILE = "artistmap.gdbm";
    014 
    015 use Log::Log4perl qw(:easy);
    016 use GDBM_File;
    017 use File::Find;
    018 use MP3::Info;
    019 use File::Basename;
    020 use File::Path;
    021 use File::Spec;
    022 use Getopt::Std;
    023 
    024 Log::Log4perl->easy_init(
    025      { level  => $INFO, layout => '%m%n'});
    026 
    027 getopts("du", \my %opts);
    028 
    029 tie %ARTIST_MAP, 'GDBM_File', $ARTIST_FILE,
    030     &GDBM_WRCREAT, 0640 or 
    031         die "Cannot tie $ARTIST_FILE";
    032 
    033 if($opts{d}) {
    034         # Dump artist map
    035     for(sort keys %ARTIST_MAP) {
    036         print "$_ => $ARTIST_MAP{$_}\n";
    037     }
    038 } elsif($opts{u}) {
    039         # Undump artist map
    040     %ARTIST_MAP = ();
    041     while(<>) {
    042         chomp;
    043         my($k, $v) = split / => /, $_, 2;
    044         $ARTIST_MAP{$k} = $v;
    045     }
    046 } else {
    047         # Link hierarchy entry to pod entry
    048     find(sub { 
    049         mklink($File::Find::name)
    050                          if /$MP3_PATTERN/;
    051     }, $POD_ROOT);
    052 }
    053 
    054 ###########################################
    055 sub mklink {
    056 ###########################################
    057     my($file) = @_;
    058 
    059     my $tag = get_mp3tag($file);
    060 
    061     if(!$tag) { 
    062         warn "No TAG info in $file";
    063         link_path($file, 
    064           "Lost+Found/" . basename($file));
    065         return;
    066     }
    067 
    068     for(qw(ARTIST ALBUM TITLE)) {
    069         unless($tag->{$_} =~ /\S/) {
    070             warn "No $_ TAG in $file";
    071             link_path($file, 
    072                       "Lost+Found/" . 
    073                       basename($file));
    074             return;
    075         }
    076     }
    077 
    078     my $track_no = $tag->{TRACKNUM};
    079     ($track_no) = ($tag->{COMMENT} =~ /(\d+)$/) unless length $track_no;
    080     $track_no = "XX" unless length $track_no;
    081 
    082     my $artist = $tag->{ARTIST};
    083 
    084     unless(exists $ARTIST_MAP{$artist}) {
    085         $ARTIST_MAP{$artist} = 
    086                       warp_artist($artist);
    087     }
    088 
    089     $artist = $ARTIST_MAP{$artist};
    090 
    091     my $relpath = File::Spec->catfile(
    092         map { s/[\s\/]/_/g; $_;
    093             } $artist, $tag->{ALBUM},
    094           "${track_no}_$tag->{TITLE}.mp3");
    095 
    096     link_path($file, $relpath);
    097 }
    098 
    099 ###########################################
    100 sub link_path {
    101 ###########################################
    102     my($file, $relpath) = @_;
    103 
    104     my $path = File::Spec->rel2abs(
    105                      $relpath, $TREE_ROOT);
    106 
    107     my $dir = dirname($path);
    108     unless(-d dirname($path)) {
    109         INFO("mkdir $dir");
    110         mkpath $dir or 
    111             die "Cannot mkpath $dir";
    112     }
    113     unless(-l $path) {
    114         INFO("Linking $file to $path");
    115         symlink($file, $path) or 
    116             die "Cannot symlink $file";
    117     }
    118 }
    119 
    120 ###########################################
    121 sub warp_artist {
    122 ###########################################
    123     my($artist) = @_;
    124 
    125     my @choices = ();
    126 
    127     my @c = split ' ', $artist;
    128 
    129     if(@c == 1) {
    130         @choices = ();
    131     } elsif($c[0] =~ /^the$/i) {
    132         my $the = shift @c;
    133         @choices = ("@c, $the");
    134     } elsif(@c == 2) {
    135         @choices = ("$c[1], $c[0]");
    136     } elsif(@c == 3) {
    137         @choices = ("$c[2], $c[0] $c[1]");
    138     }
    139 
    140     return pick($artist, @choices);
    141 }
    142 
    143 ###########################################
    144 sub pick {
    145 ###########################################
    146     my(@options) = @_;
    147 
    148     my $counter = 1;
    149     
    150     for(@options) {
    151         print "[", $counter++, "] $_\n";
    152     }
    153 
    154     $| = 1;
    155     print "[1]>";
    156 
    157     chomp(my $input = <STDIN>);
    158     $input = 1 unless $input;
    159 
    160     if($input =~ /^\d+$/) {
    161         return $options[$input-1];
    162     } else {
    163         return $input;
    164     }
    165 }

mktree definiert ab Zeile 9 zunächst eine Reihe von installationsabhängigen Parametern: Unter $POD_ROOT liegen die Pod-Verzeichnisse mit den MP3-Dateien, unter $TREE_ROOT kommen Interpreten, Alben und Songs (in dieser Reihenfolge) zu liegen. Im persistenten Hash %ARTIST_MAP steht, wie der Interpretenname aus der der öffentlichen CDDB-Datenbank zu korrigieren ist.

Zeile 16 zieht das GDBM_File-Modul herein, das der tie()-Befehl in Zeile 29 nutzt, um den Hash %ARTIST_MAP persistent abzuspeichern. Zeile 24 initialisiert Log::Log4perl, das aus alter Gewohnheit drinblieb, um mktree schnell mehr oder weniger gesprächig zu machen. %m%n gibt nur die Log-Nachricht und ein Newline-Zeichen aus.

mktree versteht dank Zeile 27 auch die Optionen -d (dump) und -u (undump), die es nur den Inhalt der permanenten %ARTIST_MAP ausgeben bzw. setzen lassen.

    mktree -d >data

legt in der Datei data die Interpreten-Tabelle wie in

    The Beatles => Beatles, The
    Salt 'N' Pepa => Salt 'N' Pepa
    Zucchero Sugar Fornaciari => Zucchero

ab. Bringt man mittels eines Texteditors manuelle Korrekturen in data an, lädt

    mktree -u <data

die ganze Chose anschließend wieder in die binäre GDBM-Datei und mktree ist beim nächsten Aufruf repariert. Sehr praktisch, falls man sich mal vertippt, nachdem mktree nach einer Eingabe gefragt hat.

MP3-Tags herausfieseln

Das Modul MP3::Info hilft beim Lesen der Tag-Informationen in MP3-Dateien. Die daraus exportierte Funktion get_mp3tag() nimmt den Namen einer MP3-Datei entgegen und gibt eine Referenz auf einen Hash zurück, der den CD-Daten entsprechend Einträge zu den Schlüsseln ARTIST, ALBUM, TITLE und COMMENT enthält.

Die ab Zeile 55 definierte Funktion mklink() in mktree nimmt den vollständigen Pfad zu einer MP3-Datei in einem Pod entgegen, extrahiert daraus mittels MP3::Info die zugehörigen CD-Daten und ermittelt den Titel des Albums und den normalisierten Interpretennamen, unter Umständen mit Hilfe des Benutzers. link_path() legt dann mit symlink einen symbolischen Link an, der von by_artist/interpret/album/song.mp3 zur wirklichen MP3-Datei im Pod zeigt. Bei wirren oder fehlenden MP3-Tag-Daten landet der Link im Lost+Found-Verzeichnis.

Die Tracknummer extrahiert das Skript hierzu mittels eines regulären Ausdrucks aus dem MP3-eigenen Feld COMMENT, in dem etwas wie ``track11'' steht. Leerzeichen und in Unix-Dateinamen unerlaubte Schräger ersetzt der map-Befehl in Zeile 94 in Interpret, Album und Songtitel durch simple Unterstriche (_). Da s/[\s\/]/_/g; nicht etwa den Ergebnisstring, sondern die Anzahl der Ersetzungen (!) zurückgibt, muss noch ein $_; hinterherkommen, damit der map-Befehl die Einzelkomponenten an das alles zu einem Pfadnamen zusammenschusternde catfile aus der File::Spec-Sammlung weiterreichen kann.

warp_artist() ab Zeile 123 versucht, mehr oder minder schlaue Vorschläge aus einem ihr übergebenen Interpretennamen zu generieren. Mit ``The Red Hot Chili Peppers'' aufgerufen, wird es ``Red Hot Chili Peppers, The'' und ``The Red Hot Chili Peppers'' generieren und beides zur Wahl stellen. Von ``Rory Galagher'' wird es ``Rory Galagher'' und ``Galagher, Rory'' ableiten.

pick schließlich nimmt (ab Zeile 146) eine Liste von Vorschlägen entgegen, bietet dem Benutzer alle Strings jeweils unter einer Nummer zur Auswahl an. Wählt der Benutzer eine der vorgeschlagenen Nummern aus, liefert pick() den zugehörigen String zurück. Gibt der Benutzer hingegen einen frei definierten neuen Textstring ein, wird dieser von pick diensteifrig übernommen und ebenfalls an den Aufrufer zurückgereicht.

Installation

Nach dem Anpassen der Konfigurationszeilen an die lokalen Gegebenheiten werden topod und mktree einfach von der Kommandozeile aus aufgerufen. topod findet gerade gerippte MP3-Dateien im gegenwärtigen Verzeichnis, mktree kann irgendwo laufen, selbst als Cronjob. Hat es einmal die by_artist-Hierarchie eingerichtet, müssen wir nur noch einen Apache-Webserver darauf einnorden.

Benötigt wird ein mod_perl-tauglicher Apache (Anleitung unter [5] oder [6]), und die lokale Perl-Installation braucht das Modul Apache::MP3 vom CPAN. Meine Installation lief mit dem Apache 1.3.37 -- mittlerweile soll mod_perl aber auch zuverlässig auf dem 2.0er schnurren. Folgende Einträge in httpd.conf aktivierten anschließend meinen Musikserver:

    <Location /songs>
      SetHandler perl-script
      PerlHandler Apache::MP3::Sorted
      PerlSetVar SortFields Artist,Album,comment
    </Location>

Das Verzeichnis /songs unter der Dokumentenwurzel htdocs des Apache muss dabei zumindest symbolisch auf das oben mit mktree angelegte by_artist-Verzeichnis zeigen. Damit der Apache dem Link nachgeht, muss in der Konfiguration etwas wie

    <Directory />
        Options FollowSymLinks
        AllowOverride None
    </Directory>

stehen. Dirigiert man dann nach einem Neustart den Browser nach

    http://localhost/songs

kann man nach Herzenslust in der Sammlung herumstreunen. Wird ein ``Stream''-Link neben einem Song aktiviert, springt der Linux-MP3-Spieler xmms ([7]) an und spielt einen oder mehrere Songs, der Reihe nach oder zufällig, ganz nach Belieben. Und das ist erst der Anfang einer wunderbaren neuen Freundschaft.

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2003/08/Perl

[2]
Michael Schilli, ``Musik aus dem Keller'', Linux-Magazin 03/2003, http://www.linux-magazin.de/Artikel/ausgabe/2003/03/perl/perl.html

[3]
Die ``crip'' Homepage, http://bach.dynet.com/crip/index.html

[4]
Apache::MP3: http://namp.sourceforge.net, CPAN

[5]
Apache mod_perl homepage: perl.apache.org

[6]
Michael Schilli, ``Hacker mögen Sport'', Linux_Magazin 02/2001, http://www.linux-magazin.de/Artikel/ausgabe/2001/02/Perl/perl.html

[7]
Der xmms auf Redhat 8.0/9.0 spielt keine MP3s mehr ab, unter http://www.gurulabs.com/downloads.html gibt's den Fix.

[8]
Steve Litt, ``Installing Your ATAPI CDRW Drive in Linux'', http://www.troubleshooters.com/linux/cdrw.htm

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.