#!/usr/bin/perl
=head MP3 Deduplicator File Set Class

=head Nomenclature of Module
 We use several strange terms in this module. Understanding the semantics of
 those terms is critical to understanding the module itself, so we will define
 several terms seen throughout here:
 "Abnormal Format" - When the suffix of a file contains upper-case characters.
 "Base key" - The hash key being used as a basis for comparison when comparing
    the keys of two different hash tables. Contrast with a "Comparison key."
 "Common Substring" - "A common substring (CS) is one or more filenames 
    occuring prior to the "end number" and the file format suffix. It is a 
    substring that starts from the beginning of a filename and stops before 
    either the end number or the '.' delimiting the format suffix. 
    Files without a suffix have no common substring.
 "Comparison key" - The hash key being compared to a Base key.
 "End Number" - A single-digit number that may or may not occur before the
    file format suffix of a filename. For example in, "Filename 1.mp3" the end
    number is "1".
 "Format" - The file type suffix, e.g. ".mp3", ".txt", ".m4a", etc.
 "Group" - Usually refers to a hash of arrays where the hash keys are some
    common trait shared by the array members. In this module the traits we 
    group around tend to be either MD5 checksums or common substrings.
 "Inverted Group" - This takes the hash of arrays in a group and turns it inside
    out, turning each member of every inner array into the keys of a new hash.
    The former keys of the old group become the new values of the inverted group
    hash. In the context of this module the inverted group is usually keyed with
    filenames. The values are usually either scalars representing one attribute
    of the file in question (an MD5 or CS), or a hash reference containing
    many attributes as values and short descriptive strings as keys.
=cut #-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
#===============================================================================
#                               MODULES AND PRAGMAS
#===============================================================================
package MP3_Dedupe_Fileset;
use strict; use warnings;
use feature 'unicode_strings'; # Supposed necessities for UTF-8 and Unicode
binmode( STDOUT, ":utf8" )   ; # compatibility? Not gonna chance it.
use Digest::MD5; # Used to obtain MD5 checksums from MP3 files.
use Encode     ; # Used to support comparison of UTF-8 filenames.
use Cwd        ; # Used to get the current working directory.
#-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-

#===============================================================================
#                                  VARIABLES
#===============================================================================
my $TRUE  = 1;
my $FALSE = 0;
my $mp3_patt = '\.[mM][pP]3'  ; # We include the '\.' to ease the process of
my $mp3_npat = '\.mp3'        ; # finding non-Music file extensions in a
my $m4a_patt = '\.[mM]4[aA]'  ; # generalized fashion.
my $m4a_npat = '\.m4a'        ; # npat vars are patterns for the normal forms
my $ogg_patt = '\.[oO][gG]{2}'; # of file extensions. We use them to find forms
my $ogg_npat = '\.ogg'        ; # with funky upper-case characters.
#-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-

#===============================================================================
#                               PRIVATE SUBROUTINES
#===============================================================================
my $by_shortest_utf8 = sub { #--------------------------------------------------
=head DESCRIPTION
 This is a sorting algorithm. We'll use it to determine sort filenames by length
 since said filenames are very likely to contain UTF-8 characters.
=cut #--------------------------------------------------------------------------
    length(Encode::encode_utf8( $a )) <=> length(Encode::encode_utf8( $b ));
};

my $quote_array = sub { #------------------------------------------------------
    return join( ', ', map { "\"".$_."\"" } @_ ) . "\n";
};

my $get_fmt = sub { #-----------------------------------------------------------
=head NOTES
 note1:
    The ?: operator is right-associative. This is a double-nested ?:. In this
    form, where the second predicate is "true" outcome for the first, it is 
    fundamentally equivalent to an AND statement.
=cut #--------------------------------------------------------------------------
    my $self = shift;
    my ( $fmt_patt, $negate ) = @_; # file extension pattern, negative set flag
    my @matches   = ();
    my @nomatches = ();
    foreach my $file ( @{ $$self{"files"} } ){
        if(   $file =~ m/^.*$fmt_patt$/                && 
            ( ! defined $negate || $negate == $FALSE ) ){
            push( @matches, $file ); 
        }
        elsif( defined $negate && $negate == $TRUE &&
               $file !~ m/^.*$fmt_patt$/           ){
            push( @nomatches, $file ); 
        }
    }
    return defined $negate  ? #note1
           $negate == $TRUE ?
                @nomatches  :            # Deliver if user wanted unmatching set
                @matches    : @matches ; # If either predicate is false we match
};

