- / images / a / af / metatool-0.24.pl
This page has not been reviewed by our documentation team (more info).
#!/usr/bin/perl use strict; use warnings; use open ':utf8'; use File::Temp qw(tempfile); use Getopt::ArgvFile home=>1; use Getopt::Long qw(GetOptions); use WebService::MusicBrainz::Release; # name of this program my $progname = 'metatool'; my $version = '0.24'; # command line options (defaulted) my $edit = 1; # true my $editor = 'vi'; my $input = 'album'; my $metaflac = 'metaflac'; my $output = 'album'; my $quiet; # false my $tmp = '/tmp'; my $trackidtag = 'MUSICBRAINZ_TRACKID'; my $verbose; # false # command line options (undefined) my $album_dir; my $discid; my $outfile; my $releaseid; my $suppress; # file to temporarily store metadata for individual track my $track_metafile = tmpfile('track'); # file to store editable metadata file my $edit_metafile = tmpfile('edit'); # has a value if header has been output my $header; # false # they key in metadata to indicate album metadata rather than track my $album_magic = 'ALBUM'; # prefix of track identifier to associate with track number my $track_magic = 'TRACK-'; # storage of all album and track metadata my %metadata = (); # types of input allowed my %inputs = ( 'album'=>\&input_album, 'musicbrainz'=>\&input_musicbrainz ); # types of output allowed my %outputs = ( 'album'=>\&output_album, 'cd2flac'=>\&output_cd2flac ); sub tag_indexes { my @tags = split(',', $_[0]); my %indexes = (); for my $n (0 ..$#tags) { $indexes{$tags[$n]} = $n; } return %indexes; } # preferred order of album tags my %album_tag_indexes = tag_indexes('ALBUM,ARTIST,ARTISTSORT,DATE'); # preferred order of track tags my %track_tag_indexes = tag_indexes('TITLE,VERSION,FEATURING,ARTIST,ARTISTSORT,ALBUM,DATE'); sub get_tag_sort_key { my $hashref = $_[0]; my $tag = $_[1]; my $index = $hashref->{$tag}; $index = 999 if (!defined($index)); return sprintf("%03d%s", $index, $tag); } sub header { # do nothing if already output or user asked to be quiet return if (defined($header) || defined($quiet)); print STDERR < 0); } # generate temporary filename sub tmpfile { my $type = $_[0]; my (undef, $f) = tempfile("$tmp/$progname-$type-XXXXXX"); return $f; } # display (optional) warning message and prompt for user confirmation sub warning { print "$_[0]\n" if (defined($_[0])); print "Press Enter to continue or ^C to cancel: "; (); } sub get_options { # parse command line options (may override defaults or initialization) GetOptions( 'discid=s' => \$discid, 'edit' => sub { $edit = 1; }, 'editor=s' => \$editor, 'help|h' => \&usage, 'input=s' => \$input, 'metaflac=s' => \$metaflac, 'noedit' => sub { undef $edit; }, 'outfile=s' => \$outfile, 'output=s' => \$output, 'quiet' => sub { $quiet = 1; undef $verbose; }, 'releaseid=s' => \$releaseid, 'suppress=s' => \$suppress, 'trackidtag=s' => \$trackidtag, 'tmp=s' => \$tmp, 'verbose' => sub { $verbose = 1; undef $quiet; }, ) or usage(); # only one (optional) command-line argument usage() if (scalar(@ARGV) > 1); # get arguments $album_dir = $ARGV[0]; # default arguments $album_dir = '.' if (!is_value($album_dir)); # validate options and arguments error("Invalid input type: $input") if (!defined($inputs{$input})); error("Invalid output type: $output") if (!defined($outputs{$output})); error("$tmp is not a writable directory.") if !(-d $tmp && -w $tmp); } # returns "no" if boolean false (undefined) sub noboolean { return (!defined($_[0]) ? "no" : ""); } # returns "undefined" if no value, otherwise value sub undefined { return (!defined($_[0]) ? "undefined" : $_[0]); } # display usage and exit sub usage { undef $quiet; header(); print STDERR < Disc ID to use for MusicBrainz lookup. [${\(undefined($discid))}] --[no]edit Edit the metadata prior to committing it. [${\(noboolean($edit))}] --editor= Program to use to edit metadata. [$editor] --input= Input type (see below). [$input] --help Display this help message. --metaflac= Program to use to manage metadata in FLAC files. [$metaflac] --outfile= File to write output, if necessary. [${\(undefined($outfile))}] --output= Output type (see below). [$output] --releaseid= Release ID to use for MusicBrainz lookup. [${\(undefined($releaseid))}] --quiet Quiet operation, no screen output. --suppress= List of tags to suppress in metadata. [${\(undefined($outfile))}] --trackidtag= Name of tag in FLAC file to store track ID. [$trackidtag] --tmp= Directory to store temporary files in. [$tmp] --verbose Verbose output of progress information. EDITOR: You will need to select editor command(s) that block until you complete editing the metadata file. After editing is complete, the file is read by $progname to store into the FLAC file(s). INPUT TYPE This determine what the input for the metadata to edit. The choices are: * album: The specified (or current) directory of FLAC files is parsed. * musicbrainz: MusicBrainz is queried using releaseid or discid. OUTPUT TYPE This determines what the output for the edited metadata is. The choices are: * album: The specified (or current) directory of FLAC files is written to. * cd2flac: Raw metadata content output (intended only for use by cd2flac). TAG SUPPRESSION You can instruct metatool to suppress specific tags from metadata editing by using the --suppress option. Specify tag names, separated by commas. Tag names are case-insensitive. CONFIGURATION FILE: You can store persistent command-line options in a file called .$progname in your home directory. They will be read as defaults prior to processing any passed command-line options. EOF exit 1; } # create normalized version of arist, album, track name sub normalize_name { # do nothing if no value return undef if (!is_value($_[0])); my $v = $_[0]; # convert multiple spaces into singluar space $v =~ s/[[:space:]]+/ /g; # convert double single quotes into single double quotation mark $v =~ s/\'\'/\"/g; return $v; } # extract year from date sub extract_year { return undef if (!defined($_[0])); my $v = $_[0]; if ($v =~ m/(\d{4})/) { return $1; } return undef; } # sets a tag value in a hash, avoiding duplication of values sub set_tag { my $track = $_[0]; my $name = $_[1]; my $value = $_[2]; return if (!defined($track) || !defined($name) || !defined($value)); # look for duplicate in hash values, return if already there if (defined($metadata{$track}{$name})) { foreach my $v (@{$metadata{$track}{$name}}) { return if ($v eq $value); } } # no duplicate found; set the value in the hash push(@{$metadata{$track}{$name}}, $value); } sub read_track_metaflac { my $filename = $_[0]; $metadata{$filename} = () if (!defined($metadata{$filename})); sys("$metaflac --no-utf8-convert --export-tags-to=$track_metafile " . escq("$album_dir/$filename")); my $handle = open_read($track_metafile); while (<$handle>) { chomp; my $line = trim($_); if ($line =~ m/^(.*)=(.*)/) { set_tag($filename, uc($1), $2) if (is_value($2)); } } close($handle); } sub common_album_tag { my $suffix = shift(); my $album_artist; foreach my $track (keys %metadata) { next if ($track eq $album_magic); my $track_albumartist = $metadata{$track}{"ALBUM$suffix"}[0]; $track_albumartist = $metadata{$track}{$suffix}[0] if (!defined($track_albumartist)); $album_artist = $track_albumartist if (!defined($album_artist)); return undef if (!defined($track_albumartist) || $track_albumartist ne $album_artist); $album_artist = $track_albumartist; } return (defined($album_artist) ? $album_artist : undef); } sub does_album_have_same_attribute_value { my $name = shift; my $value = shift; return undef if (!defined($metadata{$album_magic}{$name})); my @values = @{$metadata{$album_magic}{$name}}; my $index = grep $values[$_] eq $value, 0 .. $#values; return ($index == 0 ? undef : 1); } sub do_all_tracks_have_same_attribute_value { my $name = shift; my $value = shift; foreach my $track (keys %metadata) { next if ($track eq $album_magic); return undef if (!defined($metadata{$track}{$name})); my @values = @{$metadata{$track}{$name}}; my $index = grep $values[$_] eq $value, 0 .. $#values; return undef if ($index == 0); } # all tracks have same name-value pair return 1; } sub clear_tracks_attribute_value { my $name = shift; my $call_value = shift; return if (!defined($call_value)); foreach my $track (keys %metadata) { next if ($track eq $album_magic || !defined($metadata{$track}{$name})); my @new_array = (); foreach my $value (@{$metadata{$track}{$name}}) { push(@new_array, $value) if (defined($value) && $value ne $call_value); } @{$metadata{$track}{$name}} = @new_array; } } # promotes common track metadata to album metadata sub promote_album_metadata { # treat album artist differently here than other tags my $album_artist_name = common_album_tag('ARTIST'); my $album_artist_sort = common_album_tag('ARTISTSORT'); set_tag($album_magic, 'ARTIST', $album_artist_name); set_tag($album_magic, 'ARTISTSORT', $album_artist_sort); clear_tracks_attribute_value('ARTIST', $album_artist_name); clear_tracks_attribute_value('ALBUMARTIST', $album_artist_name); clear_tracks_attribute_value('ARTISTSORT', $album_artist_sort); clear_tracks_attribute_value('ALBUMARTISTSORT', $album_artist_sort); # get first track in hash my $first_track; foreach my $track (keys %metadata) { next if ($track eq $album_magic); $first_track = $track; last; } # iterate through all attributes in first track foreach my $key (keys %{$metadata{$first_track}}) { my @values = @{$metadata{$first_track}{$key}}; foreach my $value (@values) { if (do_all_tracks_have_same_attribute_value($key, $value) || does_album_have_same_attribute_value($key, $value)) { set_tag($album_magic, $key, $value); clear_tracks_attribute_value($key, $value); } } } } # open a file for writing sub open_write { my $filename = $_[0]; my $handle; diag("Opening $filename for writing"); open($handle, ">$filename") or error("Cannot open $filename for writing."); return $handle; } # open a file for reading sub open_read { my $filename = $_[0]; my $handle; open ($handle, "<$filename") or error ("Cannot open $filename for reading."); return $handle; } sub sort_keys { my $metadata = $_[0]; my $indexes = $_[1]; return sort { get_tag_sort_key($indexes, $a) cmp get_tag_sort_key($indexes, $b) } keys(%$metadata); } sub zerodef { return (defined($_[0]) ? $_[0] : 0); } sub write_edit_metadata { # open metafile for writing my $out = open_write($edit_metafile); my @sorted_tracks = sort { zerodef($metadata{$a}{'TRACKNUMBER'}[0]) <=> zerodef($metadata{$b}{'TRACKNUMBER'}[0]) } grep(!/^$album_magic$/, keys(%metadata)); print $out "[$album_magic]\n"; foreach my $tag (sort_keys(\%{$metadata{$album_magic}}, \%album_tag_indexes)) { foreach my $value (@{$metadata{$album_magic}{$tag}}) { next if ($tag eq 'TRACKNUMBER'); print $out "$tag=$value\n"; } } # iterate through all tracks foreach my $track (@sorted_tracks) { next if $track eq $album_magic; # track heading print $out "\n[$track]\n"; foreach my $key (sort_keys($metadata{$track}, \%track_tag_indexes)) { # output type of cd2flac suppresses track number next if ($output eq 'cd2flac' && $key eq 'TRACKNUMBER'); my @sorted_values = sort { $a cmp $b } @{$metadata{$track}{$key}}; foreach my $value (@sorted_values) { print $out "$key=$value\n"; } } } close($out); } sub read_edit_metadata { # reset album and track metadata hashes, as they will be repopulated from authorative file %metadata = (); my $handle = open_read($edit_metafile); my $track_name; while (<$handle>) { # clean up the line chomp; my $line = trim($_); # [header] line if ($line =~ m/^\[/) { if ($line =~ m/^\[$album_magic\]$/) { $track_name = $album_magic; } elsif ($line =~ m/^\[(.*)\]/) { $metadata{$1} = () if (!defined($metadata{$1})); $track_name = $1; } else { error("Invalid header in metadata: $line\n"); } } # name=value metadata assignment elsif ($line =~ m/^(.*)=(.*)/) { error("Metadata assignment $line without [HEADER].\n") if (!defined($track_name)); # assign metadata value to appropriate hash entry set_tag($track_name, $1, $2) if (is_value($2)); } elsif (!$line =~ m/^$/) { error("Invalid metadata line: $line.\n"); } } close($handle); } sub tracks_inherit_album_metadata { for my $tag (keys %{$metadata{$album_magic}}) { # track album artist will be set later by set_track_albumartists next if ($tag eq 'ARTIST' || $tag eq 'ARTISTSORT'); # iterate through tracks for (keys %metadata) { next if ($_ eq $album_magic); foreach my $value (@{$metadata{$album_magic}{$tag}}) { set_tag($_, $tag, $value); } } } } sub set_track_albumartists { # there should only be one artist per album my $album_artist_name = $metadata{$album_magic}{'ARTIST'}[0]; my $album_artist_sort = $metadata{$album_magic}{'ARTISTSORT'}[0]; # set ARTIST (or ALBUMARTIST tag in track if its artist doesn't match album's) if (defined($album_artist_name)) { foreach my $track (keys %metadata) { next if ($track eq $album_magic); my $track_artist_name = $metadata{$track}{'ARTIST'}[0]; if (!is_value($track_artist_name)) { set_tag($track, 'ARTIST', $album_artist_name); } elsif ($track_artist_name ne $album_artist_name) { set_tag($track, 'ALBUMARTIST', $album_artist_name); } } } # set ARTISTSORT (or ALBUMARTISTSORT tag in track if its artist doesn't match album's) if (defined($album_artist_sort)) { foreach my $track (keys %metadata) { next if ($track eq $album_magic); my $track_artist_sort = $metadata{$track}{'ARTISTSORT'}[0]; if (!is_value($track_artist_sort)) { set_tag($track, 'ARTISTSORT', $album_artist_sort); } elsif ($track_artist_sort ne $album_artist_sort) { set_tag($track, 'ALBUMARTISTSORT', $album_artist_sort); } } } } sub write_track_metaflac { my $file = $_[0]; # if track metadata not defined, leave track FLAC file alone return if (!defined $metadata{$file}); # output metadata for individual track my $out = open_write($track_metafile); my @sorted_keys = sort { $a cmp $b } keys %{$metadata{$file}}; foreach my $tag (@sorted_keys) { my @sorted_values = sort { $a cmp $b } @{$metadata{$file}{$tag}}; foreach my $value (@sorted_values) { print $out "$tag=$value\n" if (is_value($value)); } } close($out); sys("$metaflac --preserve-modtime --remove-all-tags" . ' --import-tags-from="' . $track_metafile . '" ' . escq("$album_dir/$file")); } sub input_album() { opendir(DIR, "$album_dir"); my @files = grep(/\.flac$/, readdir(DIR)); closedir(DIR); foreach my $filename (@files) { read_track_metaflac($filename); } } sub input_metadata() { error("metadata input type not supported yet"); } sub track_heading { return $track_magic . $_[0]; } sub search_release { my $ws = WebService::MusicBrainz::Release->new(); my $tries = 10; my $interval = 1; while ($tries--) { sleep($interval); my $response; eval { $response = $ws->search($_[0]); }; return $response if (!$@); $interval *= 2; } error("Failure querying MusicBrainz: $@"); } sub input_musicbrainz { print(STDERR "Getting MusicBrainz metadata..."); if (!is_value($releaseid)) { # release id takes precedence over disc id my $response = search_release({ DISCID=>$discid }); my $release = $response->release(); # disc is not in MusicBrainz database return if (!defined $release); $releaseid = $release->id(); } my $response = search_release({ MBID=>$releaseid, INC=>'artist release-events tracks' }); my $release = $response->release(); # release is not in MusicBrainz database return if (!defined $release); my $album_title = normalize_name($release->title()); my $disc_number; # FIXME: there's got to be a better way? # extract disc number from title if ($album_title =~ m/( \(disc .*)/) { $disc_number = substr($1, 0, index($1, ')')); my $index = index($album_title, $disc_number); $album_title = substr($album_title, 0, $index) . substr($album_title, $index + length($disc_number) + 1); $disc_number = substr($disc_number, 7, length($disc_number) - 7); $disc_number =~ s/:.*//; set_tag($album_magic, 'DISCNUMBER', $disc_number); } set_tag($album_magic, 'ALBUM', $album_title); my $album_artist = $release->artist(); my $album_artist_name = normalize_name($album_artist->name()); my $album_artist_sort = normalize_name($album_artist->sort_name()); my @tracks = @{$release->track_list()->tracks()}; my $first_track_no = 1; my $last_track_no = $#tracks + 1; # establish skeleton track structure for my $track_no ($first_track_no .. $last_track_no) { set_tag(track_heading($track_no), 'TRACKNUMBER', $track_no); } for my $track_no ($first_track_no .. $last_track_no) { my $track = $tracks[$track_no - 1]; my $track_heading = track_heading($track_no); my $track_title = normalize_name($track->title()); my $track_artist = $track->artist(); my $track_id = $track->id(); my $track_artist_name = (defined($track_artist) ? $track_artist->name() : $album_artist_name); my $track_artist_sort = (defined($track_artist) ? $track_artist->sort_name() : $album_artist_sort); my $track_featuring = undef; my $track_version = undef; # extract featured artist from track title into separate tag if ($track_title =~ m/( \(feat\. .*)/) { $track_featuring = substr($1, 0, index($1, ')')); my $index = index($track_title, $track_featuring); $track_title = substr($track_title, 0, $index) . substr($track_title, $index + length($track_featuring) + 1); $track_featuring = substr($track_featuring, 8, length($track_featuring) - 8); } # extract version information from track title into separate tag if ($track_title =~ m/( \([^\)]*(live|acoustic|instrumental|edit|reprise|mix|version|take|breakdown).*\))$/) { $track_version = $1; $track_title = substr($track_title, 0, length($track_title) - length($1)); $track_version =~ s/^ \(//; $track_version =~ s/\)$//; } set_tag($track_heading, 'ARTIST', $track_artist_name) if ($album_artist_name ne $track_artist_name); set_tag($track_heading, 'ARTISTSORT', $track_artist_sort) if ($album_artist_sort ne $track_artist_sort); set_tag($track_heading, 'TITLE', $track_title); set_tag($track_heading, 'FEATURING', $track_featuring) if (is_value($track_featuring)); set_tag($track_heading, 'VERSION', $track_version) if (is_value($track_version)); set_tag($track_heading, $trackidtag, $track_id) if (is_value($track_id)); } my $release_event_list = $release->release_event_list(); if ($release_event_list) { set_tag($album_magic, 'DATE', extract_year(trim(@{$release_event_list->events()}[0]->date()))); } # provide default no-artist assignment for no-artist tracks in "various artists" album if ($album_artist->id() eq '89ad4ac3-39f7-470e-963a-56509c546377') { for my $track_no ($first_track_no .. $last_track_no) { my $track_heading = track_heading($track_no); set_tag($track_heading, 'ARTIST', '') if (!is_value($metadata{$track_heading}{'ARTIST'})); } } # set artist name set_tag($album_magic, 'ARTIST', $album_artist_name); set_tag($album_magic, 'ARTISTSORT', $album_artist_sort); } sub input { &{$inputs{$input}}(); } sub edit() { return if (!defined($edit)); write_edit_metadata(); # launch editor to allow user to edit metadata sys($editor . " $edit_metafile"); read_edit_metadata(); } sub output_album() { warning("Committing metadata to tracks in $album_dir."); print("\n"); opendir(DIR, "$album_dir"); my @files = grep(/\.flac$/, readdir(DIR)); closedir(DIR); foreach my $filename (@files) { write_track_metaflac($filename); } } sub suppress_tags { return if (!is_value($suppress)); my @split = split(/,/, $suppress); foreach my $track (keys(%metadata)) { foreach my $tag (keys(%{$metadata{$track}})) { foreach my $stg (@split) { if (uc($tag) eq uc($stg)) { delete($metadata{$track}{$tag}); } } } } } sub output_cd2flac { my $out = is_value($outfile) ? open_write($outfile) : undef; foreach my $track (keys(%metadata)) { my $trackno; # recognize this track as containing the album metadata if ($track eq $album_magic) { $trackno = 'A'; } # recognize as a cd2flac-compatible track number elsif ($track =~ m/$track_magic(.*)/) { $trackno = $1; } # don't recognize this metadata else { next; } foreach my $tag (keys(%{$metadata{$track}})) { next if ($tag eq 'TRACKNUMBER'); # cd2flac will number the tracks itself foreach my $value (@{$metadata{$track}{$tag}}) { my $line = "$trackno:$tag=$value\n"; if (defined($out)) { print $out $line; } else { print STDOUT $line; } } } } close($out) if (defined($out)); } sub output() { tracks_inherit_album_metadata(); set_track_albumartists(); &{$outputs{$output}}(); } sub cleanup { unlink($track_metafile); unlink($edit_metafile); } # parse command line options get_options(); # display program header header(); # input album metadata from specified source input(); # suppress tags of specified type suppress_tags(); # promote common metadata across tracks to album promote_album_metadata(); # provide user ability to customize the metadata edit(); # output album metadata to specified destination output(); # perform garbage collection cleanup(); msg("Done.\n");