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
Line 
1package MP3::Find::DB;
2
3use strict;
4use warnings;
5
6use base qw(MP3::Find::Base);
7use Carp;
8
9use DBI;
10use SQL::Abstract;
11
12use MP3::Find::Util qw(get_mp3_metadata);
13
14my $sql = SQL::Abstract->new;
15
16my @COLUMNS = (
17    [ mtime        => 'INTEGER' ],  # filesystem mtime, so we can do incremental updates
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
47my $DEFAULT_STATUS_CALLBACK = sub {
48    my ($action_code, $filename) = @_;
49    print STDERR "$action_code $filename\n";
50};
51
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;
60   
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    );
70   
71    # you can do things besides just searching the database
72   
73    # create another database
74    $finder->create({ db_file => 'my_mp3s.db' });
75   
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    });
87   
88    # and then blow it away
89    $finder->destroy_db('my_mp3s.db');
90
91=head1 REQUIRES
92
93L<DBI>, L<DBD::SQLite>, L<SQL::Abstract>
94
95=head1 DESCRIPTION
96
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.
100
101The database you use should have at least one table named C<mp3> with
102the following schema:
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
133    );
134
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
164=back
165
166=cut
167
168# get a database handle from named arguments
169sub _get_dbh {
170    my $args = shift;
171
172    # we got an explicit $dbh object
173    return $args->{dbh} if defined $args->{dbh};
174
175    # or a custom DSN
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    }
185   
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    }
196
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 
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
220database is added, updated, or deleted by the C<update> and
221C<sync> methods. The arguments passed to the callback are
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";
228    }
229
230To suppress any output, set C<status_callback> to an empty sub:
231
232    status_callback => sub {}
233
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
258=head2 create_db (DEPRECATED)
259
260    $finder->create_db($db_filename);
261
262Creates a SQLite database in the file named C<$db_filename>.
263
264=cut
265
266# TODO: extended table for ID3v2 data
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});
271    my $create = 'CREATE TABLE mp3 (' . join(',', map { "$$_[0] $$_[1]" } @COLUMNS) . ')';
272    $dbh->do($create);
273}
274
275=head2 update
276
277    my $count = $finder->update({
278        dsn   => 'dbi:SQLite:dbname=mp3.db',
279        files => \@filenames,
280        dirs  => \@dirs,
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
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
291=cut
292
293# this is update_db and update_files (from Matt Dietrich) rolled into one
294sub update {
295    my $self = shift;
296    my $args = shift;
297
298    my $dbh = _get_dbh($args) or croak "Please provide a DBI database handle, DSN, or SQLite database filename";
299
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   
363    # SQLite buggy driver
364    _sqlite_workaround($mtime_sth, $insert_sth, $update_sth);
365     
366    return $count;
367}
368
369=head2 update_db (DEPRECATED)
370
371    my $count = $finder->update_db($db_filename, \@dirs);
372
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
381sub update_db {
382    my $self = shift;
383    my $db_file = shift or croak "Need the name of the database to update";
384    my $dirs = shift;
385   
386    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
387   
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);
421            $status_callback->(A => $$mp3{FILENAME});
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});
426            $status_callback->(U => $$mp3{FILENAME});
427            $count++;
428        }
429    }
430   
431    # SQLite buggy driver
432    _sqlite_workaround($mtime_sth, $insert_sth, $update_sth);
433   
434    return $count;
435}
436
437=head2 sync
438
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
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   
470    # SQLite buggy driver
471    _sqlite_workaround($select_sth, $delete_sth);
472   
473    return $count;   
474}
475
476=head2 sync_db (DEPRECATED)
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
486sub sync_db {
487    my $self = shift;
488    my $db_file = shift or croak "Need the name of the databse to sync";
489
490    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
491
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);
503            $status_callback->(D => $filename);
504            $count++;
505        }
506    }
507   
508    return $count;   
509}
510
511=head2 destroy_db
512
513    $finder->destroy_db($db_filename);
514
515Permanantly removes the database.
516
517=cut
518
519# TODO: use DSNs instead of SQLite db names (this might get funky)
520sub destroy_db {
521    my $self = shift;
522    my $db_file = shift or croak "Need the name of a database to destroy";
523    unlink $db_file;
524}
525
526
527sub search {
528    my $self = shift;
529    my ($query, $dirs, $sort, $options) = @_;
530   
531    croak 'Need a database name to search (set "db_file" in the call to find_mp3s)' unless $$options{db_file};
532   
533    my $dbh = _get_dbh($options);
534   
535    # use the 'LIKE' operator to ignore case
536    my $op = $$options{ignore_case} ? 'LIKE' : '=';
537   
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    }
544
545    my ($where, @bind) = $sql->where(
546        { map { $_ => { $op => $query->{$_} } } keys %$query },
547        ( @$sort ? [ map { uc } @$sort ] : () ),
548    );
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;
558    }
559   
560    return @results;
561}
562
563# module return
5641;
565
566=head1 TODO
567
568Store/search ID3v2 tags
569
570Driver classes to handle database dependent tasks?
571
572=head1 SEE ALSO
573
574L<MP3::Find>, L<MP3::Find::Filesystem>, L<mp3db>
575
576=head1 AUTHOR
577
578Peter Eichman <peichman@cpan.org>
579
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
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.