Ab in den Keller! (Linux-Magazin, März 2003)

Mit den Perl-Modulen Class::DBI und CDDB_get wandern CD-Daten mit Songinformationen ohne Tipparbeit in eine Datenbank.

Mit meiner CD-Sammlung führe ich große Pläne im Schilde: In naher Zukunft werden alle zu MP3s gerippt, auf einen Server gestellt und dann nur noch mittels Geräten wie dem Slinky SliMP3 [2] auf der Stereo-Anlage abgespielt.

Bevor's an Einmotten der Silberscheiben geht, hätte ich deren Titel-, Interpreten- und Songinformationen jedoch gerne in einer Datenbank, die ich dann, wenn die Scheiben im Keller sind, mit Fragen wie ``In welchem Schuhkarton liegt nochmal die Foo-Figher-CD, auf der der Song 'Generator' ist, dessen MP3 ich gerade versehentlich gelöscht habe?'' traktieren kann.

Da sich das manuelle Abtippen dieser Informationen bis ca. Mai 2007 hingezogen hätte, beschloss ich, ein kleines Skript zu schreiben, das eine gerade im Laufwerk liegende CD einliest, den freien CDDB-Server auf http://freedb.freedb.org kontaktiert, und die dort gefundenen Daten sofort in meiner eigenen Datenbank ablegt.

Und, in letzter Zeit hat das neue Datenbank-Modul Class::DBI (Original von Michael Schwern, heute unter der Fuchtel von Tony Bowden, siehe [3]) ziemlich Furore gemacht: Es entkoppelt den datenbanküblichen SQL-Wirrwarr endlich sauber von den Perl-Applikationen. Also spannte ich es kurzerhand für die Arbeit ein.

Datenstruktur

Heute nutzen wir zwei Tabellen:

cds
Jede Zeile führt eine CD.

tracks
Jede Zeile führt einen Track einer in cds gelisteten CD. Den Bezug zur cds-Tabelle stellt jeweils die Spalte cd in tracks her, die den Primärschlüssel des entsprechenden cds-Eintrags enthält.

Abbildung 1 zeigt das Schema.

Abbildung 1: So liegen die CD-Daten in den Tabellen der Datenbank. Weitere Tabellen können in Zukunft zusätzliche Informationen aufnehmen, zum Beispiel in welchem Keller eine CD gerade lagert.

Auch mit moderner Weltraumtechnologie wie Class::DBI muss man die Datenbank immer noch selbst erzeugen und mit den benötigten Tabellen initialisieren: Entweder mit einem Perl-Skript, wie schonmal in [4] gezeigt, oder aber einfach als Shell-Skript wie in Listing sql.sh. Damit entstehen die beiden leeren Tabellen, die der mysql-Dump in Abbildung 2 illustriert.

Listing 1: sql.sh

    01 
    02 DB=speicher
    03 
    04 mysqladmin --user=root create $DB
    05 
    06 mysql --user=root --database=$DB <<EOT
    07   CREATE TABLE cds (
    08     id       INT AUTO_INCREMENT,
    09     cddbid   VARCHAR(10),
    10     title    VARCHAR(40),
    11     artist   VARCHAR(40),
    12     category VARCHAR(40),
    13     PRIMARY KEY (id)
    14   )
    15 EOT
    16 
    17 mysql --user=root --database=$DB <<EOT
    18   CREATE TABLE tracks (
    19     id    INT AUTO_INCREMENT,
    20     cd    INT,
    21     track INT,
    22     song  VARCHAR(40),
    23     PRIMARY KEY (id)
    24   )
    25 EOT

Abbildung 2: MySQL hat die Tabellen gespeichert.

Class::DBI

