#!/usr/bin/perl -w # by tzz@lifelogs.com # license: Perl Artistic License use strict; use warnings; use File::Basename; use List::Compare; use WebService::FreeDB; use AppConfig qw/:argcount/; use MP3::Tag; # we will use V2 tags everywhere we can use Data::Dumper; use Term::ReadKey; use Term::ReadLine; use utf8; use Encode qw/:all/; # for the -lrp option use Lingua::RU::PhTranslit; # This file looks best with the folding-mode.el package for Emacs, # which folds everything between # {{{ and # }}} marks. The vim # editor also does a nice job of folding this file with the default # settings. # these are the only search names that are used by name use constant SEARCH_ALL => 'all'; # a 1 for the value means to use the function named so my %lrp_modes = ( koi2phtr => [ \&koi2phtr ], phtr2koi => [ \&phtr2koi ], koi2win => [ \&koi2win ], win2koi => [ \&win2koi ], koi2alt => [ \&koi2alt ], alt2koi => [ \&alt2koi ], win2phtr => [ \&win2koi, \&koi2phtr ], alt2phtr => [ \&alt2koi, \&koi2phtr ], phtr2win => [ \&phtr2koi, \&koi2win ], phtr2alt => [ \&phtr2koi, \&koi2alt ], ); my %freedb_searches = ( artist => { keywords => [], abbrev => 'I', tagequiv => 'TPE1' }, title => { keywords => [], abbrev => 'T', tagequiv => 'TALB' }, track => { keywords => [], abbrev => 'K', tagequiv => 'TIT2' }, rest => { keywords => [], abbrev => 'R', tagequiv => 'COMM' }, ); # maps ID3 v2 tag info to WebService::FreeDB info my %info2freedb = ( TALB => 'album', # this used to be 'cdname' TPE1 => 'artist', ); my %supported_frames = ( TIT1 => 1, TIT2 => 1, TRCK => 1, TALB => 1, TPE1 => 1, COMM => 1, WXXX => 1, TYER => 1, ); my @supported_frames = keys %supported_frames; my $term = new Term::ReadLine 'Input> '; # global input # {{{ set up AppConfig and process -help my $config = AppConfig->new(); $config->define( DEBUG => { ARGCOUNT => ARGCOUNT_ONE, DEFAULT => 0, ALIAS => 'D' }, CONFIG_FILE => { ARGCOUNT => ARGCOUNT_ONE, DEFAULT => 0, ALIAS => 'F' }, HELP => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0, ALIAS => 'H' }, DUMP => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0 }, ACCEPT_ALL => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0, ALIAS => 'C' }, ACCEPT_ALL_TRACK_GUESSES => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0 }, DRYRUN => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0, ALIAS => 'N' }, GUESS_TRACK_NUMBERS_ONLY => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0, ALIAS => 'G' }, TO_OGG_ONLY => { ARGCOUNT => ARGCOUNT_NONE, ALIAS => 'OGG' }, OGG_MONO => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0}, OGG_LOW => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0}, STRIP_COMMENT_ONLY => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0, ALIAS => 'SC' }, MASS_TAG_ONLY => { ARGCOUNT => ARGCOUNT_HASH, ALIAS => 'M' }, RENAME_ONLY => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0, ALIAS => 'RO' }, CLASSIFY_ONLY => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0, ALIAS => 'CO' }, RECODE_ONLY => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => 0, ALIAS => 'RCO' }, RECODE_FROM => { ARGCOUNT => ARGCOUNT_ONE, ALIAS => 'RCF', DEFAULT => 'cp1251' }, RECODE_TO => { ARGCOUNT => ARGCOUNT_ONE, ALIAS => 'RCT', DEFAULT => 'latin1' }, LINGUA_RU_PHONETIC_TRANSLIT => { ARGCOUNT => ARGCOUNT_ONE, ALIAS => 'LRP', DEFAULT => '' }, RENAME_MAX_CHARS => { ARGCOUNT => ARGCOUNT_ONE, DEFAULT => 38}, RENAME_FORMAT => { ARGCOUNT => ARGCOUNT_ONE, DEFAULT => '%a-%t-%n-%c-%s.mp3'}, RENAME_BADCHARS => { ARGCOUNT => ARGCOUNT_LIST, ALIAS => 'RB' }, RENAME_REPLACECHARS => { ARGCOUNT => ARGCOUNT_LIST, ALIAS => 'RR' }, RENAME_REPLACEMENT => { ARGCOUNT => ARGCOUNT_ONE, DEFAULT => '_' }, FREEDB_HOST => { ARGCOUNT => ARGCOUNT_ONE, DEFAULT => 'http://www.freedb.org', }, OR => { ARGCOUNT => ARGCOUNT_NONE, DEFAULT => '0', }, SEARCH_ALL() => { ARGCOUNT => ARGCOUNT_LIST, ALIAS => 'A' }, ); foreach my $search (keys %freedb_searches) { $config->define($search => { ARGCOUNT => ARGCOUNT_LIST, ALIAS => $freedb_searches{$search}->{abbrev}, }); } $config->args(); $config->file($config->CONFIG_FILE()) if $config->CONFIG_FILE(); unless (scalar @{$config->RENAME_BADCHARS()}) { push @{$config->RENAME_BADCHARS()}, split(//, ":\"`!'?&[]()/;\n\t"); } unless (scalar @{$config->RENAME_REPLACECHARS()}) { push @{$config->RENAME_REPLACECHARS()}, split(//, " "); } if ($config->HELP()) { print <RENAME_MAX_CHARS()} -rename_replacement X : character to use when replacing, default: [${\$config->RENAME_REPLACEMENT()}] -rename_format (-f) F : format for renaming; default "${\$config->RENAME_FORMAT()}" %a -> Artist %t -> Track number %n -> Album name %c -> Comment %s -> Song title -guess_track_numbers_only (-g) : guess track numbers using the file name, then exit -to_ogg_only (-ogg) : convert tracks to OGG at default quality, then exit (needs mpg123 and oggenc in the path) -ogg_mono : when converting to OGG, downmix to mono -ogg_low : when converting to OGG, downsample to 16KHzogg -rename_only (-ro) : rename tracks using the given format (see -rename_format), then exit -classify_only (-co) : put tracks in ARTIST/ALBUM directories -mass_tag_only (-m) A=X -m B=Y : mass-tag files (tag element A is X, B is Y), then exit (tag elements available: @supported_frames) -strip_comment_only (-sc) : strip comments and URLs, then exit -recode_only (-rco): convert tags from encoding A to encoding B -recode_from (-rcf) A: encoding A (original encoding), default: [${\$config->RECODE_FROM()}] -recode_to (-rct) B: encoding B (new encoding), default: [${\$config->RECODE_TO()}] -lingua_ru_phonetic_translit (-lrp) MODE: like recode_only, but uses Lingua::RU::PhTranslit functions. -lrp options: @{[ keys %lrp_modes ]} Repeatable options (you can specify them more than once, K is the keyword): -all (-a) K : search everywhere -artist (-i) K : search for these artists -title (-t) K : search for these titles -track (-k) K : search for these tracks -rest (-r) K : search for these keywords everywhere else Note that the repeatable options are cumulative, so artist A and title B will produce matches for A and B, not A or B. In the same way, artist A and artist B will produce matches for A and B, not A or B. If you want to match A or B terms, use -or, for instance: $0 -or -artist "pink floyd" -artist "the beatles" EOHIPPUS exit; } # }}} # {{{ handle the one-shot options if ($config->GUESS_TRACK_NUMBERS_ONLY() || $config->STRIP_COMMENT_ONLY() || scalar keys %{$config->MASS_TAG_ONLY()}) { foreach my $file (@ARGV) { my $tag = get_tag($file, 1); unless (defined $tag) { warn "No ID3 TAG info in '$file', skipping"; next; } next if $config->DRYRUN(); # delegate stripping comments to the mass tagging function if ($config->STRIP_COMMENT_ONLY()) { $config->MASS_TAG_ONLY()->{COMM} = ''; $config->MASS_TAG_ONLY()->{WXXX} = ''; } if (scalar keys %{$config->MASS_TAG_ONLY()}) { foreach (keys %{$config->MASS_TAG_ONLY()}) { unless (exists $supported_frames{$_}) { warn "Unsupported tag element $_ requested for mass tagging, skipping"; next; } $tag->{$_} = $config->MASS_TAG_ONLY()->{$_}; } set_tag($file, $tag); } else { my $track_number_guess = guess_track_number($file, $tag); next if $config->DRYRUN(); if (defined $track_number_guess && read_yes_no("Is track number $track_number_guess OK for '$file'?", 1)) { $tag->{TRCK} = $track_number_guess; set_tag ($file, $tag); } else { warn "Could not guess a track number for file $file, sorry"; } } } exit 0; } # }}} # {{{ handle the -to_ogg_only option if ($config->TO_OGG_ONLY()) { my %map = ( '-c' => 'COMM', '-t' => 'TIT2', '-a' => 'TPE1', '-l' => 'TALB', '-N' => 'TRCK', ); foreach my $file (@ARGV) { my $tag = get_tag($file, 1); # the extra parameter will ask us about upgrading V1 to V2 unless (defined $tag) { warn "No ID3 TAG info in '$file', skipping"; next; } my ($base) = ($file =~ m/(.+)\.[^.]+/); next unless defined $base; my $wave = "$base.wav"; my $ogg = "$base.ogg"; if (-f $ogg && ($config->ACCEPT_ALL() || read_yes_no("File $ogg already exists, keep?", 1))) { print "File $ogg already exists, skipping\n"; next; } if (-f $wave && -s $wave > 4096) # reasonable lower limit { unless ($config->ACCEPT_ALL() || read_yes_no("File $wave already exists, use?", 1)) { unlink $wave; } } unless (-f $wave) { print "Writing $wave\n"; system ('mpg123', '-q', '-w', $wave, $file) && die "mpg123 could not be run"; } my @options; foreach my $option (keys %map) { if (exists $tag->{$map{$option}} && defined $tag->{$map{$option}} && length $tag->{$map{$option}}) { push @options, $option; push @options, $tag->{$map{$option}}; } } if ($config->OGG_MONO) { push @options, '--downmix'; } if ($config->OGG_LOW) { push @options, '--resample', 16000; } if (-f $ogg && ($config->ACCEPT_ALL() || read_yes_no("File $ogg already exists, keep?", 1))) { next; } print "Writing $ogg\n"; system ('oggenc', '-o', $ogg, @options, $wave) && die "oggenc could not be run"; if (-s $ogg > 4096) { unlink $wave; } } exit 0; } # }}} # {{{ handle the -classify_only option if ($config->CLASSIFY_ONLY()) { foreach my $file (@ARGV) { my $tag = get_tag($file, 1); # the extra parameter will ask us about upgrading V1 to V2 unless (defined $tag) { warn "No ID3 TAG info in '$file', skipping"; next; } my $artist = $tag->{TPE1} || 'Unknown'; my $album = $tag->{TALB} || 'Unknown'; $artist = fix_chars($artist); $album = fix_chars($album); my $dir = $artist . '/' . $album; mkdir $artist; mkdir $dir; print "$file -> $dir\n"; rename $file, "$dir/$file"; } exit 0; } # }}} # {{{ handle the -rename_only option if ($config->RENAME_ONLY()) { foreach my $file (@ARGV) { my $tag = get_tag($file, 1); # the extra parameter will ask us about upgrading V1 to V2 unless (defined $tag) { warn "No ID3 TAG info in '$file', skipping"; next; } my %map = ( '%c' => 'COMM', '%s' => 'TIT2', '%a' => 'TPE1', '%t' => 'TALB', '%n' => 'TRCK', ); my $name = $config->RENAME_FORMAT(); foreach my $key (keys %map) { my $tagkey = $map{$key}; my $replacement = ''; if (exists $tag->{$tagkey}) { $replacement = substr $tag->{$tagkey}, 0, $config->RENAME_MAX_CHARS(); # limit to N characters if ($tagkey eq 'TRCK' && $replacement =~ m/^\d$/) { $replacement = "0$replacement"; } } $replacement =~ s/%/{{{%}}}/g; # this is how we preserve %a in the fields, for example $name =~ s/$key/$replacement/; } $name =~ s/{{{%}}}/%/g; # turn the {{{%}}} back into % in the fields print "The name after % expansion is $name\n" if $config->DEBUG(); $name = fix_chars($name); if ($name eq $file) { # do nothing print "Renaming $file is unnecessary, it already answers to our high standards\n" if $config->DEBUG(); } elsif (-e $name) { warn "Could not use name $name, it's already taken by an existing file or directory $file"; } elsif ($config->ACCEPT_ALL() || read_yes_no("Is name $name OK for '$file'?", 1)) { next if $config->DRYRUN(); print "Renaming $file -> $name\n"; rename($file, $name); } else { # do nothing } } exit 0; } # }}} # {{{ handle the -recode_only option if ($config->RECODE_ONLY() || $config->LINGUA_RU_PHONETIC_TRANSLIT()) { binmode STDOUT; foreach my $file (@ARGV) { my $tag = get_tag($file, 1); # the extra parameter will ask us about upgrading V1 to V2 unless (defined $tag) { warn "No ID3 TAG info in '$file', skipping"; next; } foreach my $entry (keys %$tag) { my $data = $tag->{$entry}; my $new_data = $data; if ($config->RECODE_ONLY()) { my $decoded = decode($config->RECODE_FROM(), $data); my $new_data = ($config->RECODE_TO() eq 'utf8' ) ? $decoded : encode($config->RECODE_TO(), $decoded); print "Tag item $entry: recoded value $new_data\n"; } elsif ($config->LINGUA_RU_PHONETIC_TRANSLIT()) { my $mode = $config->LINGUA_RU_PHONETIC_TRANSLIT(); die "Invalid LRP mode $mode, aborting" unless exists $lrp_modes{$mode}; my $functions = $lrp_modes{$mode}; foreach my $function (@$functions) { $new_data = $function->($data); $data = $new_data; } } $tag->{$entry} = $new_data # NOT this: encode('utf8', $encoded) unless $config->DRYRUN(); } set_tag($file, $tag); } exit 0; } # }}} my $cddb = WebService::FreeDB->new(DEBUG => $config->DEBUG(), HOST => $config->FREEDB_HOST); die "Could not initialize the FreeDB service" unless defined $cddb; # we use the "disc" spelling as used by WebService::FreeDB my %discs; my %olddiscinfo; my %disc_counts; my @common; # {{{ check the search counts and enter interactive mode if needed my $search_count = 0; foreach my $search (keys %freedb_searches) { $search_count += scalar @{$config->get($search)}; } print "Search count is $search_count\n" if $config->DEBUG(); if (0 == $search_count) { if(read_yes_no("No searches requested, enter interactive search spec?", 1)) { my %guessed = ( artist => {}, track => {}); foreach my $file (@ARGV) { my ($artist_guess, $track_guess) = guess_artist_and_track($file); $guessed{artist}->{$artist_guess} = 1 if defined $artist_guess; $guessed{track}->{$track_guess} = 1 if defined $track_guess; } foreach my $search (keys %freedb_searches) { if (read_yes_no("Search by $search?", 0)) { foreach my $guess (keys %{$guessed{$search}}) { if (read_yes_no("Search for guessed $search $guess?", 0)) { push @{$config->get($search)}, $guess; } } while (my $data = read_line("Add a search by $search or ENTER to go on: ", '')) { last unless defined $data && length $data; push @{$config->get($search)}, $data; } } } } else { print "Nothing to do, exiting\n"; exit 0; } } # }}} # {{{ do initial searches, results will go in %discs foreach my $search (keys %freedb_searches) { # @keywords will contain all the keywords (e.g. -artist "Pink Floyd") my @keywords = @{$config->get($search)}; # we join in the -all keywords for every search push @keywords, @{$config->get(SEARCH_ALL)}; print "Asked for keywords @keywords, search $search\n" if $config->DEBUG(); # remember the searches and keywords done push @{$freedb_searches{$search}->{keywords}}, @keywords; # do the search foreach my $keyword (@keywords) { print "Searching with keyword $keyword, search $search\n" if $config->DEBUG(); my %found_discs = $cddb->getdiscs($keyword, [$search]); if ($config->OR()) # any search with OR { push @common, keys %found_discs; } elsif (scalar @common) # second or more search without OR { my @new = keys %found_discs; my $lc = List::Compare->new(\@common, \@new); @common = $lc->get_intersection(); } else # first search without OR { @common = keys %found_discs } foreach my $disc (keys %found_discs) { $discs{$disc} = $found_discs{$disc}; $disc_counts{$disc}++; # we'll use this to remove matches later } } # foreach @keywords } # foreach keys %freedb_searches # }}} foreach my $disc (keys %discs) { next if grep { $_ eq $disc} @common; print "Deleting search result $disc, it was not in all searches\n" if $config->DEBUG(); delete $discs{$disc}; } unless (scalar keys %discs) { print "The search you requested returned no discs, sorry. Exiting.\n"; exit; } # {{{ find out what discs the user wants and get their info my @selecteddiscs; if ($config->ACCEPT_ALL()) { @selecteddiscs = keys %discs; } else { print "Enter the albums of interest for files [@ARGV]\n"; @selecteddiscs = $cddb->ask4discurls(\%discs); } unless (scalar @selecteddiscs) { print "You selected no albums, exiting...\n"; exit 0; } %olddiscinfo = %discs; # save the old data for ask2discurls %discs = (); # clear the search results # populate %discs with full search results foreach my $disc (@selecteddiscs) { my %discinfo = $cddb->getdiscinfo($disc); $discs{$disc} = \%discinfo; } # }}} if ($config->DUMP()) { print Dumper \%discs; exit 0; } # {{{ guess ID3 info for each file given on the command line foreach my $file (@ARGV) { my $tag = get_tag($file, 1); unless (defined $tag) { if (-r $file && -f $file) { print "Could not get a tag from file $file, skipping"; } else { print "Nonexistent file $file, skipping"; } next; } # note that discs_of_interest could be narrowed to specific discs but in practice this was not useful my %discs_of_interest = %discs; # {{{ keep asking for an album, get the tag from it and apply it! while(1) { my @chosen = (); # do the following unless only one album is selected if (1 == scalar keys %discs_of_interest) { @chosen = (keys %discs_of_interest)[0]; } else { # get the ask4discurls special format back from %olddiscinfo my %ask4discurls_special_hash; foreach (keys %discs) { $ask4discurls_special_hash{$_} = $olddiscinfo{$_}; } do { print_tag_info($file, $tag); print "Choose a single album or none (to skip file) from the current list\n"; @chosen = $cddb->ask4discurls(\%ask4discurls_special_hash); } while (scalar @chosen > 1); }; last if scalar @chosen == 0; next if scalar @chosen != 1; my $disc = $discs{$chosen[0]}; my $track_number_guess = guess_track_number($file, $tag); my $tracks = $disc->{trackinfo}; my $track_number; do { # ask the user for the track number, while trying to be helpful print_tag_info($file, $tag, "Old tag"); $cddb->outstd($disc); if ($config->ACCEPT_ALL_TRACK_GUESSES() && $track_number_guess && # defined $track_number_guess > 0 && # positive $track_number_guess <= scalar @$tracks) # in the correct range { $track_number = $track_number_guess; } else { $track_number = read_line(sprintf('Choose a track number 1 - %d, 0 to quit, -1 to select another album: ', scalar @$tracks), $track_number_guess); } } while (not defined $track_number || $track_number < -1 || $track_number > scalar @$tracks); # cycle to the album selection again if the user wants to select another album next if $track_number == -1; # if the user selected a track... if ($track_number > 0) { my $new_tag = make_tag_from_freedb($disc, $track_number); print_tag_info($file, $new_tag, "New tag info") if defined $new_tag; # do this if the new tag was created, DRYRUN was not specified, and the # user says YES if ($new_tag && !$config->DRYRUN() && ($config->ACCEPT_ALL() || read_yes_no("Apply new tag (you'll get a chance to modify it)?", 1))) { my $modify_tags; if ($config->ACCEPT_ALL()) { $modify_tags = 0; } else { $modify_tags = read_yes_no("Modify tag elements?", 0); } # copy each new element (but don't overwrite valid old ones) foreach my $element (keys %$new_tag) { my $old_tag_element = $tag->{$element} || ''; if ($modify_tags) { # the user can press Up Arrow to get the old tag element $term->addhistory($old_tag_element); $new_tag->{$element} = read_line("New value of $element (was '$old_tag_element'): ", $new_tag->{$element}); # put the artist and album $new_tag changes back in $disc so the next # file can also use them if (exists $info2freedb{$element}) { $disc->{$info2freedb{$element}} = $new_tag->{$element}; } } $tag->{$element} = $new_tag->{$element}; } set_tag ($file, $tag); } # if apply_new_tag... } # if $track_number > 0 last; } # while(1) # }}} } # foreach $file (@ARGV) # }}} # {{{ is_valid_track_number: return 1 if the text is a valid track number sub is_valid_track_number { my $text = shift @_; return $text && length $text && $text =~ m/\d/; } # }}} # {{{ make_tag_from_freedb: make the ID3 tag info from a FreeDB entry sub make_tag_from_freedb { my $disc = shift @_; my $track = shift @_; # argument checking return undef unless $track =~ m/^\d+$/; # note that the user inputs track "1" but WebService::FreeDB gives us that # track at position 0, so we decrement $track $track--; return undef unless exists $disc->{trackinfo}; return undef unless exists $disc->{trackinfo}->[$track]; my $track_data = $disc->{trackinfo}->[$track]; return { TIT1 => $disc->{genre}, TIT2 => $track_data->[0], TRCK => $track+1, TPE1 => $disc->{artist}, TALB => $disc->{album}, # this used to be 'cdname' TYER => $disc->{year}, WXXX => $disc->{url}, COMM => $disc->{rest}||'', }; } # }}} # {{{ set_tag: set a ID3 V2 tag on a file sub set_tag { my $file = shift @_; my $tag = shift @_; my $mp3 = MP3::Tag->new($file); print Dumper $tag; my $tags = $mp3->get_tags(); my $id3v2; if (ref $tags eq 'HASH' && exists $tags->{ID3v2}) { $id3v2 = $tags->{ID3v2}; } else { $id3v2 = $mp3->new_tag("ID3v2"); } my %old_frames = %{$id3v2->get_frame_ids()}; foreach my $fname (keys %$tag) { $id3v2->remove_frame($fname) if exists $old_frames{$fname}; if ($fname eq 'WXXX') { $id3v2->add_frame('WXXX', 'ENG', 'FreeDB URL', $tag->{WXXX}) ; } elsif ($fname eq 'COMM') { $id3v2->add_frame('COMM', 'ENG', 'Comment', $tag->{COMM}) ; } else { $id3v2->add_frame($fname, $tag->{$fname}); } } $id3v2->write_tag(); return 0; } # }}} # {{{ get_tag: get a ID3 V2 tag, using V1 if necessary sub get_tag { my $file = shift @_; my $upgrade = shift @_; my $mp3 = MP3::Tag->new($file); return undef unless defined $mp3; $mp3->get_tags(); my $tag = {}; if (exists $mp3->{ID3v2}) { my $id3v2 = $mp3->{ID3v2}; my $frames = $id3v2->supported_frames(); while (my ($fname, $longname) = each %$frames) { # only grab the frames we know next unless exists $supported_frames{$fname}; $tag->{$fname} = $id3v2->get_frame($fname); delete $tag->{$fname} unless defined $tag->{$fname}; $tag->{$fname} = $tag->{$fname}->{Text} if $fname eq 'COMM'; $tag->{$fname} = $tag->{$fname}->{URL} if $fname eq 'WXXX'; $tag->{$fname} = '' unless defined $tag->{$fname}; } } elsif (exists $mp3->{ID3v1}) { warn "No ID3 v2 TAG info in $file, using the v1 tag"; my $id3v1 = $mp3->{ID3v1}; $tag->{COMM} = $id3v1->comment(); $tag->{TIT2} = $id3v1->song(); $tag->{TPE1} = $id3v1->artist(); $tag->{TALB} = $id3v1->album(); $tag->{TYER} = $id3v1->year(); $tag->{TRCK} = $id3v1->track(); $tag->{TIT1} = $id3v1->genre(); if ($upgrade && ($config->ACCEPT_ALL() || read_yes_no("Upgrade ID3v1 tag to ID3v2 for $file?", 1))) { set_tag($file, $tag); } } else { warn "No ID3 TAG info in $file, creating it"; $tag = { TIT2 => '', TPE1 => '', TALB => '', TYER => 9999, COMM => '', }; } print "Got tag ", Dumper $tag if $config->DEBUG(); return $tag; } # }}} # {{{ print_tag_info: print the tag info sub print_tag_info { my $filename = shift @_; my $tag = shift @_; my $extra = shift @_ || 'Track info'; # argument checking return unless ref $tag eq 'HASH'; print "$extra for '$filename':\n"; foreach (keys %$tag) { printf "%10s : %s\n", $_, $tag->{$_}; } } # }}} # {{{ guess_track_number: guess track number from ID3 tag and file name sub guess_track_number { my $filename = shift @_; my $tag = shift @_ || return undef; $filename = basename($filename); # directories can contain confusing data # first try to guess the track number from the old tag if (exists $tag->{TRCK} && is_valid_track_number($tag->{TRCK})) { my $n = $tag->{TRCK} + 0; # fix tracks like 1/10 return $n; } elsif ($filename =~ m/([0123]?\d).*\.[^.]+$/) # now look for numbers in the filename (0 through 29) { print "Guessed track number $1 from filename '$filename'\n" if $config->DEBUG(); return $1; } return undef; # if all else fails, return undef } # }}} # {{{ guess_artist_and_track: guess artist and track from file name sub guess_artist_and_track { my $filename = shift @_; my $artist; my $track; $filename = basename($filename); # directories can contain confusing data if ($filename =~ m/([^-_]{3,})\s*-\s*(.{3,})\s*\.[^.]+$/) { print "Guessed artist $1 from filename '$filename'\n" if $config->DEBUG(); $artist = $1; $track = $2; } return ($artist, $track); } # }}} # {{{ input routines # {{{ read_key: read a single key from the keyboard sub read_key { my $key; ReadMode 3; while (not defined ($key = ReadKey(0))) { # No key yet } ReadMode 0; # Reset tty mode before exiting return $key; } # }}} # {{{ read_line: read a non-blank line from the user sub read_line { my $line; my $prompt = shift @_; my $default = shift @_; $line = $term->readline($prompt, $default); return $line; } # }}} # {{{ read_yes_no: get a yes/no from the user sub read_yes_no { my $line; my $prompt = shift @_; my $default = shift @_; my $true_mode = (uc $default eq 'Y' || $default eq '1'); my $user_choice; if ($true_mode) { print "$prompt (Y/n/g/^G) "; } else { print "$prompt (y/N/g/^G) "; } $user_choice = uc read_key(); print "\n"; if ($user_choice eq "\n" || $user_choice eq ' ') # ASCII space/CR { return $true_mode; } elsif ($user_choice eq 'G') { $config->ACCEPT_ALL(1); return $true_mode; } elsif (ord($user_choice) == 7) # Control-G { $config->ACCEPT_ALL_TRACK_GUESSES(1); $config->ACCEPT_ALL(1); return $true_mode; } # ASCII back space/delete elsif (ord($user_choice) == 8 || ord($user_choice) == 127) { return !$true_mode; } else { return ($user_choice eq 'Y'); } return undef; # shouldn't get here } # }}} # }}} # {{{ fix_chars: fix unsuitable characters sub fix_chars { my $name = shift @_; foreach my $char (map { quotemeta } @{$config->RENAME_BADCHARS()}) { $name =~ s/$char//g; } print "The name after character removals is $name\n" if $config->DEBUG(); my $newchar = quotemeta $config->RENAME_REPLACEMENT(); foreach my $char (map { quotemeta } @{$config->RENAME_REPLACECHARS()}) { $name =~ s/$char/$newchar/eg; } print "The name after character replacements is $name\n" if $config->DEBUG(); return $name; } # }}}