my $get_abnormal_fmt = sub { #--------------------------------------------------
    my $self = shift;
    my ( $flex_fmt, $norm_fmt ) = @_; # flexible pattern, normal format pattern
    my @matches  = ();
    foreach my $file ( $self->$get_fmt( $flex_fmt ) ){
        if( $file =~ m/^.*$flex_fmt$/ &&
            $file !~ m/^.*$norm_fmt$/ ){
            push(@matches, $file );
        }
    }
    return @matches;
};

my $do_group_quantities_balance = sub { #---------------------------------------
=head NOTES
 note1: Here we see a relatively compact way of adding up all values in a hash
        of array references where a value is a single subscript of any given
        array and the values are all subscripts of all arrays tied to every
        key in the hash.
=cut #--------------------------------------------------------------------------
    my $self      = shift;
    my @ungrouped = @{ $_[0] } ;
    my %grouped   = %{ $_[1] } ;
    my $num_grp   = map { @{ $grouped{$_} } } keys %grouped; #note1
    return $TRUE if $num_grp == scalar( @ungrouped );
    return $FALSE;
};

my $is_common_substring_of = sub { #--------------------------------------------
=head NOTES
 note : "Common Substring" (CS), "Longest Common Substring" (LCS)
 note1: Comparison is performed from the beginning of each string.
        This is effectively asking, "does our shorter or equal-length
        comparison key match the beginning of the base key?"
 note2: For our comparison to be meaningful it needs to be likely to succeed.
        Our comparison key can only be a common substring of the base key if it
        is shorter or of equal length.
 note3: Since a given file name may contain regex metacharacters we use 
        quotemeta to escape the special meanings of any such characters before 
        using a fragment of that file name as a regex pattern for matching.
=cut #--------------------------------------------------------------------------
    my $self   = shift;
    my $ckey   = shift; # Comparison key 
    my $bkey   = shift; # Base key
    my $cklen  = length(Encode::encode_utf8( $ckey )); 
    my $bklen  = length(Encode::encode_utf8( $bkey )); 
    return $FALSE if $bklen < $cklen; #note2
    my $ckpatt   = quotemeta $ckey; #note3
    return $bkey =~ /^$ckpatt/ ? $TRUE : $FALSE; #note1
    #---------------------------------------------------------------------------
    #TODO: There is a flaw with the above. Let's say that 
    # our base key is   "Gas Chamber Boogie V2.0" and our 
    # comparison key is "Gas Chamber Boogie"
    # In this case, the base key is longer than the comparison key, thereby 
    # satisfying the first check. It also, rather deviously, satisfies the
    # second check, even though we may prefer that it did not do that. After all
    # the two files appear to be named in a way that is radically different.
    # In particular, we can see that the filename associated with that base key 
    # is much more complicated than a single, appended number. However, when
    # this sub is called as part of sub $rm_merged_groups_by_common_substring,
    # and as part of regroup_mp3s_by_common_substring, the set of files
    # associated with the "Gas Chamber Boogie" key will fall victim to this 
    # algorithm and end up grouped with the set from the base key. This means
    # that if we later place files into the "lose" group based purely on CS,
    # then we will lose both copies of Gas Chamber Boogie V2.0 since it will be
    # the longer of two filenames.
    # So how do we obviate this problem? We need a way to return $FALSE for a
    # comparison of the sort described above. Unfortunately, simply checking for
    # /^$ckpatt(\s\d)/ instead of /^$ckpatt/ yields a less-than-splendid result.
    # Many groups end up depopulated. This suggests to me that the problem is
    # either somewhere else or this function is only one among many that must
    # shoulder the burden of the blame. In other words, the problem is complex
    # and likely spans the entire gamut of common substring logic!
    # This is also to suggest that fixing it may prove to be burdensome enough
    # that it wouldn't be worth it.
    #---------------------------------------------------------------------------
};

