reworked DB::new et al *again*
[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
270 sub add_tag
271 {
272     my($self, $file_id, $tag, $value)=@_;
273     my $tag_id=$self->add_to_table("tags",  $tag,
274                                    { "parents_id" => undef });
275     $self->add_relation("files_x_tags",
276                         { "files_id" => $file_id,
277                           "tags_id"  => $tag_id });
278     if(defined($value) && length($value))
279     {
280         my $val_id=$self->add_to_table("tags",  $value,
281                                        { "parents_id" => $tag_id });
282         $self->add_relation("files_x_tags",
283                             { "files_id" => $file_id,
284                               "tags_id"  => $val_id });
285     }
286 }
287
288 sub add_to_table
289 {
290     my($self, $table, $name, $extradata)=@_;
291     my $parent=undef;
292     if($extradata && $extradata->{parents_id})
293     {
294         $parent=$extradata->{parents_id};
295     }
296     my $id=$self->lookup_id($table, $name, $parent);
297     unless(defined($id))
298     {
299         my $sql="INSERT INTO $table (";
300         my @fields=qw(name);
301         if(defined($extradata))
302         {
303             push(@fields, sort keys(%$extradata));
304         }
305         $sql .= join(", ", @fields);
306         $sql .=") VALUES (";
307         $sql .= join(", ", map { "?"; } @fields);
308         $sql .= ");";
309         $id=$self->cmd_id($sql, $name, map { $extradata->{$_} || ""; } sort keys %$extradata);
310     }
311     return $id;
312 }
313
314 sub add_relation
315 {
316     my ($self, $relname, $fields)=@_;
317     return if($self->relation_exists($relname, $fields));
318     my $sql="INSERT INTO $relname (";
319     $sql .= join(", ", sort keys(%$fields));
320     $sql .= ") VALUES (";
321     $sql .= join(", ", map { "?"; } sort keys(%$fields));
322     $sql .= ");";
323     $self->cmd($sql, map { $fields->{$_}; } sort keys(%$fields));
324 }
325
326 sub files_in
327 {
328     my ($self, $dir)=@_;
329     my $sql=("SELECT files.name FROM files\n" .
330              "INNER JOIN paths ON files.paths_id=paths.id\n" .
331              "WHERE paths.name=?\n");
332 #    print "files_in: SQL: $sql\n";
333     return($self->cmd_firstcol($sql, $dir));
334 }
335
336 sub unindex
337 {
338     my($self, $path, $file)=@_;
339     my $sql=("DELETE FROM files WHERE id IN (" .
340              "\tSELECT files.id FROM files\n" .
341              "\tINNER JOIN paths ON paths.id=files.paths_id\n" .
342              "\tWHERE paths.name=? and files.name=? )\n");
343     $self->cmd_rows($sql, $path, $file);
344 }
345
346
347 sub prune_directories
348 {
349     my($self)=@_;
350     my $sql=("SELECT name, id FROM paths\n");
351     my $pathsref=$self->cmd_rows($sql);
352     my @ids=();
353     for my $pathpair (@$pathsref)
354     {
355         my($path, $id)=@$pathpair;
356         my $fullpath="$self->{absbase}/$path";
357         unless(-d $fullpath)
358         {
359             push(@ids, $id)
360         }
361     }
362     $self->prune_paths(@ids);
363     return scalar(@ids);
364 }
365
366 sub prune_paths
367 {
368     my($self, @ids)=@_;
369     return unless(@ids);
370     my $sql=("DELETE FROM files WHERE paths_id IN (\n\t" .
371              join(', ', map { "\"$_\""; } @ids). "\n\t)");
372 #    print "SQL: \n", $sql, "\n";
373     $self->cmd($sql);
374 }
375
376 sub remove_unused
377 {
378     my($self)=@_;
379     my $sql=<<'EOT';
380    DELETE FROM artists WHERE id IN (
381        SELECT artists.id FROM artists
382        LEFT JOIN files ON files.artists_id=artists.id
383        WHERE files.id IS NULL);
384
385    DELETE FROM albums WHERE id IN (
386        SELECT albums.id FROM albums
387        LEFT JOIN files ON files.albums_id=albums.id
388        WHERE files.id IS NULL);
389
390    DELETE FROM paths WHERE id IN (
391        SELECT paths.id FROM paths
392        LEFT JOIN files ON files.paths_id=paths.id
393        WHERE files.id IS NULL);
394
395    DELETE FROM files_x_tags WHERE files_id IN (
396        SELECT files_x_tags.files_id FROM files_x_tags
397        LEFT JOIN files ON files.id=files_x_tags.files_id
398        WHERE files.id IS NULL);
399
400    DELETE FROM tags WHERE id IN (
401        SELECT tags.id FROM tags
402        LEFT JOIN files_x_tags ON files_x_tags.tags_id=tags.id
403        WHERE files_x_tags.files_id IS NULL);
404
405     VACUUM
406 EOT
407 #    print "SQL: $sql\n";
408     my @sql=split(/\n\n/, $sql);
409     $self->cmd($_) for (@sql);
410 }
411
412 sub relation_exists
413 {
414     my ($self, $relname, $fields)=@_;
415     my $sql="SELECT count(1) FROM $relname WHERE ";
416     my @exprs=();
417     my @vals=();
418     for my $field (keys %$fields)
419     {
420         push(@exprs,$field);
421         push(@vals,$fields->{$field});
422     }
423     $sql .= join(' AND ', map { "$_=?"; } @exprs);
424     my ($ret)=$self->cmd_onerow($sql, @vals);
425     return $ret;
426 }
427
428 sub ok
429 {
430     my($self, $thing)=@_;
431     return(defined($thing) && length($thing) && $thing =~ /\S+/);
432 }
433
434 sub cmd_sth
435 {
436     my($self, $sql, @params)=@_;
437     my $sth=$self->{dbh}->prepare($sql);
438     my $idx=1;
439     for my $param (@params)
440     {
441         $param="" unless(defined($param));
442         $sth->bind_param($idx++, $param);
443     }
444     $sth->execute();
445     return $sth;
446 }
447
448 sub cmd
449 {
450     my ($self, @args)=@_;
451     # don't care about retcode
452     $self->cmd_sth(@args);
453 }
454
455 sub cmd_onerow
456 {
457     my ($self, @args)=@_;
458     my $sth=$self->cmd_sth(@args);
459     return($sth->fetchrow_array());
460 }
461
462 sub cmd_rows
463 {
464     my ($self, @args)=@_;
465     my $sth=$self->cmd_sth(@args);
466     return $sth->fetchall_arrayref();
467 }
468
469 sub cmd_firstcol
470 {
471     my ($self, @args)=@_;
472     return(map { $_->[0] } @{$self->cmd_rows(@args)});
473 }
474
475 sub cmd_id
476 {
477     my ($self, @args)=@_;
478     $self->cmd_sth(@args);
479     return($self->last_insert_id());
480 }
481
482 sub last_insert_id
483 {
484     my $self=shift;
485     return $self->{dbh}->last_insert_id("","","","");
486 }
487
488 sub lookup_id
489 {
490     my($self, $table, $name, $parent)=@_;
491     my $sql="SELECT id FROM $table where name=?";
492     my @args=($name);
493     if($parent)
494     {
495         $sql .= " AND parents_id=?";
496         push(@args, $parent);
497     }
498     my($id)=$self->cmd_onerow($sql, @args);
499     return $id;
500 }
501
502 __DATA__
503
504 CREATE TABLE id3fs (
505     schema_version INTEGER,
506     last_update
507 );
508
509 CREATE TABLE paths (
510     id INTEGER,
511     name text,
512     PRIMARY KEY(id DESC)
513 );
514
515 CREATE TABLE artists (
516     id INTEGER,
517     name text,
518     PRIMARY KEY(id DESC)
519 );
520
521 CREATE TABLE albums (
522     id INTEGER,
523     name text,
524     PRIMARY KEY(id DESC)
525 );
526
527 CREATE TABLE files (
528     id INTEGER,
529     name text,
530     artists_id,
531     albums_id,
532     paths_id,
533     PRIMARY KEY(id DESC),
534     FOREIGN KEY(artists_id) REFERENCES artists(id) ON DELETE CASCADE ON UPDATE CASCADE,
535     FOREIGN KEY(albums_id)  REFERENCES albums(id)  ON DELETE CASCADE ON UPDATE CASCADE,
536     FOREIGN KEY(paths_id)   REFERENCES paths(id)   ON DELETE CASCADE ON UPDATE CASCADE
537 );
538
539 CREATE TABLE tags (
540     id INTEGER,
541     parents_id INTEGER,
542     name text,
543     PRIMARY KEY(id DESC)
544 );
545
546 CREATE TABLE files_x_tags (
547     files_id INTEGER,
548     tags_id INTEGER,
549     FOREIGN KEY(files_id) REFERENCES files(id) ON DELETE CASCADE ON UPDATE CASCADE,
550     FOREIGN KEY(tags_id)  REFERENCES tags(id)  ON DELETE CASCADE ON UPDATE CASCADE
551 );
552
553 CREATE INDEX idx_fxt_both ON files_x_tags (files_id, tags_id)
554
555 CREATE INDEX idx_fxt_tags ON files_x_tags (tags_id)
556
557 CREATE INDEX idx_files_id_name ON files (id, name)
558
559 CREATE INDEX idx_files_name_id ON files (name, id)
560
561 CREATE INDEX idx_tags_id_parent_name ON tags (id, parents_id, name)
562
563 CREATE INDEX idx_tags_parent_id_name ON tags (parents_id, id, name)
564
565 CREATE INDEX idx_tags_name ON tags (name)