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

Last change on this file since 32 was 32, checked in by peter, 18 years ago
  • added 'sync' method to DB.pm that uses '_get_dbh' metthod of getting database handles
File size: 15.2 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('my_mp3s.db');
75   
76    # update the database from the filesystem
77    $finder->update_db('my_mp3s.db', ['/home/peter/mp3', '/home/peter/cds']);
78   
79    # and then blow it away
80    $finder->destroy_db('my_mp3s.db');
81
82=head1 REQUIRES
83
84L<DBI>, L<DBD::SQLite>, L<SQL::Abstract>
85
86=head1 DESCRIPTION
87
88This is the SQLite database backend for L<MP3::Find>.
89
90B<Note:> I'm still working out some kinks in here, so this backend
91is currently not as stable as the Filesystem backend.
92
93=head2 Special Options
94
95=over
96
97=item C<db_file>
98
99The name of the SQLite database file to use. Defaults to F<~/mp3.db>.
100
101The database should have at least one table named C<mp3> with the
102following 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
135=back
136
137=head1 METHODS
138
139=head2 new
140
141    my $finder = MP3::Find::DB->new(
142        status_callback => \&callback,
143    );
144
145The C<status_callback> gets called each time an entry in the
146database is added, updated, or deleted by the C<update_db> and
147C<sync_db> methods. The arguments passed to the callback are
148a status code (A, U, or D) and the filename for that entry.
149The default callback just prints these to C<STDERR>:
150
151    sub default_callback {
152        my ($status_code, $filename) = @_;
153        print STDERR "$status_code $filename\n";
154    }
155
156To suppress any output, set C<status_callback> to an empty sub:
157
158    status_callback => sub {}
159
160=head2 create
161
162    $finder->create({
163        dsn => 'dbi:SQLite:dbname=mp3.db',
164        dbh => $dbh,
165        db_file => 'mp3.db',
166    });
167
168Creates a new table for storing mp3 info in the database. You can provide
169either a DSN (plus username and password, if needed), an already created
170database handle, or just the name of an SQLite database file.
171
172=cut
173
174sub create {
175    my $self = shift;
176    my $args = shift;
177
178    my $dbh = _get_dbh($args) or croak "Please provide a DBI database handle, DSN, or SQLite database filename";
179   
180    my $create = 'CREATE TABLE mp3 (' . join(',', map { "$$_[0] $$_[1]" } @COLUMNS) . ')';
181    $dbh->do($create);
182}
183
184=head2 create_db
185
186    $finder->create_db($db_filename);
187
188Creates a SQLite database in the file named c<$db_filename>.
189
190=cut
191
192# TODO: convert to using DSNs instead of hardcoded SQLite connections
193# TODO: extended table for ID3v2 data
194sub create_db {
195    my $self = shift;
196    my $db_file = shift or croak "Need a name for the database I'm about to create";
197    my $dbh = DBI->connect("dbi:SQLite:dbname=$db_file", '', '', {RaiseError => 1});
198    my $create = 'CREATE TABLE mp3 (' . join(',', map { "$$_[0] $$_[1]" } @COLUMNS) . ')';
199    $dbh->do($create);
200}
201
202=head2 update
203
204    my $count = $finder->update({
205        dsn   => 'dbi:SQLite:dbname=mp3.db',
206        files => \@filenames,
207        dirs  => \@dirs,
208    });
209
210Compares the files in the C<files> list plus any MP3s found by searching
211in C<dirs> to their records in the database pointed to by C<dsn>. If the
212files found have been updated since they have been recorded in the database
213(or if they are not in the database), they are updated (or added).
214
215=cut
216
217sub _get_dbh {
218    my $args = shift;
219    return $args->{dbh} if defined $args->{dbh};
220    if (defined $args->{dsn}) {
221        my $dbh = DBI->connect(
222            $args->{dsn}, 
223            $args->{username}, 
224            $args->{password}, 
225            { RaiseError => 1 },
226        );
227        return $dbh;
228    }
229    # default to a SQLite database
230    if (defined $args->{db_file}) {
231        my $dbh = DBI->connect(
232            "dbi:SQLite:dbname=$$args{db_file}",
233            '',
234            '',
235            { RaiseError => 1 },
236        );
237        return $dbh;
238    }
239    return;
240}
241       
242# this is update_db and update_files (from Matt Dietrich) rolled into one
243sub update {
244    my $self = shift;
245    my $args = shift;
246
247    my $dbh = _get_dbh($args) or croak "Please provide a DBI database handle, DSN, or SQLite database filename";
248
249    my @dirs  = $args->{dirs}
250                    ? ref $args->{dirs} eq 'ARRAY'
251                        ? @{ $args->{dirs} }
252                        : ($args->{dirs})
253                    : ();
254
255    my @files  = $args->{files}
256                    ? ref $args->{files} eq 'ARRAY' 
257                        ? @{ $args->{files} }
258                        : ($args->{files})
259                    : ();
260   
261    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
262
263    my $mtime_sth = $dbh->prepare('SELECT mtime FROM mp3 WHERE FILENAME = ?');
264    my $insert_sth = $dbh->prepare(
265        'INSERT INTO mp3 (' . 
266            join(',', map { $$_[0] } @COLUMNS) .
267        ') VALUES (' .
268            join(',', map { '?' } @COLUMNS) .
269        ')'
270    );
271    my $update_sth = $dbh->prepare(
272        'UPDATE mp3 SET ' . 
273            join(',', map { "$$_[0] = ?" } @COLUMNS) . 
274        ' WHERE FILENAME = ?'
275    );
276   
277    my $count = 0;  # the number of records added or updated
278    my @mp3s;       # metadata for mp3s found
279
280    # look for mp3s using the filesystem backend if we have dirs to search in
281    if (@dirs) {
282        require MP3::Find::Filesystem;
283        my $finder = MP3::Find::Filesystem->new;
284        unshift @mp3s, $finder->find_mp3s(dir => \@dirs, no_format => 1);
285    }
286
287    # get the metadata on specific files
288    unshift @mp3s, map { get_mp3_metadata({ filename => $_ }) } @files;
289
290    # check each file against its record in the database
291    for my $mp3 (@mp3s) {       
292        # see if the file has been modified since it was first put into the db
293        $mp3->{mtime} = (stat($mp3->{FILENAME}))[9];
294        $mtime_sth->execute($mp3->{FILENAME});
295        my $records = $mtime_sth->fetchall_arrayref;
296       
297        warn "Multiple records for $$mp3{FILENAME}\n" if @$records > 1;
298       
299        if (@$records == 0) {
300            # we are adding a record
301            $insert_sth->execute(map { $mp3->{$$_[0]} } @COLUMNS);
302            $status_callback->(A => $$mp3{FILENAME});
303            $count++;
304        } elsif ($mp3->{mtime} > $$records[0][0]) {
305            # the mp3 file is newer than its record
306            $update_sth->execute((map { $mp3->{$$_[0]} } @COLUMNS), $mp3->{FILENAME});
307            $status_callback->(U => $$mp3{FILENAME});
308            $count++;
309        }
310    }
311   
312    # SQLite specific code:
313    # as a workaround for the 'closing dbh with active staement handles warning
314    # (see http://rt.cpan.org/Ticket/Display.html?id=9643#txn-120724)
315    foreach ($mtime_sth, $insert_sth, $update_sth) {
316        $_->{RaiseError} = 0;  # don't die on error
317        $_->{PrintError} = 0;  # ...and don't even say anything
318        $_->{Active} = 1;
319        $_->finish;
320    }
321   
322    return $count;
323}
324
325=head2 update_db
326
327    my $count = $finder->update_db($db_filename, \@dirs);
328
329Searches for all mp3 files in the directories named by C<@dirs>
330using L<MP3::Find::Filesystem>, and adds or updates the ID3 info
331from those files to the database. If a file already has a record
332in the database, then it will only be updated if it has been modified
333since the last time C<update_db> was run.
334
335=cut
336
337sub update_db {
338    my $self = shift;
339    my $db_file = shift or croak "Need the name of the database to update";
340    my $dirs = shift;
341   
342    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
343   
344    my @dirs = ref $dirs eq 'ARRAY' ? @$dirs : ($dirs);
345   
346    my $dbh = DBI->connect("dbi:SQLite:dbname=$db_file", '', '', {RaiseError => 1});
347    my $mtime_sth = $dbh->prepare('SELECT mtime FROM mp3 WHERE FILENAME = ?');
348    my $insert_sth = $dbh->prepare(
349        'INSERT INTO mp3 (' . 
350            join(',', map { $$_[0] } @COLUMNS) .
351        ') VALUES (' .
352            join(',', map { '?' } @COLUMNS) .
353        ')'
354    );
355    my $update_sth = $dbh->prepare(
356        'UPDATE mp3 SET ' . 
357            join(',', map { "$$_[0] = ?" } @COLUMNS) . 
358        ' WHERE FILENAME = ?'
359    );
360   
361    # the number of records added or updated
362    my $count = 0;
363   
364    # look for mp3s using the filesystem backend
365    require MP3::Find::Filesystem;
366    my $finder = MP3::Find::Filesystem->new;
367    for my $mp3 ($finder->find_mp3s(dir => \@dirs, no_format => 1)) {
368        # see if the file has been modified since it was first put into the db
369        $mp3->{mtime} = (stat($mp3->{FILENAME}))[9];
370        $mtime_sth->execute($mp3->{FILENAME});
371        my $records = $mtime_sth->fetchall_arrayref;
372       
373        warn "Multiple records for $$mp3{FILENAME}\n" if @$records > 1;
374       
375        if (@$records == 0) {
376            $insert_sth->execute(map { $mp3->{$$_[0]} } @COLUMNS);
377            $status_callback->(A => $$mp3{FILENAME});
378            $count++;
379        } elsif ($mp3->{mtime} > $$records[0][0]) {
380            # the mp3 file is newer than its record
381            $update_sth->execute((map { $mp3->{$$_[0]} } @COLUMNS), $mp3->{FILENAME});
382            $status_callback->(U => $$mp3{FILENAME});
383            $count++;
384        }
385    }
386   
387    # as a workaround for the 'closing dbh with active staement handles warning
388    # (see http://rt.cpan.org/Ticket/Display.html?id=9643#txn-120724)
389    foreach ($mtime_sth, $insert_sth, $update_sth) {
390        $_->{RaiseError} = 0;  # don't die on error
391        $_->{PrintError} = 0;  # ...and don't even say anything
392        $_->{Active} = 1;
393        $_->finish;
394    }
395   
396    return $count;
397}
398
399=head2 sync
400
401=cut
402
403sub sync {
404    my $self = shift;
405    my $args = shift;
406
407    my $dbh = _get_dbh($args) or croak "Please provide a DBI database handle, DSN, or SQLite database filename";
408   
409    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
410
411    my $select_sth = $dbh->prepare('SELECT FILENAME FROM mp3');
412    my $delete_sth = $dbh->prepare('DELETE FROM mp3 WHERE FILENAME = ?');
413   
414    # the number of records removed
415    my $count = 0;
416   
417    $select_sth->execute;
418    while (my ($filename) = $select_sth->fetchrow_array) {
419        unless (-e $filename) {
420            $delete_sth->execute($filename);
421            $status_callback->(D => $filename);
422            $count++;
423        }
424    }
425   
426    return $count;   
427}
428
429=head2 sync_db
430
431    my $count = $finder->sync_db($db_filename);
432
433Removes entries from the database that refer to files that no longer
434exist in the filesystem. Returns the count of how many records were
435removed.
436
437=cut
438
439# TODO: use DSNs instead of SQLite db names
440sub sync_db {
441    my $self = shift;
442    my $db_file = shift or croak "Need the name of the databse to sync";
443
444    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
445
446    my $dbh = DBI->connect("dbi:SQLite:dbname=$db_file", '', '', {RaiseError => 1});
447    my $select_sth = $dbh->prepare('SELECT FILENAME FROM mp3');
448    my $delete_sth = $dbh->prepare('DELETE FROM mp3 WHERE FILENAME = ?');
449   
450    # the number of records removed
451    my $count = 0;
452   
453    $select_sth->execute;
454    while (my ($filename) = $select_sth->fetchrow_array) {
455        unless (-e $filename) {
456            $delete_sth->execute($filename);
457            $status_callback->(D => $filename);
458            $count++;
459        }
460    }
461   
462    return $count;   
463}
464
465=head2 destroy_db
466
467    $finder->destroy_db($db_filename);
468
469Permanantly removes the database.
470
471=cut
472
473# TODO: use DSNs instead of SQLite db names (this might get funky)
474sub destroy_db {
475    my $self = shift;
476    my $db_file = shift or croak "Need the name of a database to destory";
477    unlink $db_file;
478}
479
480
481# TODO: use DSNs instead of SQLite db names
482sub search {
483    my $self = shift;
484    my ($query, $dirs, $sort, $options) = @_;
485   
486    croak 'Need a database name to search (set "db_file" in the call to find_mp3s)' unless $$options{db_file};
487   
488    my $dbh = DBI->connect("dbi:SQLite:dbname=$$options{db_file}", '', '', {RaiseError => 1});
489   
490    # use the 'LIKE' operator to ignore case
491    my $op = $$options{ignore_case} ? 'LIKE' : '=';
492   
493    # add the SQL '%' wildcard to match substrings
494    unless ($$options{exact_match}) {
495        for my $value (values %$query) {
496            $value = [ map { "%$_%" } @$value ];
497        }
498    }
499
500    my ($where, @bind) = $sql->where(
501        { map { $_ => { $op => $query->{$_} } } keys %$query },
502        ( @$sort ? [ map { uc } @$sort ] : () ),
503    );
504   
505    my $select = "SELECT * FROM mp3 $where";
506   
507    my $sth = $dbh->prepare($select);
508    $sth->execute(@bind);
509   
510    my @results;
511    while (my $row = $sth->fetchrow_hashref) {
512        push @results, $row;
513    }
514   
515    return @results;
516}
517
518# module return
5191;
520
521=head1 TODO
522
523Database maintanence routines (e.g. clear out old entries)
524
525Allow the passing of a DSN or an already created C<$dbh> instead
526of a SQLite database filename; or write driver classes to handle
527database dependent tasks (create_db/destroy_db).
528
529=head1 SEE ALSO
530
531L<MP3::Find>, L<MP3::Find::Filesystem>, L<mp3db>
532
533=head1 AUTHOR
534
535Peter Eichman <peichman@cpan.org>
536
537=head1 THANKS
538
539Thanks to Matt Dietrich for suggesting having an option to just
540update specific files instead of doing a (longer) full search.
541
542=head1 COPYRIGHT AND LICENSE
543
544Copyright (c) 2006 by Peter Eichman. All rights reserved.
545
546This program is free software; you can redistribute it and/or
547modify it under the same terms as Perl itself.
548
549=cut
Note: See TracBrowser for help on using the repository browser.