my $combine_lists = sub { #-----------------------------------------------------
=head DESCRIPTION
 Combines two arrays into another array representing a set of all values from
 the two input arrays. The result being a set implies that no values from
 either of the two input arrays will be repeated in the result, even if one or
 more values were repeated in one or both of the lists. The sub is of a general
 rather than specific character.
=cut #--------------------------------------------------------------------------
    my @l1 = @{ $_[0] };
    my @l2 = @{ $_[1] }; 
    my @nl = ( @l1, grep { my $i = $_; ! grep { $i eq $_ } @l1 } @l2 );
    return @nl;
};

my $common_substring = sub { #--------------------------------------------------
    my $self = shift;
    my $bkey =    $_[0]  ;
    my @bval = @{ $_[1] };
    my $ckey =    $_[2]  ;
    my @cval = @{ $_[3] };
    if( $self->$is_common_substring_of( $ckey, $bkey ) ){
       return $combine_lists->( \@bval, \@cval )
    }
    else{ return undef; }
};

my $shortest_filename = sub { #-------------------------------------------------
    my $self    = shift;
    my @val     = @_;
    my @new_val = sort $by_shortest_utf8 @val;
    return @new_val;
};

my $group_by = sub { #----------------------------------------------------------
=head NOTES
 note1: Class member function calls not called from $self need to be explicitly
        passed a copy of the $self reference. I imagine this is because any
        $self->$whatever() type of call is coming from a 'blessed' source, so
        passing the $self is implicit, but calling the function standalone or
        from a local reference reverts it to a non-blessed form of sub call.
=cut #--------------------------------------------------------------------------
    my $self         = shift;
    my $grouping_sub = shift;
    my @files        = @_;
    my %results      = ();
    foreach my $file ( @files ){ 
        my $res = $grouping_sub->( $self, $file ); #note1
        push( @{ $results{$res} }, $file );
    }
    if( ! $do_group_quantities_balance->( $self, \@files, \%results ) ){
        warn "\$group_by: error grouping files from dir \"". $$self{"name"} .
             "\": ".join( ', ', map {"\"".$_."\""} @files )  ."\n";
    }
    return %results;
};

my $md5_hex = sub { #-----------------------------------------------------------
=head NOTES
 note1: Preferred mode for MD5 digest
 note2: Create new MD5 Digest object.
 note3: Same format as md5sum CLI tool.
=cut #--------------------------------------------------------------------------
    my $self = shift;
    my $file = shift;
    my $dir  = $$self{"name"};
    my $OLDDIR = getcwd;
    chdir $dir or die "Cannot access directory \"$dir\": $!\n";
    open( my $FH, $file ) or die "Cannot open \"$file\": $!\n";
    binmode($FH);               #note1
    my $md5 = Digest::MD5->new; #note2
    $md5->addfile($FH);
    close $FH;
    chdir $OLDDIR or die "Cannot revert to old dir \"$OLDDIR\": $!\n";
    return $md5->hexdigest; #note3
};

my $chomp_end_numbers = sub { #--------------------------------------------------
=head NOTES
 note2: $self is not used in this function but other grouping subs may use it
        so we have to pass it anyway.
=cut #--------------------------------------------------------------------------
    my $self = shift; #note1
    my $file = shift;
    my ( $substr, $chomped_num, $ext );
    if( $file =~ m/^(.+)(\s\d)(\..{3,4}){1}$/ ){
        ($substr,$chomped_num,$ext) = 
            $file =~ m/^(.+)(\s\d)(\..{3,4}){1}$/;
    }
    elsif( $file =~ m/^(.+)(\..{3,4}){1}$/ ){
        ($substr,$ext) = 
            $file =~ m/^(.+)(\..{3,4}){1}$/;
    }
    else{
        warn "MP3_Dedupe_Fileset\:\:\$chomp_end_numbers: cannot ". 
            "parse file name: \"$file\"\n";
        ($substr,$ext)=("","");
    }
    return defined $substr     ? 
           $substr !~ m/^\s*$/ ?  $substr :  "ERROR!" :
           "ERROR!"            ;
};

