source: mp3-find/trunk/lib/MP3/Find/DB.pm @ 34

Last change on this file since 34 was 34, checked in by peter, 18 years ago
  • added tests for the new database methods
  • updated docs for DB.pm
  • added new test file to the MANIFEST
  • updated public changelog (Changes)
File size: 15.9 KB
RevLine 
[1]1package MP3::Find::DB;
2
3use strict;
4use warnings;
5
6use base qw(MP3::Find::Base);
[10]7use Carp;
[1]8
9use DBI;
10use SQL::Abstract;
11
[29]12use MP3::Find::Util qw(get_mp3_metadata);
13
[1]14my $sql = SQL::Abstract->new;
15
[10]16my @COLUMNS = (
[32]17    [ mtime        => 'INTEGER' ],  # filesystem mtime, so we can do incremental updates
[10]18    [ FILENAME     => 'TEXT' ], 
19    [ TITLE        => 'TEXT' ], 
20    [ ARTIST       => 'TEXT' ], 
21    [ ALBUM        => 'TEXT' ],
22    [ YEAR         => 'INTEGER' ], 
23    [ COMMENT      => 'TEXT' ], 
24    [ GENRE        => 'TEXT' ], 
25    [ TRACKNUM     => 'INTEGER' ], 
26    [ VERSION      => 'NUMERIC' ],
27    [ LAYER        => 'INTEGER' ], 
28    [ STEREO       => 'TEXT' ],
29    [ VBR          => 'TEXT' ],
30    [ BITRATE      => 'INTEGER' ], 
31    [ FREQUENCY    => 'INTEGER' ], 
32    [ SIZE         => 'INTEGER' ], 
33    [ OFFSET       => 'INTEGER' ], 
34    [ SECS         => 'INTEGER' ], 
35    [ MM           => 'INTEGER' ],
36    [ SS           => 'INTEGER' ],
37    [ MS           => 'INTEGER' ], 
38    [ TIME         => 'TEXT' ],
39    [ COPYRIGHT    => 'TEXT' ], 
40    [ PADDING      => 'INTEGER' ], 
41    [ MODE         => 'INTEGER' ],
42    [ FRAMES       => 'INTEGER' ], 
43    [ FRAME_LENGTH => 'INTEGER' ], 
44    [ VBR_SCALE    => 'INTEGER' ],
45);
46
[19]47my $DEFAULT_STATUS_CALLBACK = sub {
48    my ($action_code, $filename) = @_;
49    print STDERR "$action_code $filename\n";
50};
[10]51
[30]52=head1 NAME
53
54MP3::Find::DB - SQLite database backend to MP3::Find
55
56=head1 SYNOPSIS
57
58    use MP3::Find::DB;
59    my $finder = MP3::Find::DB->new;
[1]60   
[30]61    my @mp3s = $finder->find_mp3s(
62        dir => '/home/peter/music',
63        query => {
64            artist => 'ilyaimy',
65            album  => 'myxomatosis',
66        },
67        ignore_case => 1,
68        db_file => 'mp3.db',
69    );
[1]70   
[30]71    # you can do things besides just searching the database
[10]72   
[30]73    # create another database
[34]74    $finder->create({ db_file => 'my_mp3s.db' });
[1]75   
[34]76    # update the database by searching the filesystem
77    $finder->update({
78        db_file => 'my_mp3s.db',
79        dirs => ['/home/peter/mp3', '/home/peter/cds'],
80    });
81
82    # or just update specific mp3s
83    $finder->update({
84        db_file => 'my_mp3s.db',
85        files => \@filenames,
86    });
[30]87   
88    # and then blow it away
89    $finder->destroy_db('my_mp3s.db');
[1]90
[30]91=head1 REQUIRES
92
93L<DBI>, L<DBD::SQLite>, L<SQL::Abstract>
94
95=head1 DESCRIPTION
96
[34]97This is the database backend for L<MP3::Find>. The easiest way to
98use it is with a SQLite database, but you can also pass in your own
99DSN or database handle.
[30]100
[34]101The database you use should have at least one table named C<mp3> with
102the following schema:
[30]103
104    CREATE TABLE mp3 (
105        mtime         INTEGER,
106        FILENAME      TEXT,
107        TITLE         TEXT,
108        ARTIST        TEXT,
109        ALBUM         TEXT,
110        YEAR          INTEGER,
111        COMMENT       TEXT,
112        GENRE         TEXT,
113        TRACKNUM      INTEGER,
114        VERSION       NUMERIC,
115        LAYER         INTEGER,
116        STEREO        TEXT,
117        VBR           TEXT,
118        BITRATE       INTEGER,
119        FREQUENCY     INTEGER,
120        SIZE          INTEGER,
121        OFFSET        INTEGER,
122        SECS          INTEGER,
123        MM            INTEGER,
124        SS            INTEGER,
125        MS            INTEGER,
126        TIME          TEXT,
127        COPYRIGHT     TEXT,
128        PADDING       INTEGER,
129        MODE          INTEGER,
130        FRAMES        INTEGER,
131        FRAME_LENGTH  INTEGER,
132        VBR_SCALE     INTEGER
[1]133    );
[30]134
[34]135B<Note:> I'm still working out some kinks in here, so this backend
136is currently not as stable as the Filesystem backend. Expect API
137fluctuations for now.
138
139B<Deprecated Methods:> C<create_db>, C<update_db>, and C<sync_db>
140have been deprectaed in this release, and will be removed in a future
141release. PLease switch to the new methods C<create>, C<update>, and
142C<sync>.
143
144=head2 Special Options
145
146When using this backend, provide one of the following additional options
147to the C<search> method:
148
149=over
150
151=item C<dsn>, C<username>, C<password>
152
153A custom DSN and (optional) username and password. This gets passed
154to the C<connect> method of L<DBI>.
155
156=item C<dbh>
157
158An already created L<DBI> database handle object.
159
160=item C<db_file>
161
162The name of the SQLite database file to use.
163
[30]164=back
165
[33]166=cut
167
[34]168# get a database handle from named arguments
[33]169sub _get_dbh {
170    my $args = shift;
[34]171
172    # we got an explicit $dbh object
[33]173    return $args->{dbh} if defined $args->{dbh};
[34]174
175    # or a custom DSN
[33]176    if (defined $args->{dsn}) {
177        my $dbh = DBI->connect(
178            $args->{dsn}, 
179            $args->{username}, 
180            $args->{password}, 
181            { RaiseError => 1 },
182        );
183        return $dbh;
184    }
[34]185   
[33]186    # default to a SQLite database
187    if (defined $args->{db_file}) {
188        my $dbh = DBI->connect(
189            "dbi:SQLite:dbname=$$args{db_file}",
190            '',
191            '',
192            { RaiseError => 1 },
193        );
194        return $dbh;
195    }
[34]196
[33]197    return;
198}
199
200sub _sqlite_workaround {
201    # as a workaround for the 'closing dbh with active staement handles warning
202    # (see http://rt.cpan.org/Ticket/Display.html?id=9643#txn-120724)
203    foreach (@_) {
204        $_->{RaiseError} = 0;  # don't die on error
205        $_->{PrintError} = 0;  # ...and don't even say anything
206        $_->{Active} = 1;
207        $_->finish;
208    }
209}
210 
[30]211=head1 METHODS
212
213=head2 new
214
215    my $finder = MP3::Find::DB->new(
216        status_callback => \&callback,
217    );
218
219The C<status_callback> gets called each time an entry in the
[34]220database is added, updated, or deleted by the C<update> and
221C<sync> methods. The arguments passed to the callback are
[30]222a status code (A, U, or D) and the filename for that entry.
223The default callback just prints these to C<STDERR>:
224
225    sub default_callback {
226        my ($status_code, $filename) = @_;
227        print STDERR "$status_code $filename\n";
[1]228    }
229
[30]230To suppress any output, set C<status_callback> to an empty sub:
231
232    status_callback => sub {}
233
[31]234=head2 create
235
236    $finder->create({
237        dsn => 'dbi:SQLite:dbname=mp3.db',
238        dbh => $dbh,
239        db_file => 'mp3.db',
240    });
241
242Creates a new table for storing mp3 info in the database. You can provide
243either a DSN (plus username and password, if needed), an already created
244database handle, or just the name of an SQLite database file.
245
246=cut
247
248sub create {
249    my $self = shift;
250    my $args = shift;
251
252    my $dbh = _get_dbh($args) or croak "Please provide a DBI database handle, DSN, or SQLite database filename";
253   
254    my $create = 'CREATE TABLE mp3 (' . join(',', map { "$$_[0] $$_[1]" } @COLUMNS) . ')';
255    $dbh->do($create);
256}
257
[33]258=head2 create_db (DEPRECATED)
[30]259
260    $finder->create_db($db_filename);
261
[34]262Creates a SQLite database in the file named C<$db_filename>.
[30]263
264=cut
265
[29]266# TODO: extended table for ID3v2 data
[10]267sub create_db {
268    my $self = shift;
269    my $db_file = shift or croak "Need a name for the database I'm about to create";
270    my $dbh = DBI->connect("dbi:SQLite:dbname=$db_file", '', '', {RaiseError => 1});
[31]271    my $create = 'CREATE TABLE mp3 (' . join(',', map { "$$_[0] $$_[1]" } @COLUMNS) . ')';
272    $dbh->do($create);
[10]273}
274
[29]275=head2 update
276
277    my $count = $finder->update({
278        dsn   => 'dbi:SQLite:dbname=mp3.db',
279        files => \@filenames,
[30]280        dirs  => \@dirs,
[29]281    });
282
283Compares the files in the C<files> list plus any MP3s found by searching
284in C<dirs> to their records in the database pointed to by C<dsn>. If the
285files found have been updated since they have been recorded in the database
286(or if they are not in the database), they are updated (or added).
287
[33]288Instead of a C<dsn>, you can also provide either an already created
289database handle as C<dbh> or the filename of an SQLite database as C<db_file>.
290
[29]291=cut
292
[30]293# this is update_db and update_files (from Matt Dietrich) rolled into one
[29]294sub update {
295    my $self = shift;
296    my $args = shift;
297
[31]298    my $dbh = _get_dbh($args) or croak "Please provide a DBI database handle, DSN, or SQLite database filename";
299
[29]300    my @dirs  = $args->{dirs}
301                    ? ref $args->{dirs} eq 'ARRAY'
302                        ? @{ $args->{dirs} }
303                        : ($args->{dirs})
304                    : ();
305
306    my @files  = $args->{files}
307                    ? ref $args->{files} eq 'ARRAY' 
308                        ? @{ $args->{files} }
309                        : ($args->{files})
310                    : ();
311   
312    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
313
314    my $mtime_sth = $dbh->prepare('SELECT mtime FROM mp3 WHERE FILENAME = ?');
315    my $insert_sth = $dbh->prepare(
316        'INSERT INTO mp3 (' . 
317            join(',', map { $$_[0] } @COLUMNS) .
318        ') VALUES (' .
319            join(',', map { '?' } @COLUMNS) .
320        ')'
321    );
322    my $update_sth = $dbh->prepare(
323        'UPDATE mp3 SET ' . 
324            join(',', map { "$$_[0] = ?" } @COLUMNS) . 
325        ' WHERE FILENAME = ?'
326    );
327   
328    my $count = 0;  # the number of records added or updated
329    my @mp3s;       # metadata for mp3s found
330
331    # look for mp3s using the filesystem backend if we have dirs to search in
332    if (@dirs) {
333        require MP3::Find::Filesystem;
334        my $finder = MP3::Find::Filesystem->new;
335        unshift @mp3s, $finder->find_mp3s(dir => \@dirs, no_format => 1);
336    }
337
338    # get the metadata on specific files
339    unshift @mp3s, map { get_mp3_metadata({ filename => $_ }) } @files;
340
341    # check each file against its record in the database
342    for my $mp3 (@mp3s) {       
343        # see if the file has been modified since it was first put into the db
344        $mp3->{mtime} = (stat($mp3->{FILENAME}))[9];
345        $mtime_sth->execute($mp3->{FILENAME});
346        my $records = $mtime_sth->fetchall_arrayref;
347       
348        warn "Multiple records for $$mp3{FILENAME}\n" if @$records > 1;
349       
350        if (@$records == 0) {
351            # we are adding a record
352            $insert_sth->execute(map { $mp3->{$$_[0]} } @COLUMNS);
353            $status_callback->(A => $$mp3{FILENAME});
354            $count++;
355        } elsif ($mp3->{mtime} > $$records[0][0]) {
356            # the mp3 file is newer than its record
357            $update_sth->execute((map { $mp3->{$$_[0]} } @COLUMNS), $mp3->{FILENAME});
358            $status_callback->(U => $$mp3{FILENAME});
359            $count++;
360        }
361    }
362   
[33]363    # SQLite buggy driver
364    _sqlite_workaround($mtime_sth, $insert_sth, $update_sth);
365     
[29]366    return $count;
367}
368
[33]369=head2 update_db (DEPRECATED)
[29]370
[30]371    my $count = $finder->update_db($db_filename, \@dirs);
[29]372
[30]373Searches for all mp3 files in the directories named by C<@dirs>
374using L<MP3::Find::Filesystem>, and adds or updates the ID3 info
375from those files to the database. If a file already has a record
376in the database, then it will only be updated if it has been modified
377since the last time C<update_db> was run.
378
379=cut
380
[10]381sub update_db {
382    my $self = shift;
[19]383    my $db_file = shift or croak "Need the name of the database to update";
[10]384    my $dirs = shift;
385   
[19]386    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
387   
[10]388    my @dirs = ref $dirs eq 'ARRAY' ? @$dirs : ($dirs);
389   
390    my $dbh = DBI->connect("dbi:SQLite:dbname=$db_file", '', '', {RaiseError => 1});
391    my $mtime_sth = $dbh->prepare('SELECT mtime FROM mp3 WHERE FILENAME = ?');
392    my $insert_sth = $dbh->prepare(
393        'INSERT INTO mp3 (' . 
394            join(',', map { $$_[0] } @COLUMNS) .
395        ') VALUES (' .
396            join(',', map { '?' } @COLUMNS) .
397        ')'
398    );
399    my $update_sth = $dbh->prepare(
400        'UPDATE mp3 SET ' . 
401            join(',', map { "$$_[0] = ?" } @COLUMNS) . 
402        ' WHERE FILENAME = ?'
403    );
404   
405    # the number of records added or updated
406    my $count = 0;
407   
408    # look for mp3s using the filesystem backend
409    require MP3::Find::Filesystem;
410    my $finder = MP3::Find::Filesystem->new;
411    for my $mp3 ($finder->find_mp3s(dir => \@dirs, no_format => 1)) {
412        # see if the file has been modified since it was first put into the db
413        $mp3->{mtime} = (stat($mp3->{FILENAME}))[9];
414        $mtime_sth->execute($mp3->{FILENAME});
415        my $records = $mtime_sth->fetchall_arrayref;
416       
417        warn "Multiple records for $$mp3{FILENAME}\n" if @$records > 1;
418       
419        if (@$records == 0) {
420            $insert_sth->execute(map { $mp3->{$$_[0]} } @COLUMNS);
[19]421            $status_callback->(A => $$mp3{FILENAME});
[10]422            $count++;
423        } elsif ($mp3->{mtime} > $$records[0][0]) {
424            # the mp3 file is newer than its record
425            $update_sth->execute((map { $mp3->{$$_[0]} } @COLUMNS), $mp3->{FILENAME});
[19]426            $status_callback->(U => $$mp3{FILENAME});
[10]427            $count++;
428        }
429    }
430   
[33]431    # SQLite buggy driver
432    _sqlite_workaround($mtime_sth, $insert_sth, $update_sth);
[10]433   
434    return $count;
435}
436
[32]437=head2 sync
438
[33]439    my $count = $finder->sync({ dsn => $DSN });
440
441Removes entries from the database that refer to files that no longer
442exist in the filesystem. Returns the count of how many records were
443removed.
444
[32]445=cut
446
447sub sync {
448    my $self = shift;
449    my $args = shift;
450
451    my $dbh = _get_dbh($args) or croak "Please provide a DBI database handle, DSN, or SQLite database filename";
452   
453    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
454
455    my $select_sth = $dbh->prepare('SELECT FILENAME FROM mp3');
456    my $delete_sth = $dbh->prepare('DELETE FROM mp3 WHERE FILENAME = ?');
457   
458    # the number of records removed
459    my $count = 0;
460   
461    $select_sth->execute;
462    while (my ($filename) = $select_sth->fetchrow_array) {
463        unless (-e $filename) {
464            $delete_sth->execute($filename);
465            $status_callback->(D => $filename);
466            $count++;
467        }
468    }
469   
[33]470    # SQLite buggy driver
471    _sqlite_workaround($select_sth, $delete_sth);
472   
[32]473    return $count;   
474}
475
[33]476=head2 sync_db (DEPRECATED)
[30]477
478    my $count = $finder->sync_db($db_filename);
479
480Removes entries from the database that refer to files that no longer
481exist in the filesystem. Returns the count of how many records were
482removed.
483
484=cut
485
[13]486sub sync_db {
487    my $self = shift;
488    my $db_file = shift or croak "Need the name of the databse to sync";
[19]489
490    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
491
[13]492    my $dbh = DBI->connect("dbi:SQLite:dbname=$db_file", '', '', {RaiseError => 1});
493    my $select_sth = $dbh->prepare('SELECT FILENAME FROM mp3');
494    my $delete_sth = $dbh->prepare('DELETE FROM mp3 WHERE FILENAME = ?');
495   
496    # the number of records removed
497    my $count = 0;
498   
499    $select_sth->execute;
500    while (my ($filename) = $select_sth->fetchrow_array) {
501        unless (-e $filename) {
502            $delete_sth->execute($filename);
[19]503            $status_callback->(D => $filename);
[13]504            $count++;
505        }
506    }
507   
508    return $count;   
509}
510
[30]511=head2 destroy_db
512
513    $finder->destroy_db($db_filename);
514
515Permanantly removes the database.
516
517=cut
518
[29]519# TODO: use DSNs instead of SQLite db names (this might get funky)
[10]520sub destroy_db {
521    my $self = shift;
[33]522    my $db_file = shift or croak "Need the name of a database to destroy";
[10]523    unlink $db_file;
524}
525
[3]526
[30]527sub search {
528    my $self = shift;
529    my ($query, $dirs, $sort, $options) = @_;
[3]530   
[30]531    croak 'Need a database name to search (set "db_file" in the call to find_mp3s)' unless $$options{db_file};
[10]532   
[33]533    my $dbh = _get_dbh($options);
[10]534   
[30]535    # use the 'LIKE' operator to ignore case
536    my $op = $$options{ignore_case} ? 'LIKE' : '=';
[10]537   
[30]538    # add the SQL '%' wildcard to match substrings
539    unless ($$options{exact_match}) {
540        for my $value (values %$query) {
541            $value = [ map { "%$_%" } @$value ];
542        }
543    }
[3]544
[30]545    my ($where, @bind) = $sql->where(
546        { map { $_ => { $op => $query->{$_} } } keys %$query },
547        ( @$sort ? [ map { uc } @$sort ] : () ),
[3]548    );
[30]549   
550    my $select = "SELECT * FROM mp3 $where";
551   
552    my $sth = $dbh->prepare($select);
553    $sth->execute(@bind);
554   
555    my @results;
556    while (my $row = $sth->fetchrow_hashref) {
557        push @results, $row;
[20]558    }
[30]559   
560    return @results;
561}
[20]562
[30]563# module return
5641;
[20]565
[3]566=head1 TODO
567
[34]568Store/search ID3v2 tags
[3]569
[34]570Driver classes to handle database dependent tasks?
[10]571
[3]572=head1 SEE ALSO
573
[10]574L<MP3::Find>, L<MP3::Find::Filesystem>, L<mp3db>
[3]575
576=head1 AUTHOR
577
578Peter Eichman <peichman@cpan.org>
579
[29]580=head1 THANKS
581
582Thanks to Matt Dietrich for suggesting having an option to just
583update specific files instead of doing a (longer) full search.
584
[3]585=head1 COPYRIGHT AND LICENSE
586
587Copyright (c) 2006 by Peter Eichman. All rights reserved.
588
589This program is free software; you can redistribute it and/or
590modify it under the same terms as Perl itself.
591
592=cut
Note: See TracBrowser for help on using the repository browser.