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

Last change on this file since 29 was 29, checked in by peter, 18 years ago
  • factored out 'get_mp3_metadata' function from Filesystem.pm to Util.pm
  • added 'update' function to DB.pm that combines 'update_db' and 'update_file' (for updating just specific files); idea courtesy of Matt Dietrich
  • modified mp3db to let you mix and match files and directories on the command line; now also uses the 'update' function in DB.pm
File size: 13.3 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' ],  # the 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# TODO: use DSNs instead of SQLite db names
53sub search {
54    my $self = shift;
55    my ($query, $dirs, $sort, $options) = @_;
56   
57    croak 'Need a database name to search (set "db_file" in the call to find_mp3s)' unless $$options{db_file};
58   
59    my $dbh = DBI->connect("dbi:SQLite:dbname=$$options{db_file}", '', '', {RaiseError => 1});
60   
61    # use the 'LIKE' operator to ignore case
62    my $op = $$options{ignore_case} ? 'LIKE' : '=';
63   
64    # add the SQL '%' wildcard to match substrings
65    unless ($$options{exact_match}) {
66        for my $value (values %$query) {
67            $value = [ map { "%$_%" } @$value ];
68        }
69    }
70
71    my ($where, @bind) = $sql->where(
72        { map { $_ => { $op => $query->{$_} } } keys %$query },
73        ( @$sort ? [ map { uc } @$sort ] : () ),
74    );
75   
76    my $select = "SELECT * FROM mp3 $where";
77   
78    my $sth = $dbh->prepare($select);
79    $sth->execute(@bind);
80   
81    my @results;
82    while (my $row = $sth->fetchrow_hashref) {
83        push @results, $row;
84    }
85   
86    return @results;
87}
88
89# TODO: convert to using DSNs instead of hardcoded SQLite connections
90# TODO: extended table for ID3v2 data
91sub create_db {
92    my $self = shift;
93    my $db_file = shift or croak "Need a name for the database I'm about to create";
94    my $dbh = DBI->connect("dbi:SQLite:dbname=$db_file", '', '', {RaiseError => 1});
95    $dbh->do('CREATE TABLE mp3 (' . join(',', map { "$$_[0] $$_[1]" } @COLUMNS) . ')');
96}
97
98# this is update_db and update_files (from Matt Dietrich) rolled into one
99=head2 update
100
101    my $count = $finder->update({
102        dsn   => 'dbi:SQLite:dbname=mp3.db',
103        files => \@filenames,
104        dirs  => [qw(music downloads/mp3)],
105    });
106
107Compares the files in the C<files> list plus any MP3s found by searching
108in C<dirs> to their records in the database pointed to by C<dsn>. If the
109files found have been updated since they have been recorded in the database
110(or if they are not in the database), they are updated (or added).
111
112=cut
113
114sub update {
115    my $self = shift;
116    my $args = shift;
117
118    my $dsn   = $args->{dsn} or croak "Need a DSN to connect to";
119    my @dirs  = $args->{dirs}
120                    ? ref $args->{dirs} eq 'ARRAY'
121                        ? @{ $args->{dirs} }
122                        : ($args->{dirs})
123                    : ();
124
125    my @files  = $args->{files}
126                    ? ref $args->{files} eq 'ARRAY' 
127                        ? @{ $args->{files} }
128                        : ($args->{files})
129                    : ();
130   
131    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
132
133    my $dbh = DBI->connect($dsn, '', '', {RaiseError => 1});
134    my $mtime_sth = $dbh->prepare('SELECT mtime FROM mp3 WHERE FILENAME = ?');
135    my $insert_sth = $dbh->prepare(
136        'INSERT INTO mp3 (' . 
137            join(',', map { $$_[0] } @COLUMNS) .
138        ') VALUES (' .
139            join(',', map { '?' } @COLUMNS) .
140        ')'
141    );
142    my $update_sth = $dbh->prepare(
143        'UPDATE mp3 SET ' . 
144            join(',', map { "$$_[0] = ?" } @COLUMNS) . 
145        ' WHERE FILENAME = ?'
146    );
147   
148    my $count = 0;  # the number of records added or updated
149    my @mp3s;       # metadata for mp3s found
150
151    # look for mp3s using the filesystem backend if we have dirs to search in
152    if (@dirs) {
153        require MP3::Find::Filesystem;
154        my $finder = MP3::Find::Filesystem->new;
155        unshift @mp3s, $finder->find_mp3s(dir => \@dirs, no_format => 1);
156    }
157
158    # get the metadata on specific files
159    unshift @mp3s, map { get_mp3_metadata({ filename => $_ }) } @files;
160
161    # check each file against its record in the database
162    for my $mp3 (@mp3s) {       
163        # see if the file has been modified since it was first put into the db
164        $mp3->{mtime} = (stat($mp3->{FILENAME}))[9];
165        $mtime_sth->execute($mp3->{FILENAME});
166        my $records = $mtime_sth->fetchall_arrayref;
167       
168        warn "Multiple records for $$mp3{FILENAME}\n" if @$records > 1;
169       
170        if (@$records == 0) {
171            # we are adding a record
172            $insert_sth->execute(map { $mp3->{$$_[0]} } @COLUMNS);
173            $status_callback->(A => $$mp3{FILENAME});
174            $count++;
175        } elsif ($mp3->{mtime} > $$records[0][0]) {
176            # the mp3 file is newer than its record
177            $update_sth->execute((map { $mp3->{$$_[0]} } @COLUMNS), $mp3->{FILENAME});
178            $status_callback->(U => $$mp3{FILENAME});
179            $count++;
180        }
181    }
182   
183    # SQLite specific code:
184    # as a workaround for the 'closing dbh with active staement handles warning
185    # (see http://rt.cpan.org/Ticket/Display.html?id=9643#txn-120724)
186    foreach ($mtime_sth, $insert_sth, $update_sth) {
187        $_->{RaiseError} = 0;  # don't die on error
188        $_->{PrintError} = 0;  # ...and don't even say anything
189        $_->{Active} = 1;
190        $_->finish;
191    }
192   
193    return $count;
194}
195
196
197
198sub update_db {
199    my $self = shift;
200    my $db_file = shift or croak "Need the name of the database to update";
201    my $dirs = shift;
202   
203    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
204   
205    my @dirs = ref $dirs eq 'ARRAY' ? @$dirs : ($dirs);
206   
207    my $dbh = DBI->connect("dbi:SQLite:dbname=$db_file", '', '', {RaiseError => 1});
208    my $mtime_sth = $dbh->prepare('SELECT mtime FROM mp3 WHERE FILENAME = ?');
209    my $insert_sth = $dbh->prepare(
210        'INSERT INTO mp3 (' . 
211            join(',', map { $$_[0] } @COLUMNS) .
212        ') VALUES (' .
213            join(',', map { '?' } @COLUMNS) .
214        ')'
215    );
216    my $update_sth = $dbh->prepare(
217        'UPDATE mp3 SET ' . 
218            join(',', map { "$$_[0] = ?" } @COLUMNS) . 
219        ' WHERE FILENAME = ?'
220    );
221   
222    # the number of records added or updated
223    my $count = 0;
224   
225    # look for mp3s using the filesystem backend
226    require MP3::Find::Filesystem;
227    my $finder = MP3::Find::Filesystem->new;
228    for my $mp3 ($finder->find_mp3s(dir => \@dirs, no_format => 1)) {
229        # see if the file has been modified since it was first put into the db
230        $mp3->{mtime} = (stat($mp3->{FILENAME}))[9];
231        $mtime_sth->execute($mp3->{FILENAME});
232        my $records = $mtime_sth->fetchall_arrayref;
233       
234        warn "Multiple records for $$mp3{FILENAME}\n" if @$records > 1;
235       
236        if (@$records == 0) {
237            $insert_sth->execute(map { $mp3->{$$_[0]} } @COLUMNS);
238            $status_callback->(A => $$mp3{FILENAME});
239            $count++;
240        } elsif ($mp3->{mtime} > $$records[0][0]) {
241            # the mp3 file is newer than its record
242            $update_sth->execute((map { $mp3->{$$_[0]} } @COLUMNS), $mp3->{FILENAME});
243            $status_callback->(U => $$mp3{FILENAME});
244            $count++;
245        }
246    }
247   
248    # as a workaround for the 'closing dbh with active staement handles warning
249    # (see http://rt.cpan.org/Ticket/Display.html?id=9643#txn-120724)
250    foreach ($mtime_sth, $insert_sth, $update_sth) {
251        $_->{RaiseError} = 0;  # don't die on error
252        $_->{PrintError} = 0;  # ...and don't even say anything
253        $_->{Active} = 1;
254        $_->finish;
255    }
256   
257    return $count;
258}
259
260# TODO: use DSNs instead of SQLite db names
261sub sync_db {
262    my $self = shift;
263    my $db_file = shift or croak "Need the name of the databse to sync";
264
265    my $status_callback = $self->{status_callback} || $DEFAULT_STATUS_CALLBACK;
266
267    my $dbh = DBI->connect("dbi:SQLite:dbname=$db_file", '', '', {RaiseError => 1});
268    my $select_sth = $dbh->prepare('SELECT FILENAME FROM mp3');
269    my $delete_sth = $dbh->prepare('DELETE FROM mp3 WHERE FILENAME = ?');
270   
271    # the number of records removed
272    my $count = 0;
273   
274    $select_sth->execute;
275    while (my ($filename) = $select_sth->fetchrow_array) {
276        unless (-e $filename) {
277            $delete_sth->execute($filename);
278            $status_callback->(D => $filename);
279            $count++;
280        }
281    }
282   
283    return $count;   
284}
285
286# TODO: use DSNs instead of SQLite db names (this might get funky)
287sub destroy_db {
288    my $self = shift;
289    my $db_file = shift or croak "Need the name of a database to destory";
290    unlink $db_file;
291}
292
293# module return
2941;
295
296=head1 NAME
297
298MP3::Find::DB - SQLite database backend to MP3::Find
299
300=head1 SYNOPSIS
301
302    use MP3::Find::DB;
303    my $finder = MP3::Find::DB->new;
304   
305    my @mp3s = $finder->find_mp3s(
306        dir => '/home/peter/music',
307        query => {
308            artist => 'ilyaimy',
309            album  => 'myxomatosis',
310        },
311        ignore_case => 1,
312        db_file => 'mp3.db',
313    );
314   
315    # you can do things besides just searching the database
316   
317    # create another database
318    $finder->create_db('my_mp3s.db');
319   
320    # update the database from the filesystem
321    $finder->update_db('my_mp3s.db', ['/home/peter/mp3', '/home/peter/cds']);
322   
323    # and then blow it away
324    $finder->destroy_db('my_mp3s.db');
325
326=head1 REQUIRES
327
328L<DBI>, L<DBD::SQLite>, L<SQL::Abstract>
329
330=head1 DESCRIPTION
331
332This is the SQLite database backend for L<MP3::Find>.
333
334B<Note:> I'm still working out some kinks in here, so this backend
335is currently not as stable as the Filesystem backend.
336
337=head2 Special Options
338
339=over
340
341=item C<db_file>
342
343The name of the SQLite database file to use. Defaults to F<~/mp3.db>.
344
345The database should have at least one table named C<mp3> with the
346following schema:
347
348    CREATE TABLE mp3 (
349        mtime         INTEGER,
350        FILENAME      TEXT,
351        TITLE         TEXT,
352        ARTIST        TEXT,
353        ALBUM         TEXT,
354        YEAR          INTEGER,
355        COMMENT       TEXT,
356        GENRE         TEXT,
357        TRACKNUM      INTEGER,
358        VERSION       NUMERIC,
359        LAYER         INTEGER,
360        STEREO        TEXT,
361        VBR           TEXT,
362        BITRATE       INTEGER,
363        FREQUENCY     INTEGER,
364        SIZE          INTEGER,
365        OFFSET        INTEGER,
366        SECS          INTEGER,
367        MM            INTEGER,
368        SS            INTEGER,
369        MS            INTEGER,
370        TIME          TEXT,
371        COPYRIGHT     TEXT,
372        PADDING       INTEGER,
373        MODE          INTEGER,
374        FRAMES        INTEGER,
375        FRAME_LENGTH  INTEGER,
376        VBR_SCALE     INTEGER
377    );
378
379=back
380
381=head1 METHODS
382
383=head2 new
384
385    my $finder = MP3::Find::DB->new(
386        status_callback => \&callback,
387    );
388
389The C<status_callback> gets called each time an entry in the
390database is added, updated, or deleted by the C<update_db> and
391C<sync_db> methods. The arguments passed to the callback are
392a status code (A, U, or D) and the filename for that entry.
393The default callback just prints these to C<STDERR>:
394
395    sub default_callback {
396        my ($status_code, $filename) = @_;
397        print STDERR "$status_code $filename\n";
398    }
399
400To suppress any output, set C<status_callback> to an empty sub:
401
402    status_callback => sub {}
403
404=head2 create_db
405
406    $finder->create_db($db_filename);
407
408Creates a SQLite database in the file named c<$db_filename>.
409
410=head2 update_db
411
412    my $count = $finder->update_db($db_filename, \@dirs);
413
414Searches for all mp3 files in the directories named by C<@dirs>
415using L<MP3::Find::Filesystem>, and adds or updates the ID3 info
416from those files to the database. If a file already has a record
417in the database, then it will only be updated if it has been modified
418sinc ethe last time C<update_db> was run.
419
420=head2 sync_db
421
422    my $count = $finder->sync_db($db_filename);
423
424Removes entries from the database that refer to files that no longer
425exist in the filesystem. Returns the count of how many records were
426removed.
427
428=head2 destroy_db
429
430    $finder->destroy_db($db_filename);
431
432Permanantly removes the database.
433
434=head1 TODO
435
436Database maintanence routines (e.g. clear out old entries)
437
438Allow the passing of a DSN or an already created C<$dbh> instead
439of a SQLite database filename; or write driver classes to handle
440database dependent tasks (create_db/destroy_db).
441
442=head1 SEE ALSO
443
444L<MP3::Find>, L<MP3::Find::Filesystem>, L<mp3db>
445
446=head1 AUTHOR
447
448Peter Eichman <peichman@cpan.org>
449
450=head1 THANKS
451
452Thanks to Matt Dietrich for suggesting having an option to just
453update specific files instead of doing a (longer) full search.
454
455=head1 COPYRIGHT AND LICENSE
456
457Copyright (c) 2006 by Peter Eichman. All rights reserved.
458
459This program is free software; you can redistribute it and/or
460modify it under the same terms as Perl itself.
461
462=cut
Note: See TracBrowser for help on using the repository browser.