id3fs-tag -V: per-dir summaries
[id3fs.git] / bin / id3fs-tag
1 #!/usr/bin/perl -w
2 #
3 # id3fs - a FUSE-based filesystem for browsing audio metadata
4 # Copyright (C) 2010  Ian Beckwith <ianb@erislabs.net>
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19 use lib '/home/ianb/projects/id3fs/id3fs/lib'; # FIXME: remove
20 use strict;
21 use Getopt::Long qw(Configure);
22 use File::Find;
23 use ID3FS::AudioFile;
24 use vars qw($me);
25 $me=($0=~/(?:.*\/)?(.*)/)[0];
26
27 my @extensions=qw(mp3); # FIXME:  flac ogg
28 my (%file_tags);
29 my $verbose=0;
30 my $help=0;
31 my ($artist, $album, $track, $tracknum, $year, $v1genre, $comment,
32     $delete_artist, $delete_album, $delete_track, $delete_tracknum,
33     $delete_year, $delete_v1genre, $delete_comment, $delete_all,
34     $delete_genre, $genre, $add_tags, $delete_tags, $overwrite_tagvals,
35     $delete_tagvals, $tag_summary);
36
37 Configure(qw(bundling no_ignore_case));
38 my $optret=GetOptions(
39     "verbose|v"                      => \$verbose,
40     "help|h"                         => \$help,
41     "artist|a=s"                     => \$artist,
42     "album|l=s"                      => \$album,
43     "song|s=s"                       => \$track,
44     "tracknum|n=s"                   => \$tracknum,
45     "year|y=i"                       => \$year,
46     "v1genre|1=s"                    => \$v1genre,
47     "comment|c=s"                    => \$comment,
48     "delete-artist|A"                => \$delete_artist,
49     "delete-album|L"                 => \$delete_album,
50     "delete-song|S"                  => \$delete_track,
51     "delete-tracknum|N"              => \$delete_tracknum,
52     "delete-year|Y"                  => \$delete_year,
53     "delete-v1genre|0"               => \$delete_v1genre,
54     "delete-comment|C"               => \$delete_comment,
55     "delete|delete-all|D"            => \$delete_all,
56     "delete-genre|delete-all-tags|G" => \$delete_genre,
57     "genre|g|replace-all-tags|R=s"   => \$genre,
58     "add-tags|tags|t=s"              => \$add_tags,
59     "overwrite-tagvals|tagvals|o=s"  => \$overwrite_tagvals,
60     "delete-tags|T=s"                => \$delete_tags,
61     "delete-tags-with-values|O=s"    => \$delete_tagvals,
62     "summary|V"                      => \$tag_summary,
63     );
64
65 usage() if(!@ARGV || !$optret || $help);
66
67 while(my $path=shift @ARGV)
68 {
69     unless(-e $path)
70     {
71         warn("$me: $path: not found\n");
72         next;
73     }
74     File::Find::find( {wanted => \&wanted, follow => 1, no_chdir => 1}, $path);
75 }
76
77 summarize_tags() if($tag_summary);
78
79
80 sub wanted
81 {
82     my $ext='';
83     if(/.*\.(.*)/) { $ext=lc($1); }
84     if(-f && scalar(grep({ $ext eq lc($_);} @extensions)))
85     {
86         my $file=ID3FS::AudioFile->new($_);
87         return unless($file);
88         if($tag_summary)
89         {
90             gather_tags($_, $file);
91         }
92         else
93         {
94             my $changes=0;
95             $changes =  do_deletes($file);
96             $changes += do_adds($file);
97             if($changes)
98             {
99                 do_write($file);
100             }
101             else
102             {
103                 do_display($file);
104             }
105         }
106     }
107 }
108
109 sub do_deletes
110 {
111     my($file)=@_;
112     if($delete_all)
113     {
114         $file->delete_all();
115         # we don't want to save the tag if we've deleted it
116         return 0;
117     }
118     $file->delete_artist()   if($delete_artist);
119     $file->delete_album()    if($delete_album);
120     $file->delete_track()    if($delete_track);
121     $file->delete_tracknum() if($delete_tracknum);
122     $file->delete_year()     if($delete_year);
123     $file->delete_v1genre()  if($delete_v1genre);
124     $file->delete_comment()  if($delete_comment);
125     $file->delete_genre()    if($delete_genre || $genre);
126     $file->delete_tags($delete_tags, 0)       if($delete_tags);
127     $file->delete_tags($delete_tagvals, 1)    if($delete_tagvals);
128     $file->delete_tags($overwrite_tagvals, 1) if($overwrite_tagvals);
129
130     my $donesomething=($delete_artist   || $delete_album   || $delete_track   ||
131                        $delete_tracknum || $delete_year    || $delete_v1genre ||
132                        $delete_comment  || $delete_genre   || $delete_tags    ||
133                        $delete_tagvals  || defined($genre) || $overwrite_tagvals);
134     return($donesomething ? 1 : 0);
135 }
136
137 sub do_adds
138 {
139     my($file)=@_;
140     $file->artist($artist)     if($artist);
141     $file->album($album)       if($album);
142     $file->track($track)       if($track);
143     $file->tracknum($tracknum) if($tracknum);
144     $file->year($year)         if($year);
145     $file->v1genre($v1genre)   if($v1genre);
146     $file->comment($comment)   if($comment);
147     $file->add_tags($add_tags) if($add_tags);
148     $file->add_tags($genre)    if($genre);
149     $file->add_tags($overwrite_tagvals) if($overwrite_tagvals);
150
151     my $donesomething=(defined($artist)   || defined($album) ||
152                        defined($track)    || defined($tracknum) ||
153                        defined($year)     || defined($v1genre) ||
154                        defined($comment)  || defined($genre) ||
155                        defined($add_tags) || defined($overwrite_tagvals));
156     return( $donesomething ? 1 : 0 );
157 }
158
159 sub do_write
160 {
161     my($file)=@_;
162     $file->write();
163 }
164
165 sub do_display
166 {
167     my($file)=@_;
168     my $artist=$file->artist();
169     my $album=$file->album();
170     my $track=$file->track();
171     my $tracknum=$file->tracknum();
172     my $year=$file->year();
173     my $comment=$file->comment();
174     my $v1genre=$file->v1genre();
175     my @tags=$file->tags();
176     @tags = map { (ref($_) eq "ARRAY") ? join('/', grep {defined;} @{$_}) : $_; } @tags;
177     if($verbose)
178     {
179         print $file->path(), ":\n";
180         print "  tracknum: $tracknum\n" if($tracknum);
181         print "  artist: $artist\n"     if($artist);
182         print "  album: $album\n"       if($album);
183         print "  song: $track\n"        if($track);
184         print "  year: $year\n"         if($year);
185         print "  v1genre: $v1genre\n"   if($v1genre);
186         print "  comment: $comment\n"   if($comment);
187     }
188     else
189     {
190         my @fields=($file->path(), $tracknum, $artist, $album, $track,
191                     $year, $v1genre, $comment);
192         @fields=map { defined($_) ? $_ : ""; } @fields;
193         print join(':', @fields), "\n";
194     }
195     if(@tags)
196     {
197         if($verbose) { print "  tags: "; }
198 #       else         { print $file->path() . ":tags:"; }
199         else         { print "tags:"; }
200         print join(", ", @tags), "\n";
201     }
202 }
203
204 sub gather_tags
205 {
206     my($path, $file)=@_;
207     my @tags=$file->tags();
208     @tags=map { join('/', grep { defined; } @$_); } @tags;
209     @tags=ID3FS::AudioFile::uniq(@tags);
210     $file_tags{$path}=\@tags;
211 }
212
213 sub summarize_tags
214 {
215     my @all_tags=ID3FS::AudioFile::uniq(map { @$_; } values(%file_tags));
216
217     # group tags by directories
218     my %dir_tags=();
219     my @dirs=map { s/(.*)\/.*/$1/; $_; } keys %file_tags;
220     @dirs=ID3FS::AudioFile::uniq(@dirs);
221     for my $tag (@all_tags)
222     {
223         DIR: for my $dir (@dirs)
224         {
225             for my $file (keys %file_tags)
226             {
227                 next unless(@{$file_tags{$file}});
228                 next unless($file =~ /^$dir/);
229                 next DIR unless(grep { $_ eq $tag; } @{$file_tags{$file}});
230             }
231             push(@{$dir_tags{$dir}}, $tag);
232         }
233     }
234
235     # remove dir tags from files
236     for my $path (keys %dir_tags)
237     {
238         for my $file (keys %file_tags)
239         {
240             next unless($file =~ /^$path/);
241             $file_tags{$file} = [ ID3FS::AudioFile::list_remove(
242                                       $dir_tags{$path},
243                                       $file_tags{$file}) ];
244         }
245     }
246
247     # find common tags
248     my @common_tags=();
249     OUTER: for my $tag (@all_tags)
250     {
251         for my $taglist (values(%dir_tags))
252         {
253             next OUTER unless(grep { $_ eq $tag; } @$taglist);
254         }
255         push(@common_tags, $tag);
256     }
257
258     # remove common tags from %file_tags
259     for my $filename (keys(%file_tags))
260     {
261         next unless(@{$file_tags{$filename}});
262         $file_tags{$filename}= [ ID3FS::AudioFile::list_remove(\@common_tags,
263                                                                $file_tags{$filename}) ];
264     }
265
266     # remove common tags from %dir_tags
267     for my $path (keys(%dir_tags))
268     {
269         next unless(@{$dir_tags{$path}});
270         $dir_tags{$path}= [ ID3FS::AudioFile::list_remove(\@common_tags,
271                                                           $dir_tags{$path}) ];
272     }
273
274 #    print "ALL: ",    join(', ', @all_tags), "\n";
275     print "Common tags: ", join(', ', @common_tags), "\n";
276 #    print "PER-DIR:\n";
277     for my $path (sort keys %dir_tags)
278     {
279         next unless(@{$dir_tags{$path}});
280         print "$path: ", join(', ', @{$dir_tags{$path}}), "\n";
281     }
282
283 #    print "PER-FILE: \n";
284     for my $filename (keys(%file_tags))
285     {
286         next unless(@{$file_tags{$filename}});
287         print "$filename: ", join(', ', @{$file_tags{$filename}}), "\n";
288     }
289 }
290
291 sub usage
292 {
293     die("Usage: $me [-vhALSNY0CDG] [-a ARTIST] [-l ALBUM] [-s SONG] [-n TRACKNUM] FILES...\n".
294         "       $me [-y YEAR] [-g GENRE] [-1 V1GENRE] [-c COMMENT] [--] FILES...\n".
295         "       $me [-t TAGS,TO,ADD] [-T TAGS,TO,DELETE]  FILES...\n".
296         "       $me [-r TAGS,TO,DELETE TAGS,TO,ADD] [-R TAGS,TO,OVERWRITE,WITH] FILES...\n".
297         "With no options, displays current info in tag\n".
298         "Options:\n".
299         " -a|--artist=ARTIST                    Set artist\n".
300         " -l|--album=ALBUM                      Set album\n".
301         " -s|--song=SONG                        Set song\n".
302         " -n|--tracknum=NUM                     Set tracknum\n".
303         " -y|--year=NUM                         Set year\n".
304         " -1|--v1genre=GENRE                    Set ID3v1 genre\n".
305         " -c|--comment=COMMENT                  Set comment\n".
306         " -A|--delete-artist                    Delete artist\n".
307         " -L|--delete-album                     Delete album\n".
308         " -S|--delete-song                      Delete song\n".
309         " -N|--delete-tracknum                  Delete tracknum\n".
310         " -Y|--delete-year                      Delete year\n".
311         " -0|--delete-v1genre                   Delete ID3v1 genre\n".
312         " -C|--delete-comment                   Delete comment\n".
313         " -D|--delete|delete-all                Delete entire ID3 tag\n".
314         " -G|--delete-genre|--delete-all-tags   Delete all tags stored in genre\n".
315         " -g|-R|replace-all-tags|--genre=GENRE  Replace all tags in genre tag\n".
316         " -t|--add-tags|tags=TAG1,TAG2          Add tags to genre tag, merging with existing ones\n".
317         " -T|--delete-tags=TAG1,TAG2            Delete tags from genre\n".
318         " -r|--replace-tags TAGS1 TAGS2         Replace TAGS1 in genre with TAGS2\n".
319         " -V|--summary                          Summarize tags by directory\n" .
320         " -v|--verbose                          Verbose display\n".
321         " -h|--help                             This help\n".
322         " --                                    End of options\n");
323 }
324
325 __END__
326
327 =head1 NAME
328
329 id3fs-tag - Add files to id3fs index
330
331 =head1 SYNOPSIS
332
333 B<id3fs-tag> [B<-lvh>] S<[B<-d >I<basedir>]> S<[B<-f >I<dbpath>]> S<[B<-e >I<mp3,ogg,flac>]> [B<-->] [I<DIR>...]
334
335 =head1 DESCRIPTION
336
337 Extracts id3 tags from mp3 files (and comment tags from ogg and flac
338 files) and adds them to a sqlite database, ready for mounting
339 with L<id3fsd(8)>.
340
341 =head1 OPTIONS
342
343 =over 4
344
345 =item B<-l> | B<--list>
346
347 List tags in use in specified database.
348
349 =item S<B<-d >I<PATH>> | S<B<--dir=>I<PATH>>
350
351 Specify base directory of source files. All files will be indexed
352 relative to this point.
353
354 If not specified, defaults to the first non-option argument on the
355 command line. Note that to avoid ambiguities, if more than one
356 directory is specified on the command line, the base directory must
357 be specified explicitly.
358
359 All files indexed must be under the base directory.
360
361 =item S<B<-f >I<FILE>> | S<B<--database=>I<FILE>>
362
363 Database file to use. If not specified, defaults to
364 a hidden file called B<".id3fs"> under the base directory.
365
366 =item S<B<-e >I<EXT1,EXT2>> | S<B<--extensions=>I<EXT1,EXT2>>
367
368 File extensions to consider when indexing.
369 Defaults to B<.mp3>, B<.ogg> and B<.flac>.
370
371 =item B<-v>
372
373 Enable verbose operation.
374
375 =item B<-h>
376
377 Show a short help message.
378
379 =item B<-->
380
381 End of options.
382
383 =back
384
385 =head1 EXAMPLES
386
387 Index all files in the current directory:
388
389     id3fs-tag .
390
391 Index current directory, printing each subdirectory as it recurses
392 into it:
393
394     id3fs-tag -v .
395
396 Just index some sub-directories:
397
398     id3fs-tag -d . dir1 dir2
399
400 Store the database in a custom location:
401
402     id3fs-tag -f ~/.id3fs/index.sqlite .
403
404 Only index .mp3 and .flac files:
405
406     id3fs-tag -e mp3,flac .
407
408 =head1 BUGS
409
410 Please report any found to ianb@erislabs.net
411
412 =head1 SEE ALSO
413
414 L<id3fsd(8)>, L<MP3::Tag>, L<Audio::Flac::Header>, L<Ogg::Vorbis::Header>
415
416 =head1 AUTHOR
417
418 Ian Beckwith <ianb@erislabs.net>
419
420 Many thanks to Aubrey Stark-Toller for help wrangling SQL.
421
422 =head1 AVAILABILITY
423
424 The latest version can be found at:
425
426 L<http://erislabs.net/ianb/projects/id3fs/>
427
428 =head1 COPYRIGHT
429
430 Copyright (C) 2010  Ian Beckwith <ianb@erislabs.net>
431
432 This program is free software: you can redistribute it and/or modify
433 it under the terms of the GNU General Public License as published by
434 the Free Software Foundation, either version 3 of the License, or
435 (at your option) any later version.
436
437 This program is distributed in the hope that it will be useful,
438 but WITHOUT ANY WARRANTY; without even the implied warranty of
439 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
440 GNU General Public License for more details.
441
442 You should have received a copy of the GNU General Public License
443 along with this program.  If not, see <http://www.gnu.org/licenses/>.
444
445 =cut