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

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