my $show_group = sub { #--------------------------------------------------------
=head NOTES
 note1: Class member function calls not called from $self need to be explicitly
        passed a copy of the $self reference. I imagine this is because any
        $self->$whatever() type of call is coming from a 'blessed' source, so
        passing the $self is implicit, but calling the function standalone or
        from a local reference reverts it to a non-blessed form of sub call.
=cut #--------------------------------------------------------------------------
    my $self      = shift;
    my $group_sub = shift;
    my $sort_sub  = shift;
    my %results   = $group_sub->( $self ); #
    foreach my $k ( sort $sort_sub keys %results ){
        print "$k: " . 
            join( ', ', map { "\"".$_."\"" } @{ $results{ $k } } ) . "\n";
    }
    return $TRUE;
};

my $rm_merged_groups_by_common_substring = sub { #------------------------------
    my $self = shift;
    my %group = @_;
    my @keys_to_del; 
    map{ 
        my $bk = $_; 
        my @cks = grep { 
            my $ck = $_; $bk ne $ck &&
            $self->$is_common_substring_of( $ck, $bk );
            #-------------------------------------------------------------------
            #TODO: This seems to be a problem for a small subset of our sets.
            # Consider the scenario where:
            # $ck = "Gas Chamber Boogie"      and 
            # $bk = "Gas Chamber Boogie V2.0"
            # These pass the common_substring check, because $bk > $ck and
            # $bk =~ /^$ck/ but clearly the two substrings are very different,
            # and the files grouped between the two substrings are probably not
            # the same files (they weren't in the instance that inspired this
            # particular writeup).
            # Another instance where things go awry is that the test files
            # "1 2 3 4 5 6 7 8 9 10.mp3" and "1 2 3 4 5 6 7 8 9.mp3" are
            # grouped together but they should not be grouped together. Now,
            # in an older version of this script the grouping would be fine.
            # However we decided to limit number chomping to a single-digit at
            # some point and that means that these two filenames should be
            # separate but they're not. Worse yet, they are grouped together 
            # under a CS of "1 2 3 4 5 6 7 8 9 10" which is a bad choice
            # regardless of whether or not we assume that the end number should
            # be only a single digit. However, the code clearly makes this 
            # choice because we're consolidating on the basis of longest CS 
            # amongst similar keys.
            # This probably means that somewhere in the process of hacking 
            # together working (though not necessarily good) CS functionality 
            # we've made a real freakin' mess. The problems that arose with
            # list combination as part of this reduction process is just one 
            # of the many we're contending with now. We should definitely 
            # document thoughts on this problem in a Post Mortem in the wiki.
            #-------------------------------------------------------------------
        } keys %group;
        push( @keys_to_del, $cks[0] ) if defined $cks[0];
    } keys %group;
    foreach my $key ( @keys_to_del ){
        delete $group{$key};
    }
    return %group;
};

my $get_cars_of_group = sub { #-------------------------------------------------
=head NOTES
 note : The "cars" are the "front values" of a group. They are the file names 
        at the front of any given key for a grouping. For example, if we have 
        a group with four files, two to each key, then this sub will reduce that
        to two files, one per key, picking the first value on each list.
        Picking the first value may seem arbitrary at a glance, but if one picks
        the front element of each list on group that has had its list sorted by
        shortest file name then this is a good way to pick out the shortest file
        name for each key.
        Without a sorted group this sub has little use.
=cut #--------------------------------------------------------------------------
    my $self    = shift;
    my %group   = @_;
    my %regroup = ();
    foreach my $key ( keys %group ){
        my $newv = $group{$key}->[0];
        $regroup{$key} = $newv;
    }
    return %regroup;
};

