#!/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 <<EOF;

$progname $version - Manage metadata of tracks in an album.
Copyright 2008-2009 Paul C. Bryan. All rights reserved.

EOF

  $header = 1;
}

# output informative message if not quiet
sub msg {
  header(); print STDERR "$_[0]\n" if (!defined($quiet));
}

# display error message if not quiet, then exit with error code
sub error {
  undef $quiet; msg("$_[0]\n"); exit 1;
}

# output diagnostic if verbosity enabled
sub diag {
  msg($_[0]) if (defined($verbose));
}

# display system call as diagnostic, then execute system call
sub sys {
  diag($_[0]); system($_[0]) == 0 or error("Error calling $_[0].");
}

# escape special characters to make appropriate for quoted program arguments
sub escq {
  return undef if (!defined($_[0]));
  my $v = $_[0];
  $v =~ s/\"/\\\"/g;
  $v =~ s/\$/\\\$/g;
  return '"' . $v . '"';
}

# trims spaces from start and end of string
sub trim {
  return undef if (!defined($_[0]));
  my $v = $_[0];
  $v =~ s/^[[:space:]]+//; $v =~ s/[[:space:]]+$//;
  return $v;
}

# return true if value defined and not empty
sub is_value {
  return (defined($_[0]) && length($_[0]) > 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: ";
  (<STDIN>);
}

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 <<EOF;
Usage: $progname [options] [album_dir]

OPTIONS:
  --discid=<id>       Disc ID to use for MusicBrainz lookup. [${\(undefined($discid))}]
  --[no]edit          Edit the metadata prior to committing it. [${\(noboolean($edit))}]
  --editor=<prog>     Program to use to edit metadata. [$editor]
  --input=<type>      Input type (see below). [$input]
  --help              Display this help message.
  --metaflac=<prog>   Program to use to manage metadata in FLAC files. [$metaflac]
  --outfile=<path>    File to write output, if necessary. [${\(undefined($outfile))}]
  --output=<type>     Output type (see below). [$output]
  --releaseid=<id>    Release ID to use for MusicBrainz lookup. [${\(undefined($releaseid))}]
  --quiet             Quiet operation, no screen output.
  --suppress=<tags>   List of tags to suppress in metadata. [${\(undefined($outfile))}]
  --trackidtag=<tag>  Name of tag in FLAC file to store track ID. [$trackidtag]
  --tmp=<dir>         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");

