Update soundtest
[grml-scripts.git] / usr_bin / arename.pl
1 #!/usr/bin/perl
2 use warnings;
3 use strict;
4
5 use Getopt::Std;
6 use File::Basename;
7 use File::Copy;
8 use MP3::Tag;
9 use Ogg::Vorbis::Header;
10
11 # documentation {{{
12 =head1 NAME
13
14 arename.pl - automatically rename audio files by tagging information
15
16 =head1 SYNOPSIS
17
18 arename.pl [OPTION(s)] FILE(s)...
19
20 =head1 OPTIONS AND ARGUMENTS
21
22 =over 8
23
24 =item B<-d>
25
26 Go into dryrun mode.
27
28 =item B<-f>
29
30 Overwrite files if needed.
31
32 =item B<-h>
33
34 Display a short help text.
35
36 =item B<-V>
37
38 Display version infomation.
39
40 =item B<-v>
41
42 Enable verbose output.
43
44 =item B<-p> E<lt>prefixE<gt>
45
46 Define a prefix for destination files.
47
48 =item B<-T> E<lt>templateE<gt>
49
50 Define a compilation template.
51
52 =item B<-t> E<lt>templateE<gt>
53
54 Define a generic template.
55
56 =item I<FILE(s)...>
57
58 Input files, that are subject for renaming.
59
60 =back
61
62 =head1 DESCRIPTION
63
64 B<arename.pl> is a tool that is able to rename audio files by looking at
65 a file's tagging information, from which it will assemble a consistent
66 destination file name. The format of that filename is configurable for the
67 user by the use of template strings.
68
69 B<arename.pl> currently supports two widely used audio formats, namely
70 MPEG Layer3 and ogg vorbis. The format, that B<arename.pl> will assume
71 for each input file is determined by the file's filename-extension
72 (I<.mp3> vs. I<.ogg>). The extension check is case-insensitive.
73
74 By default, B<arename.pl> will refuse to overwrite destination files,
75 if the file in question already exists. You can force overwriting by
76 supplying the B<-f> option.
77
78 =head1 FILES
79
80 B<arename.pl> uses up to two configuration files. As for most programs,
81 the script will try to read a configuration file, that is located in the
82 user's I<home directory>. In addition to that, it will try to load I<local>
83 configuration files, if it finds appropriately named files in the
84 I<current directory>.
85
86 =over 8
87
88 =item B<~/.arenamerc>
89
90 per-user global configuration file.
91
92 =item B<./.arename.local>
93
94 per-directory local configuration file.
95
96 =back
97
98 =head2 File format
99
100 The format of the aforementioned files is pretty simple.
101 It is parsed line by line. Empty lines, lines only containing whitespace
102 and lines, whose first non whitespace character is a hash character (I<#>)
103 are ignored.
104
105 Each line consists of one or two parts. If there are two parts,
106 they are separated by whitespace. The first part of the line will be used
107 as the identifier of a setting (eg. I<verbose>). The second part (read: the
108 rest of the line) is used as the value of the setting. (No quoting, or whatsoever
109 is required.)
110
111 If a line consists of only one part, that means the setting is switched on.
112
113 =head2 Configuration file example
114
115   # switch on verbosity
116   verbose
117
118   # the author is crazy! use a sane template by default. :-)
119   template &artist - &album (&year) - &tracknumber. &tracktitle
120
121 =head1 SETTINGS
122
123 The following settings are supported in all configuration files:
124
125 =over 8
126
127 =item B<comp_template>
128
129 Defines a template to use with files that provide a compilation tag
130 (for 'various artist' CDs, for example). This setting can still be
131 overwritten by the B<-T> command line option. (default value:
132 I<va/&album/&tracknumber - &artist - &tracktitle>)
133
134 =item B<default_year>
135
136 Defines a default year, for files, that lack this information.
137 (default value: I<undefined>)
138
139 =item B<prefix>
140
141 Defines a prefix for destination files. This setting can still be
142 overwritten by the B<-p> command line option. (default value: I<.>)
143
144 =item B<sepreplace>
145
146 Tagging information strings may contain slashes, which is a pretty bad
147 idea on most filesystems. Therefore, you can define a string, that replaces
148 slashes with the value of this setting. (default value: I<_>)
149
150 =item B<template>
151
152 Defines a template to use with files that do not provide a compilation tag
153 (or where the compilation tag and the artist tag are exactly the same).
154 This setting can still be overwritten by the B<-T> command line option.
155 (default value: I<&artist[1]/&artist/&album/&tracknumber - &tracktitle>)
156
157 =item B<tnpad>
158
159 This defines the width, to which the tracknumber field is padded with zeros
160 on the left. (default value: I<2>)
161
162 =item B<verbose>
163
164 Switches on verbosity by default. (default value: I<off>)
165
166 =back
167
168 =head1 TEMPLATE FORMAT
169
170 B<arename.pl>'s templates are quite simple, yet powerful.
171
172 At simplest, a template is just a fixes character string. However, that would
173 not be exactly useful. So, the script is able to expand certain expressions
174 with information gathered from the file's tagging information.
175
176 The expressions can have two slightly different forms:
177
178 =over 8
179
180 =item B<&>I<identifier>
181
182 The simple form.
183
184 =item B<&>I<identifier>B<[>I<length>B<]>
185
186 The "complex" form. The I<length> argument in square brackets defines the
187 maximum length, to which the expression should be expanded.
188
189 =back
190
191 =head2 Available expression identifiers
192
193 The data, that is expanded is derived from tagging information in
194 the audio files. For I<.ogg> files, the tag checking B<arename.pl> does
195 is case insensitive and the first matching tag will be used.
196
197 =over 8
198
199 =item B<album>
200
201 Guess.
202
203 =item B<artist>
204
205 Guess again.
206
207 =item B<compilation>
208
209 For I<.ogg> this is filled with information found in the 'albumartist' tag.
210 For I<.mp3> this is filled with information from the id3v2 TPE2 frame.
211 If the mp3 file only provides a id3v1 tag, this is not supported.
212
213 =item B<tracknumber>
214
215 The number of the position of the track on the disc. Obviously. However, this
216 can be in the form of '12' or '12/23'. In the second form, only the part left
217 of the slash is used. The tracknumber is a little special, as you can defined
218 to what width it should be padded with zeros on the left (see I<tnpad> setting
219 in L<arename(1)/SETTINGS>).
220
221 =item B<tracktitle>
222
223 Well...
224
225 =item B<year>
226
227 Year (id3v1), TYER (id3v2) or DATE tag (.ogg).
228
229 =back
230
231 =head1 SEE ALSO
232
233 L<Ogg::Vorbis::Header(3)> and L<MP3::Tag(3)>.
234
235 =head1 AUTHOR
236
237 Frank Terbeck E<lt>ft@bewatermyfriend.orgE<gt>,
238
239 Please report bugs.
240
241 =head1 LICENSE
242
243  Copyright 2007
244  Frank Terbeck <ft@bewatermyfriend.org>, All rights reserved.
245
246  Redistribution and use in source and binary forms, with or without
247  modification, are permitted provided that the following conditions
248  are met:
249
250    1. Redistributions of source code must retain the above
251       copyright notice, this list of conditions and the following
252       disclaimer.
253    2. Redistributions in binary form must reproduce the above
254       copyright notice, this list of conditions and the following
255       disclaimer in the documentation and/or other materials
256       provided with the distribution.
257
258   THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED
259   WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
260   OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
261   DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS OF THE
262   PROJECT BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
263   EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
264   PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
265   PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
266   OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
267   (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
268   OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
269
270 =cut
271 #}}}
272 # variables {{{
273 my (
274     %defaults, %methods, %opts,
275     $dryrun, $comp_template, $force, $prefix,
276     $sepreplace, $template, $tnpad, $verbose
277 );
278 my ($NAME, $VERSION) = ( 'arename.pl', 'v0.3' );
279 #}}}
280 sub apply_defaults { #{{{
281     my ($datref) = @_;
282
283     foreach my $key (keys %defaults) {
284         if (!defined $datref->{$key}) {
285             if ($verbose) {
286                 print "  -!- Setting ($key) to \"$defaults{$key}\".\n";
287             }
288             $datref->{$key} = $defaults{$key};
289         }
290     }
291 }
292 #}}}
293 sub arename { #{{{
294     my ($file, $datref, $ext) = @_;
295     my ($t, $newname);
296
297     apply_defaults($datref);
298
299     if ($verbose) { #{{{
300         print "  -!- Artist     : \"" .
301             (defined $datref->{artist}      ? $datref->{artist}      : "-.-")
302             . "\"\n";
303         print "  -!- Compilation: \"" .
304             (defined $datref->{compilation} ? $datref->{compilation} : "-.-")
305             . "\"\n";
306         print "  -!- Album      : \"" .
307             (defined $datref->{album}       ? $datref->{album}       : "-.-")
308             . "\"\n";
309         print "  -!- Tracktitle : \"" .
310             (defined $datref->{tracktitle}  ? $datref->{tracktitle}  : "-.-")
311             . "\"\n";
312         print "  -!- Tracknumber: \"" .
313             (defined $datref->{tracknumber} ? $datref->{tracknumber} : "-.-")
314             . "\"\n";
315         print "  -!- Year       : \"" .
316             (defined $datref->{year}        ? $datref->{year}        : "-.-")
317             . "\"\n";
318     } #}}}
319
320     if (defined $datref->{compilation}
321         && $datref->{compilation} ne $datref->{artist}) {
322
323         $t = $comp_template;
324     } else {
325         $t = $template;
326     }
327
328     $newname = expand_template($t, $datref);
329     if (!defined $newname) {
330         return;
331     }
332
333     $newname = $prefix . '/' . $newname . '.' . $ext;
334
335     if (file_eq($newname, $file)) {
336         print "  -!- ($file)\n      would stay the way it is, skipping.\n";
337         return;
338     }
339
340     if (-e $newname && !$force) {
341         print "  -!- ($newname) exists.\n      use '-f' to force overwriting.\n";
342         return;
343     }
344
345     ensure_dir(dirname($newname));
346
347     print "  -!- mv '$file' \\\n" . 
348           "         '$newname'\n";
349
350     if (!$dryrun) {
351         xrename($file, $newname);
352     }
353 }
354 #}}}
355 sub ensure_dir { #{{{
356     # think: mkdir -p /foo/bar/baz
357     my ($wantdir) = @_;
358     my (@parts, $sofar);
359
360     if (-d $wantdir) {
361         return;
362     }
363
364     if ($wantdir =~ '^/') {
365         $sofar = '/';
366     } else {
367         $sofar = '';
368     }
369
370     @parts = split(/\//, $wantdir);
371     foreach my $part (@parts) {
372         if ($part eq '') {
373             next;
374         }
375         $sofar = (
376                   $sofar eq ''
377                     ? $part
378                     : (
379                         $sofar eq '/'
380                           ? '/' . $part
381                           : $sofar . "/" . $part
382                       )
383                  );
384
385         if (!-d $sofar) {
386             if ($dryrun || $verbose) {
387                 print "  -!- mkdir \"$sofar\"\n";
388             }
389             if (!$dryrun) {
390                 mkdir($sofar) or die "  -!- Could not mkdir($sofar).\n" .
391                                      "  -!- Reason: $!\n";
392             }
393         }
394     }
395 }
396 #}}}
397 sub expand_template { #{{{
398     my ($template, $datref) = @_;
399     my @tags = (
400         'album',
401         'artist',
402         'compilation',
403         'tracknumber',
404         'tracktitle',
405         'year'
406     );
407
408     foreach my $tag (@tags) {
409         my ($len, $token);
410
411         while ($template =~ m/&$tag(\[(\d+)\]|)/) {
412             $len = 0;
413             if (defined $2) { $len = $2; }
414
415             if (!defined $datref->{$tag} || $datref->{$tag} eq '') {
416                 warn "  -!- $tag not defined, but required by template. Giving up.\n";
417                 return undef;
418             }
419
420             if ($len > 0) {
421                 $token = substr($datref->{$tag}, 0, $len);
422             } else {
423                 if ($tag eq 'tracknumber') {
424                     my $val;
425                     if ($datref->{$tag} =~ m/^([^\/]*)\/.*$/) {
426                         $val = $1;
427                     } else {
428                         $val = $datref->{$tag};
429                     }
430                     $token = sprintf "%0" . $tnpad . "d", $val;
431                 } else {
432                     $token = $datref->{$tag};
433                 }
434             }
435             if ($token =~ m!/!) {
436                 if ($verbose) {
437                     print "  -!- Found directory seperator in token.\n";
438                     print "  -!- Replacing with \"$sepreplace\".\n";
439                 }
440                 $token =~ s!/!$sepreplace!g;
441             }
442             $template =~ s/&$tag(\[(\d+)\]|)/$token/;
443         }
444     }
445
446     return $template;
447 }
448 #}}}
449 sub file_eq { #{{{
450     my ($f0, $f1) = @_;
451     my (@stat0, @stat1);
452
453     if (!-e $f0 || !-e $f1) {
454         # one of the two doesn't even exist, can't be the same then.
455         return 0;
456     }
457
458     @stat0 = stat $f0 or die "Could not stat($f0): $!\n";
459     @stat1 = stat $f1 or die "Could not stat($f1): $!\n";
460
461     if ($stat0[0] == $stat1[0] && $stat0[1] == $stat1[1]) {
462         # device and inode are the same. same file.
463         return 1;
464     }
465
466     return 0;
467 }
468 #}}}
469 sub process_mp3 { #{{{
470     my ($file) = @_;
471     my ($mp3, %data, $info);
472
473     $mp3 = MP3::Tag->new($file);
474
475     if (!defined $mp3) {
476         print "  -!- Failed to open \"$file\".\n  -!- Reason: $!\n";
477         return;
478     }
479
480     $mp3->get_tags;
481
482     if (!exists $mp3->{ID3v1} && !exists $mp3->{ID3v2}) {
483         print "  -!- No tag found. Ignoring.\n";
484         $mp3->close();
485         return;
486     }
487
488     if (exists $mp3->{ID3v2}) {
489         ($data{artist},      $info) = $mp3->{ID3v2}->get_frame("TPE1");
490         ($data{compilation}, $info) = $mp3->{ID3v2}->get_frame("TPE2");
491         ($data{album},       $info) = $mp3->{ID3v2}->get_frame("TALB");
492         ($data{tracktitle},  $info) = $mp3->{ID3v2}->get_frame("TIT2");
493         ($data{tracknumber}, $info) = $mp3->{ID3v2}->get_frame("TRCK");
494         ($data{year},        $info) = $mp3->{ID3v2}->get_frame("TYER");
495     } elsif (exists $mp3->{ID3v1}) {
496         print "  -!- Only found ID3v1 tag.\n";
497         $data{artist}      = $mp3->{ID3v1}->artist;
498         $data{album}       = $mp3->{ID3v1}->album;
499         $data{tracktitle}  = $mp3->{ID3v1}->title;
500         $data{tracknumber} = $mp3->{ID3v1}->track;
501         $data{year}        = $mp3->{ID3v1}->year;
502     }
503
504     $mp3->close();
505
506     arename($file, \%data, 'mp3');
507 }
508 #}}}
509 sub process_ogg { #{{{
510     my ($file) = @_;
511     my ($ogg, %data, @tags);
512
513     $ogg = Ogg::Vorbis::Header->load($file);
514
515     if (!defined $ogg) {
516         print "  -!- Failed to open \"$file\".\n  -!- Reason: $!\n";
517         return;
518     }
519
520     @tags = $ogg->comment_tags;
521
522     foreach my $tag (@tags) {
523         my ($realtag, $value);
524         if (!(
525                 $tag =~ m/^ALBUM$/i         ||
526                 $tag =~ m/^ARTIST$/i        ||
527                 $tag =~ m/^TITLE$/i         ||
528                 $tag =~ m/^TRACKNUMBER$/i   ||
529                 $tag =~ m/^DATE$/i          ||
530                 $tag =~ m/^ALBUMARTIST$/i
531             )) { next; }
532
533         $value = join(' ', $ogg->comment($tag));
534         if ($tag =~ m/^ALBUM$/i) {
535             $realtag = 'album';
536         } elsif ($tag =~ m/^ARTIST$/i) {
537             $realtag = 'artist';
538         } elsif ($tag =~ m/^TITLE$/i) {
539             $realtag = 'tracktitle';
540         } elsif ($tag =~ m/^TRACKNUMBER$/i) {
541             $realtag = 'tracknumber';
542         } elsif ($tag =~ m/^DATE$/i) {
543             $realtag = 'year';
544         } elsif ($tag =~ m/^ALBUMARTIST$/i) {
545             $realtag = 'compilation';
546         } else {
547             die "This should not happen. Report this BUG. ($tag, $value)";
548         }
549
550         if (!defined $data{$realtag}) {
551             $data{$realtag} = $value;
552         }
553     }
554
555     arename($file, \%data, 'ogg');
556 }
557 #}}}
558 sub process_warn { #{{{
559     my ($file) = @_;
560
561     warn "  -!- No method for handling \"$file\".\n";
562 }
563 #}}}
564 sub rcload { #{{{
565     my ($file, $desc) = @_;
566     my ($fh, $retval);
567     my $count = 0;
568     my $lnum  = 0;
569
570     if (!open($fh, "<$file")) {
571         warn "Failed to read $desc ($file).\n  -!- Reason: $!\n";
572         return 1;
573     }
574
575     print "Reading \"$file\"...\n";
576
577     while (my $line = <$fh>) {
578         chomp($line);
579         $lnum++;
580
581         if ($line =~ m/^\s*#/ || $line =~ m/^\s*$/) {
582             next;
583         }
584
585         $line =~ s/^\s*//;
586         my ($key,$val) = split(/\s+/, $line, 2);
587
588         if ($key eq 'template') {
589             $template = $val;
590         } elsif ($key eq 'comp_template') {
591             $comp_template = $val;
592         } elsif ($key eq 'default_year') {
593             $defaults{year} = $val;
594         } elsif ($key eq 'sepreplace') {
595             $sepreplace = (defined $val ? $val : "");
596         } elsif ($key eq 'tnpad') {
597             $tnpad = $val;
598         } elsif ($key eq 'verbose') {
599             $verbose = 1;
600         } elsif ($key eq 'prefix') {
601             $prefix = $val;
602         } else {
603             warn "$file,$lnum: invalid line '$line'.\n";
604             return -1;
605         }
606
607         $count++;
608     }
609     close $fh;
610
611     print "  -!- Read $desc.\n  -!- $count valid items.\n";
612     return 0;
613 }
614 #}}}
615 sub xrename { #{{{
616     # a rename() replacement, that implements renames across
617     # filesystems via File::copy() + unlink().
618     # This assumes, that source and destination directory are
619     # there, because it stat()s them, to check if it can use
620     # rename().
621     my ($src, $dest) = @_;
622     my (@stat0, @stat1, $d0, $d1, $cause);
623
624     $d0 = dirname($src);
625     $d1 = dirname($dest);
626     @stat0 = stat $d0 or die "Could not stat($d0): $!\n";
627     @stat1 = stat $d1 or die "Could not stat($d1): $!\n";
628
629     if ($stat0[0] == $stat1[0]) {
630         $cause = 'rename';
631         rename $src, $dest or goto err;
632     } else {
633         $cause = 'copy';
634         copy($src, $dest) or goto err;
635         $cause = 'unlink';
636         unlink $src or goto dir;
637     }
638
639     return 0;
640
641 err:
642     die "  -!- Could not rename($src, $dest);\n" .
643         "  -!- Reason: $cause(): $!\n";
644 }
645 #}}}
646 # handle options {{{
647
648 if ($#ARGV == -1) {
649     $opts{h} = 1;
650 } else {
651     if (!getopts('dfhVvp:T:t:', \%opts)) {
652         if (exists $opts{t} && !defined $opts{t}) {
653             die " -t *requires* a string argument!\n";
654         } elsif (exists $opts{T} && !defined $opts{T}) {
655             die " -T *requires* a string argument!\n";
656         } elsif (exists $opts{p} && !defined $opts{p}) {
657             die " -p *requires* a string argument!\n";
658         } else {
659             die "    Try $NAME -h\n";
660         }
661     }
662 }
663
664 if (defined $opts{h}) {
665     print " Usage:\n  $NAME [-d,-f,-h,-V,-v,-p <prefix>,-[Tt] <template>] FILE(s)...\n\n";
666     print "    -d                Go into dryrun mode.\n";
667     print "    -f                Overwrite files if needed.\n";
668     print "    -h                Display this help text.\n";
669     print "    -V                Display version infomation.\n";
670     print "    -v                Enable verbose output.\n";
671     print "    -p <prefix>       Define a prefix for destination files.\n";
672     print "    -T <template>     Define a compilation template.\n";
673     print "    -t <template>     Define a generic template.\n";
674     print "\n";
675     exit 0;
676 }
677
678 if (defined $opts{V}) {
679     print " $NAME $VERSION\n";
680     exit 0;
681 }
682
683 #}}}
684 # set defaults {{{
685
686 $dryrun        = 0;
687 $force         = 0;
688 $prefix        = '.';
689 $sepreplace    = '_';
690 $tnpad         = 2;
691 $verbose       = 0;
692 $comp_template = "va/&album/&tracknumber - &artist - &tracktitle";
693 $template      = "&artist[1]/&artist/&album/&tracknumber - &tracktitle";
694
695 #}}}
696 # reading config file(s) {{{
697
698 my $rc = $ENV{HOME} . "/.arenamerc";
699 my $retval = rcload($rc, "arename.pl configuration");
700 if ($retval < 0) {
701     die "Error(s) in \"$rc\". Aborting.\n";
702 } elsif ($retval > 0) {
703     warn "Error opening configuration; using defaults.\n";
704 }
705
706 if (-r "./.arename.local") {
707     $rc = "./.arename.local";
708     $retval = rcload($rc, "local configuration");
709     if ($retval < 0) {
710         die "Error(s) in \"$rc\". Aborting.\n";
711     } elsif ($retval > 0) {
712         warn "Error opening local configuration.\n";
713     }
714 }
715
716 print "\n";
717
718 #}}}
719 # let cmd line options overwrite {{{
720
721 if ($#ARGV == -1) {
722     die "No input files. See: $NAME -h\n";
723 }
724
725 if (defined $opts{f}) {
726     $force = $opts{f};
727 }
728
729 if (defined $opts{p}) {
730     $prefix = $opts{p};
731 }
732
733 if (defined $opts{t}) {
734     $template = $opts{t};
735 }
736
737 if (defined $opts{T}) {
738     $comp_template = $opts{T};
739 }
740
741 if (defined $opts{d}) {
742     $dryrun = $opts{d};
743 }
744
745 if (defined $opts{v}) {
746     $verbose = $opts{v};
747 }
748
749 undef %opts;
750
751 #}}}
752 # process what's left on the commandline aka. main() {{{
753 %methods = (
754     '.mp3$' => \&process_mp3,
755     '.ogg$' => \&process_ogg
756 );
757
758 if ($dryrun) {
759     print "+++ We are on a dry run!\n";
760 }
761
762 if ($verbose) {
763     print "+++ Running verbose.\n";
764 }
765
766 if ($dryrun || $verbose) {
767     print "\n";
768 }
769
770 foreach my $file (@ARGV) {
771     my $done = 0;
772     print "Processing: $file\n";
773     if (-l $file) {
774         warn "  -!- Refusing to handle symbolic links ($file).\n";
775         next;
776     }
777     if (! -r $file) {
778         warn "  -!- Can't read \"$file\": $!\n";
779         next;
780     }
781
782     foreach my $method (sort keys %methods) {
783         if ($file =~ m!$method!i) {
784             $methods{$method}->($file);
785             $done = 1;
786         }
787     }
788
789     if (!$done) {
790         process_warn($file);
791     }
792 }
793 #}}}