add audiotype/ tags; reenable filters
[id3fs.git] / lib / ID3FS / DB.pm
1 package ID3FS::DB;
2
3 use strict;
4 use warnings;
5 use DBI;
6 use ID3FS::AudioFile;
7 use Cwd;
8
9 our $SCHEMA_VERSION=1;
10 my $dbfile=".id3fs";
11
12 sub new
13 {
14     my $proto=shift;
15     my $class=ref($proto) || $proto;
16     my $self={};
17     bless($self,$class);
18
19     $self->{me}=shift;
20     $self->{verbose}=shift;
21     my $init=shift;
22     $self->{base}=shift;
23     $self->{dbpath}=shift || ($self->{base} . "/" . $dbfile);
24     $self->{absbase}=Cwd::abs_path($self->{base});
25
26     my $connectstr="dbi:SQLite:dbname=$self->{dbpath}";
27     my $exists=-f $self->{dbpath};
28     $self->{dbh}=DBI->connect($connectstr, undef, undef,
29                               { AutoCommit=>1 } );
30     unless(defined($self->{dbh}))
31     {
32         die("$self->{me}: DB Error: " . $DBI::errstr . "\n");
33     }
34
35     if($exists)
36     {
37         $self->checkschema();
38     }
39     else
40     {
41         $self->create();
42     }
43     $self->enable_foreign_keys();
44     return $self;
45 }
46
47 sub find_db
48 {
49     # class method
50     shift if(ref($_[0]) eq "ID3FS::DB");
51
52     my($me, $init, @dirs)=@_;
53     my $base=undef;
54     for my $dir (@dirs)
55     {
56         my $path=Cwd::abs_path($dir);
57         do
58         {
59             $base=$path;
60             $path=~s/(.*)\/.*/$1/;
61         }
62         while(! -f "$base/$dbfile" && length($path) && -d $path);
63         if(-f "$base/$dbfile")
64         {
65             return $base;
66         }
67     }
68     if(!-f "$base/$dbfile")
69     {
70         unless($init)
71         {
72             print "$me: db not found at $base/$dbfile\n";
73             return undef;
74         }
75         $base=$dirs[0];
76
77     }
78     return $base;
79 }
80
81 sub base_dir { return shift->{base}; }
82
83 sub create
84 {
85     my($self,$name)=@_;
86     my @schema=split(/\n\n/,join("", <DATA>));
87     close(DATA);
88     for my $cmd (@schema)
89     {
90         $self->{dbh}->do($cmd);
91     }
92     $self->cmd("INSERT INTO id3fs (schema_version, last_update) VALUES (?, ?)",
93                $SCHEMA_VERSION, time());
94 }
95
96 sub checkschema
97 {
98     my $self=shift;
99     my ($version)=$self->cmd_onerow("SELECT schema_version from id3fs");
100     if(!defined($version) || $version != $SCHEMA_VERSION)
101     {
102         die("$self->{me}: id3fs database version " .
103             defined($version) ? $version : '""' .
104             "not known, current version is $SCHEMA_VERSION.\n");
105     }
106 }
107
108 sub analyze
109 {
110     my $self=shift;
111     $self->cmd("ANALYZE");
112 }
113
114 sub enable_foreign_keys
115 {
116     my $self=shift;
117     $self->cmd("PRAGMA foreign_keys = ON");
118 }
119
120 sub last_update
121 {
122     my($self, $newval)=@_;
123     if(defined($newval))
124     {
125         $self->cmd("UPDATE id3fs SET last_update=?", $newval);
126     }
127     else
128     {
129         ($newval)=$self->cmd_onerow("SELECT last_update from id3fs");
130     }
131     return $newval;
132 }
133
134 sub bare_tags
135 {
136     my($self)=@_;
137     my $sql=("SELECT tags.name FROM tags\n" .
138              "WHERE tags.parents_id=''\n" .
139              "GROUP BY tags.name\n");
140     my @names=$self->cmd_firstcol($sql);
141     return (@names);
142 }
143
144 sub tags_with_values
145 {
146     my($self)=@_;
147     my $sql=("SELECT p.name, t.name  FROM tags t\n" .
148              "INNER JOIN tags p ON t.parents_id=p.id\n" .
149              "GROUP BY p.name, t.name\n");
150 #    print "SQL: $sql\n";
151     my $result=$self->cmd_rows($sql);
152     my $tags={};
153     for my $pair (@$result)
154     {
155         push(@{$tags->{$pair->[0]}}, $pair->[1]);
156     }
157     return $tags;
158 }
159
160 sub tag_has_values
161 {
162     my($self, $id)=@_;
163     my $sql=("SELECT COUNT(*) FROM tags\n\t" .
164              "WHERE tags.parents_id=?\n");
165     my ($rows)=$self->cmd_onerow($sql, $id);
166     return $rows;
167 }
168
169 sub relativise
170 {
171     my($self, $path, $name, $mountpoint)=@_;
172     my $id3fs_path=$self->{dbpath};
173     $id3fs_path=~s/(.*)\/.*/$1/;
174     my $rpath="$self->{absbase}/$path";
175     my $vpath="$mountpoint/$id3fs_path";
176     my @path=split(/\//,$rpath);
177     my @rel=split(/\//,$vpath);
178     #absolute paths have empty first element due to leading /
179     shift(@path) if($path[0] eq "");
180     shift(@rel)  if($rel[0]  eq "");
181     if($path[0] ne $rel[0])
182     {
183         #no path in common, return absolute
184         print "FAIL: NO PATHS IN COMMON\n";
185         return $name;
186     }
187     # f: /home/foo/bar/baz.mp3
188     # r: /home/ianb/music/albums
189     while(@path && @rel && ($path[0] eq $rel[0]))
190     {
191         shift(@path);
192         shift(@rel);
193 #       print "POP ";
194     }
195 #    print "\n";
196     my $upcount=scalar(@rel);
197     my $result="../" x $upcount;
198     $result .= join("/",@path);
199     $result .= "/$name";
200     return $result;
201 }
202
203 sub add
204 {
205     my($self,$path)=@_;
206     my $relpath=Cwd::abs_path($path);
207     $relpath =~ s/^\Q$self->{absbase}\E\/?//;
208     my($filepart,$pathpart);
209     if($relpath !~ /\//)
210     {
211         $pathpart='';
212         $filepart=$relpath;
213     }
214     else
215     {
216         ($pathpart, $filepart) = ($relpath =~ /(.*)\/(.*)/);
217     }
218     my $file=ID3FS::AudioFile->new($path, $self->{me});
219     return unless(defined($file));
220     my $artist=$file->artist();
221     my $album=$file->album();
222     my $v1genre=$file->v1genre();
223     my $year=$file->year();
224     my $audiotype=$file->audiotype();
225     my @tags=$file->tags();
226     my $haspic=$file->haspic();
227
228     $artist=undef unless($self->ok($artist));
229     print "$self->{me}: $path: no artist tag defined\n" unless(defined($artist));
230     my $artist_id=$self->add_to_table("artists",  $artist);
231     my $path_id=$self->add_to_table("paths", $pathpart);
232     $album=undef unless($self->ok($album));
233     if($self->{verbose} && !defined($album))
234     {
235         print "$self->{me}: $path: no album tag defined\n";
236     }
237
238     my $albums_id=$self->add_to_table("albums", $album);
239     my $file_id=$self->add_to_table("files", $filepart,
240                                     { "artists_id" => $artist_id,
241                                       "albums_id"  => $albums_id,
242                                       "paths_id"   => $path_id });
243     for my $tag (@tags)
244     {
245         $self->add_tag($file_id, @$tag);
246     }
247
248     $year="UNKNOWN" unless($self->ok($year));
249     $self->add_tag($file_id, "year", $year);
250     if($year=~/^(\d\d\d)\d$/)
251     {
252         $self->add_tag($file_id, "decade", "${1}0s");
253     }
254     else
255     {
256         $self->add_tag($file_id, "decade", "UNKNOWN");
257     }
258
259     if($self->ok($v1genre))
260     {
261         $self->add_tag($file_id, "v1genre", $v1genre);
262     }
263
264     if($haspic)
265     {
266         $self->add_tag($file_id, "haspic", undef);
267     }
268
269     if($self->ok($audiotype))
270     {
271         $self->add_tag($file_id, "audiotype", $audiotype);
272     }
273 }
274
275 sub add_tag
276 {
277     my($self, $file_id, $tag, $value)=@_;
278     my $tag_id=$self->add_to_table("tags",  $tag,
279                                    { "parents_id" => undef });
280     $self->add_relation("files_x_tags",
281                         { "files_id" => $file_id,
282                           "tags_id"  => $tag_id });
283     if(defined($value) && length($value))
284     {
285         my $val_id=$self->add_to_table("tags",  $value,
286                                        { "parents_id" => $tag_id });
287         $self->add_relation("files_x_tags",
288                             { "files_id" => $file_id,
289                               "tags_id"  => $val_id });
290     }
291 }
292
293 sub add_to_table
294 {
295     my($self, $table, $name, $extradata)=@_;
296     my $parent=undef;
297     if($extradata && $extradata->{parents_id})
298     {
299         $parent=$extradata->{parents_id};
300     }
301     my $id=$self->lookup_id($table, $name, $parent);
302     unless(defined($id))
303     {
304         my $sql="INSERT INTO $table (";
305         my @fields=qw(name);
306         if(defined($extradata))
307         {
308             push(@fields, sort keys(%$extradata));
309         }
310         $sql .= join(", ", @fields);
311         $sql .=") VALUES (";
312         $sql .= join(", ", map { "?"; } @fields);
313         $sql .= ");";
314         $id=$self->cmd_id($sql, $name, map { $extradata->{$_} || ""; } sort keys %$extradata);
315     }
316     return $id;
317 }
318
319 sub add_relation
320 {
321     my ($self, $relname, $fields)=@_;
322     return if($self->relation_exists($relname, $fields));
323     my $sql="INSERT INTO $relname (";
324     $sql .= join(", ", sort keys(%$fields));
325     $sql .= ") VALUES (";
326     $sql .= join(", ", map { "?"; } sort keys(%$fields));
327     $sql .= ");";
328     $self->cmd($sql, map { $fields->{$_}; } sort keys(%$fields));
329 }
330
331 sub files_in
332 {
333     my ($self, $dir)=@_;
334     my $sql=("SELECT files.name FROM files\n" .
335              "INNER JOIN paths ON files.paths_id=paths.id\n" .
336              "WHERE paths.name=?\n");
337 #    print "files_in: SQL: $sql\n";
338     return($self->cmd_firstcol($sql, $dir));
339 }
340
341 sub unindex
342 {
343     my($self, $path, $file)=@_;
344     my $sql=("DELETE FROM files WHERE id IN (" .
345              "\tSELECT files.id FROM files\n" .
346              "\tINNER JOIN paths ON paths.id=files.paths_id\n" .
347              "\tWHERE paths.name=? and files.name=? )\n");
348     $self->cmd_rows($sql, $path, $file);
349 }
350
351
352 sub prune_directories
353 {
354     my($self)=@_;
355     my $sql=("SELECT name, id FROM paths\n");
356     my $pathsref=$self->cmd_rows($sql);
357     my @ids=();
358     for my $pathpair (@$pathsref)
359     {
360         my($path, $id)=@$pathpair;
361         my $fullpath="$self->{absbase}/$path";
362         unless(-d $fullpath)
363         {
364             push(@ids, $id)
365         }
366     }
367     $self->prune_paths(@ids);
368     return scalar(@ids);
369 }
370
371 sub prune_paths
372 {
373     my($self, @ids)=@_;
374     return unless(@ids);
375     my $sql=("DELETE FROM files WHERE paths_id IN (\n\t" .
376              join(', ', map { "\"$_\""; } @ids). "\n\t)");
377 #    print "SQL: \n", $sql, "\n";
378     $self->cmd($sql);
379 }
380
381 sub remove_unused
382 {
383     my($self)=@_;
384     my $sql=<<'EOT';
385    DELETE FROM artists WHERE id IN (
386        SELECT artists.id FROM artists
387        LEFT JOIN files ON files.artists_id=artists.id
388        WHERE files.id IS NULL);
389
390    DELETE FROM albums WHERE id IN (
391        SELECT albums.id FROM albums
392        LEFT JOIN files ON files.albums_id=albums.id
393        WHERE files.id IS NULL);
394
395    DELETE FROM paths WHERE id IN (
396        SELECT paths.id FROM paths
397        LEFT JOIN files ON files.paths_id=paths.id
398        WHERE files.id IS NULL);
399
400    DELETE FROM files_x_tags WHERE files_id IN (
401        SELECT files_x_tags.files_id FROM files_x_tags
402        LEFT JOIN files ON files.id=files_x_tags.files_id
403        WHERE files.id IS NULL);
404
405    DELETE FROM tags WHERE id IN (
406        SELECT tags.id FROM tags
407        LEFT JOIN files_x_tags ON files_x_tags.tags_id=tags.id
408        WHERE files_x_tags.files_id IS NULL);
409
410     VACUUM
411 EOT
412 #    print "SQL: $sql\n";
413     my @sql=split(/\n\n/, $sql);
414     $self->cmd($_) for (@sql);
415 }
416
417 sub relation_exists
418 {
419     my ($self, $relname, $fields)=@_;
420     my $sql="SELECT count(1) FROM $relname WHERE ";
421     my @exprs=();
422     my @vals=();
423     for my $field (keys %$fields)
424     {
425         push(@exprs,$field);
426         push(@vals,$fields->{$field});
427     }
428     $sql .= join(' AND ', map { "$_=?"; } @exprs);
429     my ($ret)=$self->cmd_onerow($sql, @vals);
430     return $ret;
431 }
432
433 sub ok
434 {
435     my($self, $thing)=@_;
436     return(defined($thing) && length($thing) && $thing =~ /\S+/);
437 }
438
439 sub cmd_sth
440 {
441     my($self, $sql, @params)=@_;
442     my $sth=$self->{dbh}->prepare($sql);
443     my $idx=1;
444     for my $param (@params)
445     {
446         $param="" unless(defined($param));
447         $sth->bind_param($idx++, $param);
448     }
449     $sth->execute();
450     return $sth;
451 }
452
453 sub cmd
454 {
455     my ($self, @args)=@_;
456     # don't care about retcode
457     $self->cmd_sth(@args);
458 }
459
460 sub cmd_onerow
461 {
462     my ($self, @args)=@_;
463     my $sth=$self->cmd_sth(@args);
464     return($sth->fetchrow_array());
465 }
466
467 sub cmd_rows
468 {
469     my ($self, @args)=@_;
470     my $sth=$self->cmd_sth(@args);
471     return $sth->fetchall_arrayref();
472 }
473
474 sub cmd_firstcol
475 {
476     my ($self, @args)=@_;
477     return(map { $_->[0] } @{$self->cmd_rows(@args)});
478 }
479
480 sub cmd_id
481 {
482     my ($self, @args)=@_;
483     $self->cmd_sth(@args);
484     return($self->last_insert_id());
485 }
486
487 sub last_insert_id
488 {
489     my $self=shift;
490     return $self->{dbh}->last_insert_id("","","","");
491 }
492
493 sub lookup_id
494 {
495     my($self, $table, $name, $parent)=@_;
496     my $sql="SELECT id FROM $table where name=?";
497     my @args=($name);
498     if($parent)
499     {
500         $sql .= " AND parents_id=?";
501         push(@args, $parent);
502     }
503     my($id)=$self->cmd_onerow($sql, @args);
504     return $id;
505 }
506
507 __DATA__
508
509 CREATE TABLE id3fs (
510     schema_version INTEGER,
511     last_update
512 );
513
514 CREATE TABLE paths (
515     id INTEGER,
516     name text,
517     PRIMARY KEY(id DESC)
518 );
519
520 CREATE TABLE artists (
521     id INTEGER,
522     name text,
523     PRIMARY KEY(id DESC)
524 );
525
526 CREATE TABLE albums (
527     id INTEGER,
528     name text,
529     PRIMARY KEY(id DESC)
530 );
531
532 CREATE TABLE files (
533     id INTEGER,
534     name text,
535     artists_id,
536     albums_id,
537     paths_id,
538     PRIMARY KEY(id DESC),
539     FOREIGN KEY(artists_id) REFERENCES artists(id) ON DELETE CASCADE ON UPDATE CASCADE,
540     FOREIGN KEY(albums_id)  REFERENCES albums(id)  ON DELETE CASCADE ON UPDATE CASCADE,
541     FOREIGN KEY(paths_id)   REFERENCES paths(id)   ON DELETE CASCADE ON UPDATE CASCADE
542 );
543
544 CREATE TABLE tags (
545     id INTEGER,
546     parents_id INTEGER,
547     name text,
548     PRIMARY KEY(id DESC)
549 );
550
551 CREATE TABLE files_x_tags (
552     files_id INTEGER,
553     tags_id INTEGER,
554     FOREIGN KEY(files_id) REFERENCES files(id) ON DELETE CASCADE ON UPDATE CASCADE,
555     FOREIGN KEY(tags_id)  REFERENCES tags(id)  ON DELETE CASCADE ON UPDATE CASCADE
556 );
557
558 CREATE INDEX idx_fxt_both ON files_x_tags (files_id, tags_id)
559
560 CREATE INDEX idx_fxt_tags ON files_x_tags (tags_id)
561
562 CREATE INDEX idx_files_id_name ON files (id, name)
563
564 CREATE INDEX idx_files_name_id ON files (name, id)
565
566 CREATE INDEX idx_tags_id_parent_name ON tags (id, parents_id, name)
567
568 CREATE INDEX idx_tags_parent_id_name ON tags (parents_id, id, name)
569
570 CREATE INDEX idx_tags_name ON tags (name)