#!/usr/bin/perl -w

=pod

=head1 NAME

tv_grab_fi - Grab TV listings for Finland.

=head1 SYNOPSIS

tv_grab_fi --help

tv_grab_fi [--config-file FILE] --configure [--gui OPTION]

tv_grab_fi [--config-file FILE] [--output FILE] [--days N]
           [--offset N] [--quiet]

tv_grab_fi --list-channels

tv_grab_fi --capabilities

tv_grab_fi --version

=head1 DESCRIPTION

Output TV listings for several channels available in Finland.
The data comes from www.telkku.com. The grabber relies on parsing HTML
so it might stop working at any time.

First run B<tv_grab_fi --configure> to choose, which channels you want
to download. Then running B<tv_grab_fi> with no arguments will output
listings in XML format to standard output.

B<--configure> Prompt for which channels,
and write the configuration file.

B<--config-file FILE> Set the name of the configuration file, the
default is B<~/.xmltv/tv_grab_fi.conf>.  This is the file written by
B<--configure> and read when grabbing.

B<--gui OPTION> Use this option to enable a graphical interface to be used.
OPTION may be 'Tk', or left blank for the best available choice.
Additional allowed values of OPTION are 'Term' for normal terminal output
(default) and 'TermNoProgressBar' to disable the use of XMLTV::ProgressBar.

B<--output FILE> Write to FILE rather than standard output.

B<--days N> Grab N days.  The default is 14 (22 days should be available).

B<--offset N> Start N days in the future.  The default is to start
from today.

B<--quiet> Suppress the progress messages normally written to standard
error.

B<--list-channels> Write output giving <channel> elements for every
channel available (ignoring the config file), but no programmes.

B<--capabilities> Show which capabilities the grabber supports. For more
information, see L<http://wiki.xmltv.org/index.php/XmltvCapabilities>

B<--version> Show the version of the grabber.

B<--help> Print a help message and exit.

=head1 SEE ALSO

L<xmltv(5)>.

=head1 AUTHOR

Officially maintained by Ville Ahonen (ville dot ahonen at iki dot fi), but in practice all recent fixes have been written by Stefan Becker. Based on previous version of tv_grab_fi by Matti Airas. 

=head1 BUGS

The data source does not include full channels information and the
channels are identified by short names rather than the RFC2838 form
recommended by the XMLTV DTD.

=cut

######################################################################
# initializations

use strict;
use XMLTV::Version '$Id: tv_grab_fi,v 1.65 2010/12/15 21:58:24 stefanb2 Exp $ ';
use XMLTV::Capabilities qw/baseline manualconfig cache/;
use XMLTV::Description 'Finland';
use Encode qw(decode_utf8);
use Getopt::Long;
use Date::Manip;
use HTML::Entities;
use HTML::TreeBuilder;
use POSIX qw(strftime tzset);
use Time::Local qw(timelocal);

use XMLTV;
use XMLTV::Memoize;
use XMLTV::ProgressBar;
use XMLTV::Ask;
use XMLTV::Config_file;
#use XMLTV::DST;
use XMLTV::Get_nice;
use XMLTV::Mode;
use XMLTV::Date;
# Todo: perhaps we should internationalize messages and docs?
use XMLTV::Usage <<END
$0: get Finnish television listings in XMLTV format
To configure: $0 --configure [--config-file FILE]
To grab listings: $0 [--config-file FILE] [--output FILE] [--days N]
        [--offset N] [--quiet]
To list channels: $0 --list-channels
To show capabilities: $0 --capabilities
To show version: $0 --version
END
  ;

my $DOMAIN = 'telkku.com';
my $SITE = "http://www.$DOMAIN";

# Attributes of the root element in output.
my $HEAD = { 'source-info-url'     => "$SITE/",
	     'source-data-url'     => "$SITE/",
	     'generator-info-name' => 'XMLTV',
	     'generator-info-url'  => 'http://xmltv.org/',
	   };

# default language
my $LANG="fi";

# Global channel data.
our @ch_all;


######################################################################
# get options

# Get options, including undocumented --cache option.
XMLTV::Memoize::check_argv('XMLTV::Get_nice::get_nice_aux');
my ($opt_days, $opt_offset, $opt_help, $opt_output,
    $opt_configure, $opt_config_file, $opt_gui,
    $opt_quiet, $opt_list_channels);
