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

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