package epg::flac::archive::tags; =head1 NAME tags.pl subroutines for tag processing =head1 DESCRIPTION TODO =cut # POSIX.1-2017, Base Definitions, 3.282 Portable Filename Character Set says # [A-Za-z0-9._-] # https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_282 # Flac tags (set with `flac -T fieldname=...`) are Vorbis comments. # https://www.xiph.org/vorbis/doc/v-comment.html#fieldnames field names we care about: # - TITLE - Track/Work name # - VERSION - may be used to differentiate multiple versions of the same track title in a single collection. (e.g. remix info) # - ALBUM - The collection name to which this track belongs # - TRACKNUMBER - The track number of this piece # - ARTIST - The artist generally considered responsible for the track # - DATE - Date the track was recorded (XXX I use US release date) # https://age.hobba.nl/audio/mirroredpages/ogg-tagging.html (supposedly mirrored from http://reactor-core.org/ogg-tagging.html ) # specifies more: # - DISCNUMBER - if part of a multi-disc album, put the disc number here # - VERSION - e.g. "live", "radio edit" # - PARTNUMBER - part number if a work is divided across tracks # - PART - part name e.g. "Oh sole mio" # https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html # - ALBUMARTIST - maps to ID3v2 TPE2 # Input is a directory containing: # - trackNN.cdda.wav - WAV format files ripped from CD-DA audio tracks where NN is track number 01 - 99 # - tags - described below # - cover.front.jpeg - optional JPEG format file containing the album front cover # The tags file is composed of two blocks: # 1. album tags # 2. track tags # Album tags may be: # - ALBUM # - ARTIST # - DATE # - DISCNUMBER (optional) # Track tags may be any of the rest of the tags listed above, suffixed with # [N] where N is the track number. ARTIST, and only ARTIST, may also appear # in the track tags. In this case, it overrides the album artist. In order # to add artists, the album artist must be listed again. For example, Reba # McEntire's "The Heart Won't Lie" on the album "It's Your Call" features # Vince Gill, and is specified as: # ARTIST=Reba McEntire # ALBUM=It's Your Call # TITLE[5]=The Heart Won't Lie # ARTIST[5]=Reba McEntire # ARTIST[5]=Vince Gill # TODO # - sub filename in (fa-encode, fa-mp3cd, and flac2mp3) TESTED # - use PART, PARTNUMBER, VERSION in those filenames # - replace tags.pl with fa-tags which the others still import but which is a tool # - perltidy # - -bt=2 Strictest brace tightness # - -pt=2 Strictest paren tightness # - -nvc No vertical code alignment # - -se errors to stderr... how is this not the default? use v5.12; use warnings; use Exporter 'import'; our @EXPORT_OK = qw[ read_tags read_tags_metaflac disc_tags track_tags track_tags_from_disc mangle_for_file_name quote two_digits ]; sub two_digits { my $n = shift || 0; my $s = sprintf('%02d', $n); if (length($s) != 2) { die("$n won't fit into two digits") } $s } sub read_tags { local $_; my $fh = shift; my %album; my @tracks; my $tracknum = 0; my $name; while (<$fh>) { chomp; s/^DATE\[US]=200/DATE=200/; # I have DATE[US]=2004 and DATE[US]=2007. TODO fix them and remove hack. if (!s/^([^=[\]]+)(\[(\d+)])?=//) { if (defined($name) && $name eq 'DESCRIPTION') { # Qobuz offers FLAC files like this. warn("looks like DESCRIPTION had multiple lines; discarding and returning early: $_"); last } die("no field name in: $_"); } $name = uc($1); # TODO validate $name # TODO album tags should be illegal after track tags if (defined($3)) { if ($3 == $tracknum + 2) { $tracknum++; } elsif ($3 != $tracknum + 1) { $tracknum++; # increment from 0 to 1 for error message die("illegal track number jump from $tracknum to $3") } push(@{$tracks[$tracknum]->{$name}}, $_); } else { push(@{$album{$name}}, $_); } } \%album, \@tracks } sub read_tags_metaflac { local $@; my $fn = shift; open(my $fh, '-|', 'metaflac', '--no-utf8-convert', '--export-tags-to=-', $fn) || die("metaflac: $!"); my @result = eval { read_tags($fh) }; if ($@) { die("$@: $fn") } if (!close($fh)) { if ($! == 0) { die("metaflac exited $?") } die("close(metaflac): $!") } @result } sub one_or_undef { my $name = shift; my $tags = shift; my $tag = $tags->{$name}; if (defined($tag) && @$tag > 0) { return $tag->[0] } undef } sub one { my $tag = one_or_undef(@_); if (!defined($tag)) { my $name = shift; die("exactly one $name tag required") } $tag } =over =item B Interpret the tags hash for a disc, returning: =over =item * Artist tag (exactly one required in the hash). =item * Album tag (exactly one required). =item * Date tag (zero or one required). =item * Disc number (zero or one required) as a reference to an array of size 0 or 1. =back =cut sub disc_tags { my %tags = @_; if (!defined($tags{ALBUM}) || @{$tags{ALBUM}} == 0) { die('exactly one ALBUM tag required, found zero') } if (@{$tags{ALBUM}} != 1) { die('exactly one ALBUM tag required, found [', join("\n", @{$tags{ALBUM}}), ']') } my $album_tag = $tags{ALBUM}->[0]; # TODO ALBUMARTIST if (!defined($tags{ARTIST}) || @{$tags{ARTIST}} != 1) { die('exactly one ARTIST tag required') } my $artist = $tags{ARTIST}->[0]; my $date; if (defined($tags{DATE})) { if (@{$tags{DATE}} != 1) { die('one or zero DATE tags required') } $date = $tags{DATE}->[0]; } my $version; if (defined($tags{VERSION})) { if (@{$tags{VERSION}} != 1) { die('one or zero VERSION tags required') } $version = $tags{VERSION}->[0]; } my @discnumber = (); if (defined($tags{DISCNUMBER})) { if (@{$tags{DISCNUMBER}} != 1) { die('one or zero DISCNUMBER tags required') } my ($discnum) = @{$tags{DISCNUMBER}}; @discnumber = ($discnum); # But check it against DISCTOTAL if present. my $total = $tags{DISCTOTAL}; if (defined($total) && @$total > 0) { my ($total) = @$total; if ($total < $discnum) { die("DISCNUMBER=$discnum > DISCTOTAL=$total") } if ($total == 1) { # Qobuz offers FLAC files like this. @discnumber = (); } } } $artist, $album_tag, $date, \@discnumber, $version } # TODO dedup with flac2mp3 sub track_tags { my $tags = shift; # TODO probably ought to return hash too. then track_tags would augment my ($artist, $album, $date, $discnumber, $version) = disc_tags(%$tags); # TODO resurrect whole-disc FLAC? # Stupid hack: only a single-track file should have the # TRACKNUMBER tag, so use it if set for the first pass through # the loop. At the end of the loop, we'll set $track for the # next run, so this continues to work for multi-track files. # if track == None: # track = 1 # else: # track = int(track) my $tracknumber = epg::flac::archive::tags::one(TRACKNUMBER => $tags); my $title = epg::flac::archive::tags::one(TITLE => $tags); # TODO restore PARTNUMBER and VERSION next time i need them ( artist => $artist, album => $album, date => $date, discnumber => $discnumber, tracknumber => $tracknumber, title => $title, version => $version, ) } # TODO confusing relationship with track_tags which is wrong sub track_tags_from_disc { my ($album, $tracks, $tracknumber) = @_; my $trackcount = @$tracks; if ($trackcount < $tracknumber) { die("requested track $tracknumber out of $trackcount tracks"); } # Listing the track tags second makes them override the album tags. %$album, %{$tracks->[$tracknumber - 1]} } sub mangle_for_file_name { my $fn = shift; $fn =~ s/[!,.?]//g; # discard these punctuation marks $fn =~ s/\s+/_/g; # replace all whitespace with _ $fn =~ s/[^A-Za-z0-9_]/-/g; # everything else with - $fn =~ s/_[_-]/_/g; # collapse repeated _ or - to _ $fn =~ s/-[_-]/_/g; # more $fn =~ s/^[_-]+//; # discard any _ or - at beginning $fn =~ s/[_-]+$//; # at end too $fn =~ s/_[_-]/_/g; # XXX repeated collapse?! test and fix... $fn } sub quote { my $s = shift; $s =~ s/'/'\\''/g; "'$s'" } =back =cut 1;