$opt_days  = 14; # default
$opt_offset = 0; # default
$opt_quiet  = 0; # default
GetOptions('days=i'        => \$opt_days,
	   'offset=i'      => \$opt_offset,
	   'help'          => \$opt_help,
	   'configure'     => \$opt_configure,
	   'config-file=s' => \$opt_config_file,
       'gui:s'         => \$opt_gui,
	   'output=s'      => \$opt_output,
	   'quiet'         => \$opt_quiet,
	   'list-channels' => \$opt_list_channels,
	  )
  or usage(0);
die 'number of days must not be negative'
  if (defined $opt_days && $opt_days < 0);
usage(1) if $opt_help;

XMLTV::Ask::init($opt_gui);

my $mode = XMLTV::Mode::mode('grab', # default
			     $opt_configure => 'configure',
			     $opt_list_channels => 'list-channels',
			    );

# File that stores which channels to download.
my $config_file
  = XMLTV::Config_file::filename($opt_config_file, 'tv_grab_fi', $opt_quiet);

my @config_lines; # used only in grab mode
if ($mode eq 'configure') {
    XMLTV::Config_file::check_no_overwrite($config_file);
}
elsif ($mode eq 'grab') {
    @config_lines = XMLTV::Config_file::read_lines($config_file);
}
elsif ($mode eq 'list-channels') {
    # Config file not used.
}
else { die }

# Whatever we are doing, we need the channels data.
my %channels = get_channels(); # sets @ch_all
my @channels;

######################################################################
# write configuration

if ($mode eq 'configure') {
    open(CONF, ">$config_file") or die "cannot write to $config_file: $!";

    # Ask about each channel.
    my @chs = sort keys %channels;
    my @names = map { $channels{$_} } @chs;
    my @qs = map { "add channel $_?" } @names;
    my @want = ask_many_boolean(1, @qs);
    foreach (@chs) {
	my $w = shift @want;
	warn("cannot read input, stopping channel questions"), last
	  if not defined $w;
	# No need to print to user - XMLTV::Ask is verbose enough.

	# Print a config line, but comment it out if channel not wanted.
	print CONF '#' if not $w;
	my $name = shift @names;
	print CONF "channel $_ $name\n";
	# TODO don't store display-name in config file.
    }

    close CONF or warn "cannot close $config_file: $!";
    say("Finished configuration.");

    exit();
}

# Not configuration, we must be writing something, either full
# listings or just channels.
#
die if $mode ne 'grab' and $mode ne 'list-channels';

# Options to be used for XMLTV::Writer.
my %w_args = (
	      encoding => 'UTF-8',
	     );	      ;
if (defined $opt_output) {
    open($w_args{OUTPUT}, ">:utf8", $opt_output)
      or die "cannot write to $opt_output: $!";
} else {
    binmode(STDOUT, ":utf8");
}
my $writer = new XMLTV::Writer(%w_args);
$writer->start($HEAD);

if ($mode eq 'list-channels') {
    # Write channels mode.
    $writer->write_channel($_) foreach @ch_all;
    $writer->end();
    exit();
}

######################################################################
# We are producing full listings.
die if $mode ne 'grab';