my $get_cdrs_of_group = sub { #-------------------------------------------------
=head NOTES
 note : In the same LISP-derived fashion that the 'car' of a group represents 
        the front of each list within a grouping, the 'cdr' represents the group
        of all lists containing only the parts of a given list occurring after 
        the first element.
        Once again the name is very general and does not suggest the utility of
        this sub. This sub is useful for situations where we want to get a list
        of every file that is longer than the shortest file for a given group
        key.
        Knowing which files in a group do not have the shortest names is useful
        for knowing which files we can most likely delete for the crime of being
        extraneous, iTunes-created copies.
        Again, without a sorted group this sub's merits are dubious at best.
 note1: In the event that we have an array of 1 or 0 arguments we will end up
        with an empty array for that key value. That said we should never be
        using this on an array with 0 arguments since that would imply that we
        have a flawed grouping containing useless keys that need to be pruned
        or else some kind of grouping error that has omitted files.
=cut #--------------------------------------------------------------------------
    my $self = shift;
    my %group   = @_;
    my %regroup = ();
    foreach my $key ( keys %group ){
        my @newv = @{ $group{$key}}[ 1 .. $#{$group{$key}} ]; #note1
        $regroup{$key} = \@newv;
    }
    return %regroup;
};

my $show_hash_of_scalars = sub {
    my $self  = shift;
    my %group = @_;
    foreach my $key ( sort { $a cmp $b } keys %group ){ 
        print "$key: \"" . $group{$key} . "\"\n";
    }
};

my $show_hash_of_arrays  = sub {
    my $self = shift;
    my %group = @_;
    foreach my $key ( sort { $a cmp $b } keys %group ){ 
        print "$key: " . $quote_array->(@{$group{$key}});
    }
};

my $invert_group = sub { #------------------------------------------------------
    my $self    = shift;
    my $invkey  = shift;
    my %group   = @_;
    my %inverse = ();
    foreach my $g ( keys %group ){ # $g is the criteria the input is grouped by.
        my @filenames = @{$group{$g}};
        map { $inverse{$_} = $g; } @filenames;
    }
    my $num_files   = $self->get_mp3s;
    my $num_inverse = scalar keys %inverse;
    if( $num_files != $num_inverse ){
        die "Inversion \"$invkey\" has fewer keys than fileset for \"" .
            $$self{"name"} . "\" has files!\n"                         ; 
    }
    return %inverse; #%
};

my $hashes_have_same_keys = sub { #---------------------------------------------
    my %h1   = %{ $_[0] };
    my %h2   = %{ $_[1] };
    my %ans  = ();
    map { $ans{$_}++; } ((keys %h1), (keys %h2));
    my $any_missing = grep { $ans{$_} != 2 } keys %ans;
    return $any_missing ? $FALSE : $TRUE; #$
};

my $get_x_w_diff_y = sub { #----------------------------------------------------
=head NOTES
 note1: string-based key for inner hashes of the inverted group being searched. 
        The "same" key is used to identify two entries of interest if the values 
        corresponding to it are the same between the two entries.
 note2: string-based key for inner hashes of the inverted group being searched.
        The "diff" key is used to identify two entries of interest if the values
        corresponding to it are different between the two entries.
 note3: We expect that every entry in our inverted group will contain keys
        corresponding to $same_k and $diff_k. If this condition is not satisfied
        then it will be a fatal error and the program will terminate.
 note4: Technically it shouldn't matter whether or not we compare any
        filename to itself. However, we are explicitly avoiding that scenario 
        here. One the one hand, the condition we're checking for will always be 
        false. Thus we may be optimizing prematurely in putting this check here. 
        That said though it will probably save us some runtime.
        TODO: However, this could also be causing a very strange MD5 checksum
        error that crops up every so often for a single file in the 
        vbr_or_dmg.txt generated by dedupe.pl. Hard to say.
 note5: Structure of this arrangement is that the common value between the
        filenames is used to arrange the groups as keys to the outer hash.
        In the inner hashes, the filenames are used as keys from which one may
        optionally access the things that differentiate each filename.
            Also note that we do not expect any collisions on the keys used for 
        the inner hashes. This technique is useful because it helps us to 
        further subdivide the groups if needed. For example, if a given set of 
        filenames have the same MD5 but differing common substrings between 
        three or more files then we may find that some of those files share 
        a common substring which means that we've got to deal with a very 
        complex grouping.
=cut #--------------------------------------------------------------------------
    my $self     = shift;
    my $same_k   = shift; #note1
    my $diff_k   = shift; #note2
    my %invert   = @_   ; #note3
    my %affected = ()   ;
    foreach my $base_fn ( keys %invert ){ # loop for base filename.
        foreach my $comp_fn ( keys %invert ){ # loop for comparison filename.
            next if $base_fn eq $comp_fn; #note4
            my $svb = $invert{$base_fn}->{$same_k}; #val of same for base fname.
            my $svc = $invert{$comp_fn}->{$same_k}; #val of same for comp fname.
            my $dvb = $invert{$base_fn}->{$diff_k}; #val of diff for base fname.
            my $dvc = $invert{$comp_fn}->{$diff_k}; #val of diff for comp fname.
            if( $svb eq $svc && $dvb ne $dvc ){
                $affected{$svb} = $affected{$svb} // {};
                $affected{$svb}->{$base_fn} = $dvb; #note5
            }
        }
    }
    return %affected; #%%
};

my $show_hash_of_hash = sub { #-------------------------------------------------
    my $self     = shift;
    my %affected = @_;
    foreach my $same_k ( sort { $a cmp $b } keys %affected ){
        my @inner_ks= sort { $a cmp $b } keys %{$affected{$same_k}};
        my @pairs = map { $_ ." => " . $affected{$same_k}->{$_} } @inner_ks;
        print "$same_k :\n" . join( "\n", map { "\t- ".$_  } @pairs ) . "\n";
    }
};

#-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-

#===============================================================================
#                               PUBLIC SUBROUTINES
#===============================================================================
sub group_mp3s_by_MD5 { #-------------------------------------------------------
    my $self = shift;
    return $self->$group_by( $md5_hex , $self->get_mp3s );
}

sub show_MD5_mp3_groups { #-----------------------------------------------------
    my $self = shift;   
    $self->$show_group( \&group_mp3s_by_MD5, sub { $a cmp $b } );
}

sub group_mp3s_by_chomped_num { #-----------------------------------------------
    my $self = shift;
    return $self->$group_by( $chomp_end_numbers, $self->get_mp3s );
}

sub show_chomped_num_mp3_groups { #---------------------------------------------
    my $self = shift;
    $self->$show_group( \&group_mp3s_by_chomped_num , sub { $a cmp $b } );
}

sub regroup_mp3s_by_common_substring { #----------------------------------------
=head NOTES
 note1: For some reason @{ $cv } ends up being a better choice than @cs, which
 suffers from redundancy problems. The nature of this is examined herein:
 http://localhost:8080/wiki?name=Horrible+problems+with+Regroup+subs+and+redundancy
 Essentially, if the composite key is a common substring of the base key then we
 would prefer to push the composite value rather than a combination of the base
 and comparison values because otherwise we'll end up pushing redundant copies 
 of something with each iteration of the inner foreach loop (for the ck).
=cut #--------------------------------------------------------------------------
    my $self        = shift;
    my %group       = @_;
    my %regroup     = ();
    my @bkeys       = sort { $a cmp $b } keys %group;
    my @ckeys       = @bkeys;
    my %merged      = ();
    my %prune       = ();
    #---------------------------------------------------------------------------
    # Iterate through every base key comparing each one to every composite key.
    #---------------------------------------------------------------------------
    foreach my $bk ( @bkeys ){
        my $bv = $group{$bk}; # Is a reference.
        foreach my $ck ( @ckeys ){
            my $cv = $group{$ck}; # Is a reference.
            my @cs = $self->$common_substring( $bk, $bv, $ck, $cv );  
            push( @{ $regroup{$bk} }, @{ $cv } ) 
                if grep { defined $_ && $_ !~ m/^\s*$/ } @cs;
        }
        #-----------------------------------------------------------------------
        # After regrouping for this base key, resort its list.
        #-----------------------------------------------------------------------
        $regroup{$bk} = [ $self->$shortest_filename( @{ $regroup{$bk} } ) ]; 
    }
    %regroup = $self->$rm_merged_groups_by_common_substring( %regroup );
    return %regroup;
}

sub show_common_substrings { #--------------------------------------------------
    my $self = shift;
    my $sub = sub { return &regroup_mp3s_by_common_substring( 
        $self, $self->group_mp3s_by_chomped_num ); };
    $self->$show_group( $sub, sub {$a cmp $b});
}

sub regroup_MD5s_by_shortest_filename { #---------------------------------------
    my $self    = shift;
    my %group   = $self->group_mp3s_by_MD5;
    my %regroup = ();
    foreach my $key ( keys %group ){
        my @newv = $self->$shortest_filename( @{ $group{$key} } );
        $regroup{$key} = \@newv;
    }
    return %regroup;
}

sub show_MD5s_by_shortest_filename { #------------------------------------------
    my $self    = shift;
    $self->$show_group( \&regroup_MD5s_by_shortest_filename, sub{ $a cmp $b } );
}

sub show_shortest_from_MD5s { #-------------------------------------------------
    my $self  = shift;
    my %group = $self->$get_cars_of_group(
        $self->regroup_MD5s_by_shortest_filename );
    $self->$show_hash_of_scalars(%group);
}

sub get_shortest_from_common_substrings { #-------------------------------------
    my $self = shift;
    my %group = $self->$get_cars_of_group( 
        $self->regroup_mp3s_by_common_substring( 
            $self->group_mp3s_by_chomped_num ));
    return %group;
}

sub show_shortest_from_common_substrings { #------------------------------------
    my $self = shift;
    my %group = $self->$get_cars_of_group( 
        $self->regroup_mp3s_by_common_substring( 
            $self->group_mp3s_by_chomped_num ));
    $self->$show_hash_of_scalars(%group);
}

sub get_longest_from_common_substrings { #--------------------------------------
    my $self = shift;
    my %group = $self->$get_cdrs_of_group( 
        $self->regroup_mp3s_by_common_substring( 
            $self->group_mp3s_by_chomped_num ));
    return %group;
}

sub show_longest_from_common_substrings { #-------------------------------------
    my $self = shift;
    my %group = $self->$get_cdrs_of_group( 
        $self->regroup_mp3s_by_common_substring( 
            $self->group_mp3s_by_chomped_num ));
    $self->$show_hash_of_arrays(%group);
}

sub get_MD5s_shortest_filename_only { #-----------------------------------------
    my $self       = shift;
    my %group      = $self->regroup_MD5s_by_shortest_filename;
    my %car_of_grp = $self->get_cdrs_of_group( %group );
    return %car_of_grp;
}

sub show_MD5s_shortest_filename_only { #----------------------------------------
    my $self  = shift;
    my %group = $self->get_MD5s_shortest_filename_only;
    $self->$show_hash_of_scalars(%group);
}

sub invert_cs_group { #---------------------------------------------------------
    my $self    = shift;
    my %group   = $self->regroup_mp3s_by_common_substring( 
        $self->group_mp3s_by_chomped_num );
    my %inverse = $self->$invert_group( "cs", %group );
    return %inverse; #%
}
sub show_invert_cs_group { #----------------------------------------------------
    my $self = shift;
    $self->$show_hash_of_scalars( $self->invert_cs_group );
}

sub invert_md5_group { #--------------------------------------------------------
    my $self    = shift;
    my %group   = $self->regroup_MD5s_by_shortest_filename;
    my %inverse = $self->$invert_group( "md5", %group );
    return %inverse; #%
}

sub show_invert_md5_group { #----------------------------------------------------
    my $self = shift;
    $self->$show_hash_of_scalars( $self->invert_md5_group );
}

sub get_filenames_w_cs_and_MD5 { #----------------------------------------------
    my $self        = shift;
    my %invert_cs   = $self->invert_cs_group ;
    my %invert_md5  = $self->invert_md5_group;
    my $num_inv_cs  = scalar keys %invert_cs ;
    my $num_inv_md5 = scalar keys %invert_md5;
    my %invert_combo;
    if( $num_inv_cs != $num_inv_md5 ){
        die "Inversion of cs and md5 groups for fileset of dir \""            .
        $$self{"name"}."\" has a mismatched number of files between the two " .
        "group inversions! cs = $num_inv_cs, md5 = $num_inv_md5\n"            ; 
    }
    if( ! $hashes_have_same_keys->( \%invert_cs, \%invert_md5 ) ){
        die "Inversion of cs and md5 groups for fileset of dir \""             .
        $$self{"name"}."\" contains two inverted groups with differing keys!\n";
    }
    %invert_combo = map { 
        ( $_, { "cs" => $invert_cs{$_}, "md5" => $invert_md5{$_}, } ) 
    } keys %invert_cs;
    return %invert_combo; #%%
}

sub show_filenames_w_cs_and_MD5 { #---------------------------------------------
    my $self = shift;
    my %combo = $self->get_filenames_w_cs_and_MD5;
    $self->$show_hash_of_hash( %combo );
}

sub get_cs_w_diff_MD5s { #------------------------------------------------------
    my $self     = shift;
    my %invert   = $self->get_filenames_w_cs_and_MD5;
    my %affected = $self->$get_x_w_diff_y( "cs", "md5", %invert );
    return %affected; #%%
}

sub show_cs_w_diff_MD5s { #-----------------------------------------------------
    my $self = shift;
    my %affected = $self->get_cs_w_diff_MD5s;
    $self->$show_hash_of_hash( %affected );
}

sub get_MD5s_w_diff_cs { #------------------------------------------------------
    my $self     = shift;
    my %invert   = $self->get_filenames_w_cs_and_MD5;
    my %affected = $self->$get_x_w_diff_y( "md5", "cs", %invert );
    return %affected; #%%
}

sub show_MD5s_w_diff_cs { #-----------------------------------------------------
    my $self = shift;
    my %affected = $self->get_MD5s_w_diff_cs;
    $self->$show_hash_of_hash( %affected );
}

sub get_mp3s { #----------------------------------------------------------------
    my $self = shift;
    return $self->$get_fmt( $mp3_patt ); #@
}

sub get_abnormal_mp3s { #-------------------------------------------------------
    my $self = shift;
    return $self->$get_abnormal_fmt( $mp3_patt, $mp3_npat ); #@
}

sub get_m4as { #----------------------------------------------------------------
    my $self = shift;
    return $self->$get_fmt( $m4a_patt ); #@
}

sub get_abnormal_m4as { #-------------------------------------------------------
    my $self = shift;
    return $self->$get_abnormal_fmt( $m4a_patt, $m4a_npat ); #@
}

sub get_oggs { #----------------------------------------------------------------
    my $self = shift;
    return $self->$get_fmt( $ogg_patt ); #@
}

sub get_abnormal_oggs { #-------------------------------------------------------
    my $self = shift;
    return $self->$get_abnormal_fmt( $ogg_patt, $ogg_npat ); #@
}

sub get_non_music { #-----------------------------------------------------------
    my $self = shift;
    my @music_patts = ( $mp3_patt, $m4a_patt, $ogg_patt ); 
    my $non_music_patt = '(' . join( '|', @music_patts ) . ')';
    return $self->$get_fmt( $non_music_patt, $TRUE ); #@
}

sub new { #---------------------------------------------------------------------
    my $class    = shift;
    my $dir_name = shift;
    my @files    = @_;
    my $obj      = { };
    bless $obj, $class;
    $$obj{"name"}  = $dir_name;
    $$obj{"files"} = \@files;
    return $obj;
}
#-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-
#                                 END OF CLASS
#-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-
1;