Statt weiter SQL zu sprechen, ziehen wir jetzt aber einfach die Abstraktionsschnittstelle Class::DBI ein. Die Datenbank speicher und beide Tabellen cds und tracks werden durch Klassen repräsentiert: Wie in Abbildung 3 dargestellt, stammt das die Datenbank speicher repräsentierende CD::Collection::DBI direkt von Class::DBI ab. In Listing CD.pm wird die Klasse in den Zeilen 10 bis 15 definiert. use base is neu für die Angabe der Basisklasse, ein eleganter Ersatz für den etwas Geek-haften @ISA-Array.

Die Klasse zeigt allerdings nur ein Stück Initialisierungscode, ohne wirklich Methoden oder Daten zu definieren -- alles wird von Class::DBI und seinem Klüngel ererbt. Der Aufruf von set_db legt die verwendete Datenbank mit Benutzername (root) und Passwort (leer) fest.

Abbildung 3: Die Vererbungshierarchie der verwendeten Klassen

Weiter unten folgen dann die Klassen package CD::Collection::Slot und CD::Collection::Track, die die Tabellen cds und tracks logisch repräsentieren und, wie Abbildung 3 zeigt, beide von der Datenbank-Abstraktionsklasse CD::Collection::DBI abgeleitet sind.

Um Class::DBI Genüge zu tun, definieren auch sie nur etwas Initialisierungscode, der die repräsentierten Datenbanktabellen festlegt und deren Spaltennamen an Class::DBI weitergibt.

Zeile 26 definiert die Relation einer CD zu ihren Tracks: Ein cds-Eintrag ist üblicherweise mit mehreren Einträgen in der Tabelle tracks verknüpft. Die has_many-Methode bereitet die Klasse CD::Collection::Slot darauf vor, indem sie Class::DBI mitteilt, dass die Spaltenwerte für cd in CD::Collection::Track auf den Primärschlüssel von CD::Collection::Slot (implizit id, da es die erste Spalte in Zeile 24 war) verweisen. Die Reihenfolge der Tracks ist über deren Position auf der CD definiert, Zeile 28 legt dies über den Spaltennamen track (in der Tabelle tracks) fest. Nach dieser Definition ist tracks() anschließend der Name einer neuen CD::Collection::Slot-Methode, die zu einem CD::Collection::Slot-Objekt alle CD::Collection::Track-Objekte liefert. Weiter erzeugt Class::DBI hinter den Kulissen eine Methode add_to_tracks(), mit der man einfach neue Tracks zu einer CD einfügen kann.

Das war's -- jeder, der CD.pm einbindet, kann abstrakt auf die Tabellen zugreifen: Daten einfügen, abfragen, auffrischen, löschen -- alles ohne eine Zeile SQL.

Passieren Fehler, werfen Class::DBI-Funktionen in der Standardeinstellung einfach Exceptions, die, falls sie nicht abgefangen werden, das Programm beenden. Es ist möglich, dieses Verhalten zu verändern, aber für das heute vorgestellte einfache Skript soll's genügen.

Listing 2: CD.pm

    01 ###########################################
    02 package CD;
    03 ###########################################
    04 # Mike Schilli, 2002 (m@perlmeister.com)
    05 ###########################################
    06 use warnings;
    07 use strict;
    08 
    09 ###########################################
    10 package CD::Collection::DBI;
    11 ###########################################
    12 use base q(Class::DBI);
    13 CD::Collection::DBI->set_db('Main', 
    14                     'dbi:mysql:speicher', 
    15                     'root', '');
    16 
    17 ###########################################
    18 package CD::Collection::Slot;
    19 ###########################################
    20 use base q(CD::Collection::DBI);
    21 
    22 CD::Collection::Slot->table('cds');
    23 CD::Collection::Slot->columns(
    24     All => qw(id cddbid title 
    25               artist category));
    26 CD::Collection::Slot->has_many('tracks', 
    27     'CD::Collection::Track' => 'cd', 
    28     { sort => 'track' });
    29 
    30 ###########################################
    31 package CD::Collection::Track;
    32 ###########################################
    33 use base q(CD::Collection::DBI);
    34 
    35 CD::Collection::Track->table('tracks');
    36 CD::Collection::Track->columns(
    37     All => qw(id cd track song)
    38 );
    39 
    40 1;

