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.
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. |
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.
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.
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.
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.
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.
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.
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.
![]() |
Michael Schilliarbeitet 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. |