# Read configuration.
my %title;
my %description;
my $line_num = 1;
foreach (@config_lines) {
    ++ $line_num;
    next if not defined;

    # XMLTV::Config::read_lines doesn't allow us to set the encoding
    $_ = decode_utf8($_);

    if (/^channel:?\s+(\S+)\s+([^\#]+)/) {
	my $ch_did = $1;
	my $ch_name = $2;
	$ch_name =~ s/\s*$//;
	push @channels, $ch_did;
	$channels{$ch_did} = $ch_name;
    }
    elsif (/^series:?\s+title:?\s+([^\#]+)/) {
	my $name = $1;
	$name =~ s/\s*$//;
	$title{$name}++;
    }
    elsif (/^series:?\s+description:?\s+([^\#]+)/) {
	my $name = $1;
	$name =~ s/\s*$//;
	$description{$name}++;
    }
    else {
	warn "$config_file:$line_num: bad line\n";
    }
}

######################################################################
# begin main program

die "No channels specified, run me with --configure\n"
  if not keys %channels;

# Each page on telkku.com contains the program information
# for one channel for one whole day.
#
# Example (compiled from several pages for illustration):
#
#  /- start time             (day)
#  |     /- program title
#  |     |
# [23:45 Uutisikkuna         (yesterday)]
#  00:10 Uutisikkuna         (today    )
#  ...
#  23:31 Uusi päivä          (today    )
#  00:00 Kova laki           (tomorrow )
# [00:40 Piilosana           (tomorrow )]
# [01:00 Tellus-tietovisa    (tomorrow )]
#
# The lines in [] don't appear on every page.
{
  my @dates;

  sub init_date_data() {
    # Start one day before offset
    my $date = DateCalc(parse_date('today'),
			($opt_offset - 1) . " days") or die;

    # End one day after offset + days
    for (0..$opt_days+1) {
      my($year, $month, $day) = split(':', UnixDate($date, "%Y:%m:%d"));
      push(@dates, {
		    day   => int($day),
		    month => int($month),
		    year  => int($year),
		   });
      $date  = DateCalc($date, "+1 day") or die;
    }
  }

  sub get_day($) {
    my($index) = @_;
    return(@dates[$index, $index + 1, $index + 2]);
  }
}
init_date_data();

# Time zone handling
#
# Now that we have setup the day list we switch to a fixed time zone in order
# to interpret the program start times from telkku.com. In this case we of
# course use
#
#      Europe/Helsinki
#
# which can mean
#
#      EET  = GMT+02:00 (East European Time)
#      EEST = GMT+03:00 (East European Summer Time)
#
# depending on the day of the year. By using a fixed time zone this grabber
# will always be able to correctly calculate the program start time in UTC,
# no matter what the time zone of the local system is.
#
# Test program:
# ---------------------- CUT HERE ---------------------------------------------
# use Time::Local;
# use POSIX qw(strftime tzset);
#
# # DST test days for Europe 2010
# my @testdays = (
# 		# hour, minute, mday, month
# 		[    2,     00,    1,     1],
# 		[    2,     59,   28,     3],
# 		[    3,     00,   28,     3],
# 		[    3,     01,   28,     3],
# 		[    3,     00,    1,     7],
# 		[    3,     59,   31,    10],
# 		[    4,     00,   31,    10],
# 		[    4,     01,   31,    10],
# 		[    2,     00,    1,    12],
# 	       );
#
# print strftime("System time zone is: %Z\n", localtime(time()));
# if (@ARGV) {
#   $ENV{TZ} = "Europe/Helsinki";
#   tzset();
# }
# print strftime("Script time zone is: %Z\n", localtime(time()));
#
# foreach my $date (@testdays) {
#   my $time = timelocal(0, @{$date}[1, 0, 2], $date->[3] - 1, 2010);
#   print
#     "$time: ", strftime("%d-%b-%Y %T %z", localtime($time)),
#     " -> ",    strftime("%d-%b-%Y %T +0000", gmtime($time)), "\n";
# }
# ---------------------- CUT HERE ---------------------------------------------
#
# Test runs:
#
# 1) system on Europe/Helsinki time zone [REFERENCE]
#
# $ perl test.pl
# System time zone is: EET
# Script time zone is: EET
# 1262304000: 01-Jan-2010 02:00:00 +0200 -> 01-Jan-2010 00:00:00 +0000
# 1269737940: 28-Mar-2010 02:59:00 +0200 -> 28-Mar-2010 00:59:00 +0000
# 1269738000: 28-Mar-2010 04:00:00 +0300 -> 28-Mar-2010 01:00:00 +0000
# 1269738060: 28-Mar-2010 04:01:00 +0300 -> 28-Mar-2010 01:01:00 +0000
# 1277942400: 01-Jul-2010 03:00:00 +0300 -> 01-Jul-2010 00:00:00 +0000
# 1288486740: 31-Oct-2010 03:59:00 +0300 -> 31-Oct-2010 00:59:00 +0000
# 1288490400: 31-Oct-2010 04:00:00 +0200 -> 31-Oct-2010 02:00:00 +0000
# 1288490460: 31-Oct-2010 04:01:00 +0200 -> 31-Oct-2010 02:01:00 +0000
# 1291161600: 01-Dec-2010 02:00:00 +0200 -> 01-Dec-2010 00:00:00 +0000
#
# 2) system on America/New_York time zone
#
# $ TZ="America/New_York" perl test.pl
# System time zone is: EST
# Script time zone is: EST
# 1262329200: 01-Jan-2010 02:00:00 -0500 -> 01-Jan-2010 07:00:00 +0000
# 1269759540: 28-Mar-2010 02:59:00 -0400 -> 28-Mar-2010 06:59:00 +0000
# 1269759600: 28-Mar-2010 03:00:00 -0400 -> 28-Mar-2010 07:00:00 +0000
# 1269759660: 28-Mar-2010 03:01:00 -0400 -> 28-Mar-2010 07:01:00 +0000
# 1277967600: 01-Jul-2010 03:00:00 -0400 -> 01-Jul-2010 07:00:00 +0000
# 1288511940: 31-Oct-2010 03:59:00 -0400 -> 31-Oct-2010 07:59:00 +0000
# 1288512000: 31-Oct-2010 04:00:00 -0400 -> 31-Oct-2010 08:00:00 +0000
# 1288512060: 31-Oct-2010 04:01:00 -0400 -> 31-Oct-2010 08:01:00 +0000
# 1291186800: 01-Dec-2010 02:00:00 -0500 -> 01-Dec-2010 07:00:00 +0000
#
# 3) system on America/New_York time zone, script on Europe/Helsinki time zone
#    [compare to output from (1)]
#
# $ TZ="America/New_York" perl test.pl switch
# System time zone is: EST
# Script time zone is: EET
# 1262304000: 01-Jan-2010 02:00:00 +0200 -> 01-Jan-2010 00:00:00 +0000
# 1269737940: 28-Mar-2010 02:59:00 +0200 -> 28-Mar-2010 00:59:00 +0000
# 1269738000: 28-Mar-2010 04:00:00 +0300 -> 28-Mar-2010 01:00:00 +0000
# 1269738060: 28-Mar-2010 04:01:00 +0300 -> 28-Mar-2010 01:01:00 +0000
# 1277942400: 01-Jul-2010 03:00:00 +0300 -> 01-Jul-2010 00:00:00 +0000
# 1288486740: 31-Oct-2010 03:59:00 +0300 -> 31-Oct-2010 00:59:00 +0000
# 1288490400: 31-Oct-2010 04:00:00 +0200 -> 31-Oct-2010 02:00:00 +0000
# 1288490460: 31-Oct-2010 04:01:00 +0200 -> 31-Oct-2010 02:01:00 +0000
# 1291161600: 01-Dec-2010 02:00:00 +0200 -> 01-Dec-2010 00:00:00 +0000
#
# Setup fixed time zone for program start time interpretation
$ENV{TZ} = "Europe/Helsinki";
tzset();

# the order in which we fetch the channels matters
my @to_get;
foreach my $ch_did (@channels) {
    my $ch_name=$channels{$ch_did};
    my $ch_xid="$ch_did.$DOMAIN";
    $writer->write_channel({ id => $ch_xid,
			     'display-name' => [ [ $ch_name ] ] });
    foreach my $i (0..$opt_days-1) {
	push(@to_get, [ get_day($i), $ch_xid, $ch_did ]);
    }
}

# This progress bar is for both downloading and parsing.  Maybe
# they could be separate stages.
#
my $bar = new XMLTV::ProgressBar( {
   name => 'getting listings',
   count => scalar @to_get,
 } ) if not $opt_quiet;
foreach (@to_get) {
    foreach (process_table(@{ $_ })) {
	$writer->write_programme($_);
    }
    update $bar if not $opt_quiet;
}
$bar->finish() if not $opt_quiet;
$writer->end();

######################################################################
# subroutine definitions

# Use Log::TraceMessages if installed.
BEGIN {
    eval { require Log::TraceMessages };
    if ($@) {
	*t = sub {};
	*d = sub { '' };
    }
    else {
	*t = \&Log::TraceMessages::t;
	*d = \&Log::TraceMessages::d;
	Log::TraceMessages::check_argv();
    }
}

# Take a day (day/month/year) and the program start time (hour/minute)
# and convert it to seconds since Epoch in the current time zone (see above)
sub program_time_to_epoch($$) {
  my($date, $program) = @_;
  return(timelocal(0, $program->{minute}, $program->{hour},
		   $date->{day}, $date->{month} - 1, $date->{year}));
}

####
# process_table: fetch a URL and process it
#
# arguments:
#    one URL can contain data from 3 days (previous/today/tomorrow)
#    xmltv id of channel
#    their id of channel
#
# returns: list of programme hashes to write
#
sub process_table {
    my ($yesterday, $today, $tomorrow, $ch_xmltv_id, $ch_their_id) = @_;
    my $url = "$SITE/channel/list/$ch_their_id/" .
      sprintf("%04d%02d%02d", $today->{year}, $today->{month}, $today->{day});
    t "getting URL: $url";
    my $tree = get_nice_tree($url, \&decode_utf8);
    local $SIG{__WARN__} = sub {
	warn "$url: $_[0]";
    };

    my @program_data = get_program_data($tree);

    # No data found -> return empty list
    return unless @program_data;

    # Check for day crossing between first and second entry
    my @dates = ($today, $tomorrow);
    unshift(@dates, $yesterday)
      if ((@program_data > 1) &&
	  ($program_data[0]->{start} > $program_data[1]->{start}));


    my @r;
    my $date          = shift(@dates);
    my $current       = shift(@program_data);
    my $current_start = $current->{start};
    my $current_epoch = program_time_to_epoch($date, $current);
    foreach my $next (@program_data) {

      # Start of next program might be on the next day
      my $next_start = $next->{start};
      $date          = shift(@dates)
	if $current_start > $next_start;
      my $next_epoch = program_time_to_epoch($date, $next);

      push(@r, make_programme_hash($ch_xmltv_id, $current,
				   $current_epoch, $next_epoch));

      $current       = $next;
      $current_start = $next_start;
      $current_epoch = $next_epoch;
    }
    return @r ;
}

# Convert seconds since Epoch to XMLTV time stamp
#
# NOTE: We have to generate the time stamp using local time plus time zone as
#       some XMLTV users, e.g. mythtv in the default configuration, ignore the
#       XMLTV time zone value.
#
sub epoch_to_xmltv_time($) {
  my($time) = @_;

  # Unfortunately strftime()'s %z is not portable...
  #
  # return(strftime("%Y%m%d%H%M00 %z", localtime($time));
  #
  # ...so we have to roll our own:
  #
  my @time = localtime($time); #               is_dst
  return(strftime("%Y%m%d%H%M00 +0", @time) . ($time[8] ? "3": "2") . "00");
}

sub make_programme_hash {
    my ($ch_xmltv_id, $current, $start, $stop) = @_;

    # XMLTV programme desciptor
    my %prog = (
		channel => $ch_xmltv_id,
		start   => epoch_to_xmltv_time($start),
		stop    => epoch_to_xmltv_time($stop),
	       );

    # Check for series.
    #
    # Check 1: episode name of series in title.
    # If title contains a colon (:), check to see if the string on the
    # left-hand side of the colon has been defined as a series in the
    # conf-file. If it has, assume that the string on the left-hand side
    # of the colon is the name of the series, and the string on the
    # right-hand side is the name of the episode. For example, if the
    # following line has been defined in the tv_grab_fi.conf-file:
    # "series title Prisma", and the title of the program is
    # "Prisma: Totuus tappajadinosauruksista", then the script will assume
    # that the title of the program is actually "Prisma", and the episode
    # name/sub-title is "Totuus tappajadinosauruksista".
    if (($current->{title} =~ m/([^:]+):\s*(.*)/) &&
	(exists $title{$1})) {
	my $new_title = $1;
	my $episode = $2;
	t "series $new_title, episode title $episode";
	$prog{title}=[ [ $new_title, $LANG ] ];
	$prog{'sub-title'} = [ [ $episode, $LANG ] ];
    }
    else {
	$prog{title}=[ [ $current->{title}, $LANG ] ];
    }
    # Check 2: episode name of series in description.
    # Check if the program has a description. If so, also check if the title
    # of the program has been defined as a series in the conf-file. If it
    # has, assume that the first sentence (i.e. the text before the first
    # period) marks the name of the episode. For example, if the following
    # line has been defined in the tv_grab_fi.conf-file:
    # "series description Batman", the title of of the program is "Batman",
    # and the description of the program is "Pingviinin paluu. Amerikkalainen
    # animaatiosarja. Outojen ryöstöjen sarja johdattaa Batmanin Pingviinin
    # jäljille.", then the script will assume that the episode name/sub-title
    # is "Pingviinin paluu", and that the description is actually
    # "Amerikkalainen animaatiosarja. Outojen ryöstöjen sarja johdattaa
    # Batmanin Pingviinin jäljille."
    if ((defined $current->{description}) &&
	(exists $description{$current->{title}})   &&
	($current->{description} =~ s/^\s*([^.]+)\.\s*//)) {
	my $episode = $1;
	t "series $current->{title}, episode title $episode";
	$prog{'sub-title'} = [ [ $episode, $LANG ] ];

	# Make sure the description is not left empty
	$current->{description} = "TBA" if ($current->{description} =~ /^\s*$/);
    }
    $prog{desc}=[ [ $current->{description}, $LANG ] ]
      if defined $current->{description};

    t "generated ($prog{start} -> " . (exists $prog{stop} ? $prog{stop} : "NO END") . ": $prog{title}->[0]->[0]";

    return \%prog;
}

#####
# All program info is contained within a unsorted list with class "programList"
#
# </div>
#  <ul class="programList">
#   <li>
#    <span class="programDate"><a href="http://www.telkku.com/program/show/2010112621451">23:45&nbsp;Uutisikkuna</a></span><br />
#    <span class="programDescription"></span>
#   </li>
#   ...
#  </ul>
#  <div ...
#
sub get_program_data {
    my $tree = shift;
    t "get_program_data() ENTRY for tree: $tree";
    my @data;
    # Dump the html-tree to a string for matching
    my $html = $tree->as_HTML;
    # We don't need the tree anymore
    $tree->delete;
    while ($html =~ m,<li><span class="programDate"><a href="$SITE/program/show/\d+">(\d{2}):(\d{2})&nbsp;(.+?)</a></span><br(?: \/)?><span class="programDescription">(.*?)</span>,go) {
      # Use decode_entities() to convert html characters
      # to ascii (e.g &auml; to ä)
      my $hour        = $1;
      my $minute      = $2;
      my $start       = $hour * 60 + $minute; # minutes since midnight
      my $title       = decode_entities($3);
      my $description = decode_entities($4);

      # Only record entry if title isn't empty
      if (length($title) > 0) {
	t "got programme starting at $start '$title'";

	# mandatory fields
	my %program = (
		       hour   => $hour,
		       minute => $minute,
		       start  => $start,
		       title  => $title,
		      );

	# optional fields
	$program{description} = $description
	  if length($description) > 0;

	push(@data, \%program);
      }
    }

    t 'get_program_data() RETURNING ' . d \@data;
    return @data;
}

# get channel listing
sub get_channels {
    my $bar = new XMLTV::ProgressBar({
       name => 'getting list of channels',
       count => 1,
    } ) if not $opt_quiet;

    my $url="$SITE/channel";
    t "getting URL: $url";
    my $tree = get_nice_tree($url, \&decode_utf8);

    # FIXME commonize this
    local $SIG{__WARN__} = sub {
	warn "$url: $_[0]";
    };
    local $SIG{__DIE__} = sub {
	die "$url: $_[0]";
    };

    # All channels are listed in the left side bar within <li> tags
    #
    # <div id="channelContainer">
    #  <div id="channelList">
    #   <div id="channelListHeader">
    #	 <div id="channelListHeaderRight"></div>
    #    <div id="channelListHeaderLeft"></div>
    #    Kaikki kanavat
    #   </div>
    #   <ul>
    #    <li><a href="http://telkku.com/channel/list/1/20101127">TV1</a></li>
    #    ...
    #   </ul>
    #  </div>
    #  <div id="channelContent">

    my $html = $tree->as_HTML;
    # We don't need the tree anymore
    $tree->delete;
    my($trunc_html) = $html =~ m,<div id="channelContainer">.*</div><ul>(.*)</ul></div><div id="channelContent">,;
    die "can't find channel information" unless defined $trunc_html;

    my %channels = ($trunc_html =~ m,<li><a href="$SITE/channel/list/(\d+)/\d+">(.+?)</a>,g);
    die "no channels could be found" if not keys %channels;

    @ch_all = map { {
		      'display-name' => [ [ $channels{$_}, $LANG ] ],
		      id             => $_,
		    } }
              sort { $a <=> $b }
	      keys %channels;

    update $bar if not $opt_quiet;
    $bar->finish() if not $opt_quiet;
    return %channels;
}