Los geht's

Als Anwendung zeigt Listing addcd ein Skript, das die gerade im Laufwerk sitzende CD ausliest, deren Textdaten über die CDDB-Datenbank vom Internet holt und sie daraufhin in die Datenbank einspeichert.

Der Teil mit der CD und der CDDB-Datenbank ist trivial: Zeile 10 zieht das praktische CDDB_get-Modul von Armin Obersteiner herein und importiert die Funktion get_cddb.

Zeile 11 zieht Log::Log4perl im Easy-Modus herein und die Zeilen 13 bis 15 konfigurieren es für die nachfolgenden Ausgaben -- ohne diesen neuen Helfer schreibe ich kein Skript mehr.

Der get_cddb()-Aufruf in Zeile 17 nimmt eine Hash-Referenz auf einige Parameterwerte entgegen: CDDB-Server, Port, wo die CD im Rechner steckt, und ob das Programm die Erkennung interaktiv bestätigen soll: CDDB findet nämlich manchmal mehrere passende CDs, mit input => 1 lässt es den Benutzer die Richtige interaktiv auf der Kommandozeile bestätigen. Leider tut es das in jedem Fall, auch wenn nur ein Eintrag existiert. Zurück kommt im Erfolgsfall eine Liste mit Key/Value-Parametern oder undef im Fehlerfall. Dieses nicht-optimale Interface kompensiert das Skript mit einem Array @cddata, der die Daten von CDDB_get aufschnappt. Ist das erste Element undef, ging etwas schief (Verbindung zum Server kaputt, CD nicht lesbar, kein passender Eintrag gefunden), und Zeile 26 bricht das Skript mit einer Fehlermeldung ab.

Listing 3: addcd

    01 #!/usr/bin/perl
    02 ###########################################
    03 # addcd -- Add a CD to the database
    04 # Mike Schilli, 2002 (m@perlmeister.com)
    05 ###########################################
    06 use warnings;
    07 use strict;
    08 
    09 use CD;
    10 use CDDB_get qw(get_cddb);
    11 use Log::Log4perl qw(:easy);
    12 
    13 Log::Log4perl->easy_init({
    14     level  => $DEBUG,
    15     layout => "%m%n"});
    16 
    17 my @cddat=get_cddb({
    18     CDDB_HOST => "freedb.freedb.org",
    19     CDDB_PORT => 8880,
    20     CDDB_MODE => "cddb",
    21     CD_DEVICE => "/dev/cdrom",
    22     input     => 1,          # interactive
    23 });
    24 
    25 unless ($cddat[0]) {
    26     LOGDIE "No cddb entry found";
    27 }
    28 
    29 my %cddat = @cddat;
    30 
    31 if(CD::Collection::Slot->search(
    32       artist   => $cddat{artist},
    33       title    => $cddat{title},
    34    )) {
    35     LOGDIE "$cddat{artist}/$cddat{title}" .
    36          " already in DB - exiting.";
    37 }
    38 
    39 INFO "Adding $cddat{artist}/$cddat{title}";
    40 
    41 my $cd = CD::Collection::Slot->create(
    42     { cddbid   => $cddat{id},
    43       artist   => $cddat{artist},
    44       title    => $cddat{title},
    45       category => $cddat{cat},
    46     }
    47 );
    48 
    49 my $n=1;
    50 
    51 foreach my $song ( @{$cddat{track}} ) {
    52     INFO "Adding track $n: $song";
    53 
    54     $cd->add_to_tracks(
    55         { track => $n,
    56           song  => $song,
    57         });
    58 
    59     $n++;
    60 }

Insgesamt liefert CDDB_get Werte für die folgenden Schlüssel in der Rückgabeliste:

title
Titel der CD

artist
Interpret

cat
Kategorie (rock, folk, rap ...)

tracks
Referenz auf einen Array von Songtiteln der CD

id
Den von der CD eingelesenen Hexwert, über den in der CDDB gesucht wurde

Zeile 31 feuert die search-Methode der die Tabelle cds abstrahierenden CD::Collection::Slot-Klasse ab und sucht mit

    title  => $cddat{title},
    artist => $cddat{artist},

nach einer CD in der Datenbank, deren Interpret und Titel dem der aktuell untersuchten CD entspricht. Findet sich ein Eintrag, wurde die CD offensichtlich schon einmal eingescannt und Zeile 35 bricht mit einer Nachricht an den Benutzer ab.

Die create-Methode von CD::Collection::Slot in Zeile 41 nimmt eine Hashreferenz auf Key/Value-Paare des neuen Datenbankeintrags entgegen. Unter anderem geht der Wert für $cddat{id} in der Spalte cddbid in die Datenbank.

Zeile 51 iteriert über alle in @{$cddat{track}} enthaltenen Songtitel, numeriert diese in $n von 1 an aufsteigend durch und ruft für jeden Track $cd->add_to_tracks(). Diese Methode interpretiert Class::DBI wegen der vorher in CD.pm (Zeile 26) definierten has_many-Beziehung in schlauer Weise und linkt jeden neuen Track in tracks sofort mit dem Primärschlüssel der entsprechenden CD in cds.

Und ... Action!

Die benötigten Module DBI, DBD::mysql, Class::DBI und auch CDDB_get gibt's, wie immer, auf dem CPAN und lassen sich leicht mittels einer CPAN-Shell installieren.

Dann schnell eine CD ins Laufwerk gelegt und addcd aufgerufen:

    $ addcd
    This CD could be:
    1: Haindling / Karussell
    0: none of the above
    
    Choose: 1
    Adding Haindling/Karussell
    Adding track 1: Karussell
    ...
    Adding track 14: Bellaria - Solange wir leben

Fertig! Um herauszufinden, was schon alles in der Datenbank steht, eignet sich ein simples Skript wie das in Listing dumpdb. Es hangelt sich durch die CD-Tabelle CD::Collection::Slot, findet mit tracks() die dazugehörigen Einträge in CD::Collection::Tracks, und gibt alles auf STDOUT aus:

    Haindling: Karussell
         1 Karussell
        ...
        14 Bellaria - Solange wir leben
    Die Toten Hosen: Auswaertsspiel
         1 Du Lebst Nur Einmal (Vorher)
        ...
        18 Kein Alkohol (Ist Auch Keine Loesung)
    ...

Listing 4: dumpdb

    01 #!/usr/bin/perl
    02 ###########################################
    03 # dumpdb -- Dump DB 'speicher' content
    04 # Mike Schilli, 2002 (m@perlmeister.com)
    05 ###########################################
    06 use warnings;
    07 use strict;
    08 
    09 use CD;
    10 
    11 for my $cd (
    12     CD::Collection::Slot->retrieve_all()) {
    13 
    14     print $cd->artist(), ": ", 
    15           $cd->title(), "\n";
    16 
    17     for my $track ($cd->tracks()) {
    18         printf "    %2d: %s\n", 
    19                $track->track(), 
    20                $track->song();
    21     }
    22 }

Das war's bereit's -- einfach und übersichtlich, nicht? Ich sage eine große Zukunft für Class::DBI voraus. Bis zum nächsten Mal!

Infos

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

[2]
``Slinky SliMP3: An Affordable MP3 Stereo Component'', Nathan Torkington, 2002, http://www.onlamp.com/pub/a/onlamp/2002/12/11/slimp3.html

[3]
``Class::DBI'', Tony Bowden, http://www.perl.com/pub/a/2002/11/27/classdbi.html

[4]
``Streichel-Zoo'', Michael Schilli, Linux-Magazin 06/2002, http://www.linux-magazin.de/Artikel/ausgabe/2002/06/perl-api/perl-api.html

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.