#!/usr/bin/perl
=pod
=encoding utf8
=head1 NAME
tv_grab_pt_vodafone - Grab TV listings for Vodafone in Portugal
=head1 SYNOPSIS
tv_grab_pt_vodafone --help
tv_grab_pt_vodafone --configure [--config-file FILE]
tv_grab_pt_vodafone [--config-file FILE]
[--days N] [--offset N] [--channel xmltvid,xmltvid,...]
[--output FILE] [--quiet | --debug]
tv_grab_pt_vodafone --list-channels [--config-file FILE]
[--output FILE] [--quiet | --debug]
=head1 DESCRIPTION
Output TV listings in XMLTV format for many stations available in Portugal.
This program consumes the EPG service from L.
First you must run B to choose which stations
you want to receive.
Then running B with no arguments will get listings for
the stations you chose for the maximum 7 days, including today.
=head1 OPTIONS
B<--configure> Prompt for which stations to download and write the
configuration file.
B<--config-file FILE> Set the name of the configuration file, the
default is B<~/.xmltv/tv_grab_pt_vodafone.conf>. This is the file written by
B<--configure> and read when grabbing.
B<--output FILE> When grabbing, write output to FILE rather than
standard output.
B<--days N> When grabbing, grab N days rather than everything available.
B<--offset N> Start grabbing at today + N days.
B<--quiet> Only print error-messages on STDERR.
B<--debug> Provide more information on progress to STDERR to help in
debugging.
B<--list-channels> Output a list of all channels that data is available for.
The list is in xmltv-format.
B<--capabilities> Show which capabilities the grabber supports. For more
information, see L
B<--version> Show the version of the grabber.
B<--help> Print a help message and exit.
=head1 ERROR HANDLING
If the grabber fails to download data from Vodafone, it will print an
error message to STDERR and then exit with a status code of 1 to indicate
that the data is missing.
=head1 ENVIRONMENT VARIABLES
The environment variable HOME can be set to change where configuration
files are stored. All configuration is stored in $HOME/.xmltv/. On Windows,
it might be necessary to set HOME to a path without spaces in it.
=head1 CREDITS
Kevin Groeneveld (kgroeneveld at gmail dot com)
This grabber uses code from tv_grab_pt_meo by Karl Dietz, dekarl -at- users -dot- sourceforge -dot- net,
and from tv_grab_zz_sdjson by Kevin Groeneveld, kgroeneveld -at- gmail -dot- com.
The original idea of this grabber came from higuita's shell script, see
L.
Special thanks to Vodafone for building a clean, fast, and public access API;
much more reliable than Meo's open API (but sadly not as open) and much better
than the lack of any API from NOS.
=head1 AUTHOR
Nuno Sénica, nsenica -at- gmail -dot- com.
=head1 BUGS
None known.
=cut
use warnings;
use strict;
use utf8;
use XMLTV;
use XMLTV::Version "$XMLTV::VERSION";
use DateTime;
use Encode; # used to convert 'perl strings' into 'utf-8 strings'
use XML::LibXML;
use XMLTV::Configure::Writer;
use XMLTV::Get_nice qw/get_nice/;
use XMLTV::Options qw/ParseOptions/;
use XMLTV::Supplement qw/GetSupplement/;
use JSON;
use Text::Unidecode;
use URI::Escape qw/ uri_escape /;
use URI::Encode qw/ uri_encode uri_decode/;
# use Data::Dump qw/pp/; # uncomment to debug
my $maxdays = 1+6; # data source is limited to 7 days (including today)
my $grabber_name = 'tv_grab_pt_vodafone';
my $grabber_version = '3.00';
my $json_baseurl = 'https://cdn.pt.vtv.vodafone.com/epg/';
# Generate with:
# jq -r '.channels[]|(.epgId,.title,.logo.color.uri)' channel.list.new | sed 'N;N; s/\n/\t/g' | sort > channel.list
my $channel_list_file = 'channel.list';
my $ua = LWP::UserAgent->new(ssl_opts => {
SSL_cipher_list => 'DEFAULT:!DH',
});
$ua->agent("$grabber_name $grabber_version");
$ua->default_header('accept-encoding' => scalar HTTP::Message::decodable());
my( $opt, $conf ) = ParseOptions( {
grabber_name => $grabber_name,
capabilities => [qw/apiconfig baseline manualconfig preferredmethod/],
listchannels_sub => \&list_channels,
stage_sub => \&config_stage,
version => "$XMLTV::VERSION",
description => "Portugal (Vodafone)",
preferredmethod => 'allatonce',
defaults => { days => $maxdays, offset => 0, quiet => 0, debug => 0 },
} );
# limit to maxdays in the future
if ($opt->{offset} + $opt->{days} > $maxdays) {
$opt->{days} = $maxdays - $opt->{offset};
}
# Get the actual data and print it to stdout.
my $is_success=1;
my $startDate = DateTime->from_epoch( epoch => time () );
$startDate->set_time_zone( 'Europe/Lisbon' );
$startDate->truncate( to => 'day' );
$startDate->add( days => $opt->{offset} );
my $endDate=$startDate->clone()->add( days => $opt->{days} );
$endDate->add( seconds => -1 );
my %w_args = (
cutoff => '000000',
days => $opt->{days},
encoding => 'UTF-8',
offset => $opt->{offset},
);
my $writer = new XMLTV::Writer( %w_args );
$writer->start({
'generator-info-name' => "XMLTV/".$opt->{version},
'generator-info-url' => 'http://www.xmltv.org/',
'source-info-name' => 'EPG Service for Vodafone',
'source-info-url' => $json_baseurl,
});
if ( ! undef $opt->{days} ) {
if( !$opt->{quiet} ) {
print( STDERR "fetching data\n" );
}
get_epg( $writer, $startDate, $endDate );
} else {
if( !$opt->{quiet} ) {
print( STDERR "no data available for the requested time period\n" );
}
$is_success = 0;
}
$writer->end();
if( $is_success ) {
exit 0;
} else {
exit 1;
}
sub config_stage
{
my( $stage, $conf ) = @_;
die "Unknown stage $stage" if $stage ne "start";
my $result;
my $writer = new XMLTV::Configure::Writer( OUTPUT => \$result, encoding => 'utf-8' );
$writer->start( { grabber => 'tv_grab_pt_vodafone' } );
$writer->end( 'select-channels' );
return $result;
}
sub list_channels
{
my ( $conf, $opt ) = @_;
my $channellist = _read_channel_data();
my $output=XML::LibXML::Document->new( '1.0', 'utf-8' );
my $root=XML::LibXML::Element->new( 'tv' );
$output->setDocumentElement( $root );
foreach my $key ( sort keys %$channellist ) {
my $channel=$channellist->{$key};
my $sigla=$key;
my $name=$channel->{name};
my $icon=$channel->{icon};
my $tmp=XML::LibXML::Element->new( 'channel' );
$tmp->setAttribute( 'id', encode( 'UTF-8', $sigla ) );
$tmp->appendTextChild( 'display-name', encode( 'UTF-8', $name ) );
my $iconElement=XML::LibXML::Element->new( 'icon' );
$iconElement->setAttribute( 'src', $icon );
$tmp->appendChild( $iconElement );
$root->appendChild( $tmp );
}
return $output->toString();
}
sub _read_channel_data
{
my $channel_list = {};
my $c = 0;
my $channel_list_str = GetSupplement( 'tv_grab_pt_vodafone', $channel_list_file );
foreach my $channel_string (split( /\n/, $channel_list_str )) {
chomp($channel_string);
# remove commented channels
$channel_string =~ s/^#.*//;
next if not length $channel_string;
my @items = split("\t",$channel_string);
$items[1] =~ s/\"//g;
$channel_list->{ $items[0] } = {
name => decode('UTF-8', $items[1]),
icon => $items[2]
};
}
return $channel_list
}
sub get_epg
{
my( $writer, $startDate, $endDate ) = @_;
my @channelList = @{$conf->{channel}};
my %xmlchannels;
my %xmlprogs;
my $curDate = $startDate;
my $channelInfo = _read_channel_data();
while ( $curDate <= $endDate ) {
for my $channel (@channelList) {
my $channelId = make_channelid( $channelInfo->{$channel}->{name} );
my %ch = (
'id' => $channelId,
'icon' => [ { src => unquote($channelInfo->{$channel}->{icon}) } ],
);
# multiple display-names are ok and may be useful to match other tools lists
my @displayname = ( [ sanitizeUTF8( $channelInfo->{$channel}->{name} ), 'pt' ] ,
[ sanitizeUTF8( $channelId ), 'pt' ] ,
[ sanitizeUTF8( $channel ), 'pt' ] );
push @{ $ch{'display-name'} }, @displayname ;
$xmlchannels{ $channelId } = \%ch ;
my $date_day = $curDate->strftime('%d');
my $date_month = $curDate->strftime('%m');
my $date_year = $curDate->year;
for my $period ("00-06","06-12","12-18","18-00") {
print( STDERR "requesting EPG from " . $curDate->ymd() ." [".$period."]". " for " . $channelId . "\n" ) if( !$opt->{quiet} );
print( STDERR " GET ".$json_baseurl.$channel."/".$date_year."/".$date_month."/".$date_day."/".$period."\n" ) if( $opt->{debug} );
my $epgSource = json_request('get', $channel."/".$date_year."/".$date_month."/".$date_day."/".$period);
if ( ! $epgSource ){
die("Bad EPG download, probably channel list is outdated, rerun the grabber configure to update the list.\n" ); }
elsif ( !$epgSource->{result} || !$epgSource->{result}->{objects} || scalar @{$epgSource->{result}->{objects}} == 0 ){
print( STDERR " Empty EPG download for ".$channel.", probably channel list is outdated or no API data for that channel\n" .
" Rerun the grabber configure to update the list or check for the channel EPG in the Vodafone app.\n" );
next;
};
my $data = $epgSource->{result}->{objects};
PROGRAMME:
for my $programme ( @{ $data }) {
my %prog;
my ($dtstart, $dtend, $starts_today) = make_dates($programme->{startDate}, $programme->{endDate}, $curDate);
$prog{start} = $dtstart;
$prog{stop} = $dtend;
$prog{channel} = $channelId;
$prog{title} = get_title($programme);
$prog{desc} = get_desc($programme);
$prog{date} = get_date($programme);
$prog{'episode-num'} = make_episode_num($programme);
my $length = get_length($programme);
$prog{length} = $length if $length;
my $icon = get_icon($programme);
$prog{icon} = $icon if $icon;
my $category = get_category($programme);
$prog{category} = $category if $category;
my $country = get_country($programme);
$prog{country} = $country if $country;
my $rating = get_rating($programme);
$prog{rating} = $rating if $rating;
my $image = get_images($programme);
$prog{image} = $image if $image;
my $actors = get_actors($programme);
$prog{credits}{actor} = $actors if $actors;
my $directors = get_directors($programme);
$prog{credits}{director} = $directors if $directors;
# We can get the same programme for two different days if it goes past midnight.
# Lets remove duplicates here.
$xmlprogs{$channelId}{ $dtstart, $dtend } = \%prog;
print( STDERR " Adding programme: " . $prog{title}[0][0] . " [" . $dtstart . " - " . $dtend . "]\n" ) if( $opt->{debug} );
}
}
}
$curDate->add(days => 1);
}
$writer->write_channel($_) for values %xmlchannels;
for my $ch (keys %xmlchannels) {
$writer->write_programme($_) for values %{ $xmlprogs{$ch} };
}
$is_success=1;
}
sub sanitizeUTF8 {
my ($str) = @_;
$str =~ s/[^[:print:]]+//g;
return encode('UTF-8', $str, Encode::FB_CROAK);
}
sub json_request {
my ($method, $path, $content) = @_;
# TODO(nsenica): Implement proper throttling control.
sleep(0.1);
my $url = $json_baseurl . $path;
print( STDERR "json_request(" . $method . ") url: " . $url . "\n" ) if( $opt->{debug} );
my @params;
push(@params, content_type => 'application/x-www-form-urlencoded; charset=UTF-8');
push(@params, content => $content) if defined $content;
my $response = $ua->$method($url, @params);
if($response->is_success()) {
return JSON->new->utf8(1)->decode( $response->decoded_content());
}
else {
my $msg = $response->decoded_content();
if($response->header('content-type') =~ m{text/html;charset=UTF-8}i) {
my $error = decode_json($msg);
$msg = "Server (ID=$error->{'serverID'} Time=$error->{'datetime'}) returned an error:\n"
."$error->{'message'} ($error->{'code'}/$error->{'response'})";
}
print( STDERR " Error on the remote EPG API call\n" ) if( !$opt->{quiet} );
print( STDERR $msg . "\n" ) if( $opt->{debug} );
return JSON->new->utf8(1)->decode('{"data": [] }');
}
}
sub make_episode_num
{
my ($programme) = @_;
return unless $programme->{metas}{'season number'};
my $output;
my $season;
my $episode;
if ( $programme->{metas}{'season number'}{value} ) {
$season = $programme->{metas}{'season number'}{value} - 1;
}
if ( $programme->{metas}{'episode num'}{value} ) {
$episode = $programme->{metas}{'episode num'}{value} - 1;
}
$output = [ [ ($season // "") . "." . ($episode // "") . ".", 'xmltv_ns' ] ] if (defined $season || defined $episode);
if ( defined $season && defined $episode ) {
push @{ $output }, [ ($season+1) ." ". ($episode+1) , 'onscreen' ];
}
elsif ( defined $season ) {
push @{ $output }, [ ($season+1) , 'onscreen' ];
}
elsif ( defined $episode ) {
push @{ $output }, [ ($episode+1) , 'onscreen' ];
}
return $output;
}
sub make_dates
{
my( $startTime, $endTime, $curDate ) = @_;
my $dtstart = DateTime->from_epoch(epoch => $startTime);
my $starts_today = 0;
# does the programme start on the day we want listings for?
if ($dtstart->day == $curDate->day) {
$starts_today = 1;
}
my $dtend = DateTime->from_epoch(epoch =>$endTime);
# dates look like GMT, we tried UTC but in summer time they fail
return ($dtstart->strftime( '%Y%m%d%H%M%S +0000' ), $dtend->strftime( '%Y%m%d%H%M%S +0000' ), $starts_today);
}
sub make_channelid
{
my( $id ) = @_;
$id = lc( $id ); # turn into lowercase
$id =~ s/\s+//g; # remove whitespace
$id =~ s/&//g; # remove ampersand
$id =~ s/!//g; # remove !
$id =~ s/\"//g; # remove ""
$id =~ s/\+/-plus/g; # turn + into -plus
$id = unidecode($id);
$id .= '.tv.vodafone.pt'; # append domain part
return( $id );
}
sub unquote {
my ($str) = @_;
$str =~ s/^"//;
$str =~ s/"$//;
return $str;
}
sub get_title {
my ($programme) = @_;
return [ [ sanitizeUTF8($programme->{name}), 'pt' ] ];
}
sub get_desc {
my ($programme) = @_;
return [ [ sanitizeUTF8($programme->{description}), 'pt' ] ] if ($programme->{description});
}
sub get_length {
my ($programme) = @_;
if ($programme->{metas}{'display duration'}{value}) {
my $duration_str = $programme->{metas}{'display duration'}{value};
my @duration_items = split(/[PTHMS]/, $duration_str);
return $duration_items[2] * 3600 + $duration_items[3] * 60 + $duration_items[4] if scalar @duration_items;
}
}
sub get_icon {
my ($programme) = @_;
if ($programme->{images}[0]{url} && $programme->{images}[0]{imageTypeName} eq "ca") {
return [ { src => $programme->{images}[0]{url} . "/width/360/height/640/quality/95" } ];
}
if ($programme->{images}[0]{url} && $programme->{images}[0]{imageTypeName} eq "cc") {
return [ { src => $programme->{images}[0]{url} . "/width/640/height/360/quality/95" } ];
}
}
sub get_category {
my ($programme) = @_;
if ($programme->{tags}{genre}) {
my @category = map { [ sanitizeUTF8($_->{value}), 'pt' ] } @{$programme->{tags}{genre}{objects}};
return \@category;
}
}
sub get_country {
my ($programme) = @_;
return [ [ sanitizeUTF8($programme->{tags}{'country of production'}{objects}[0]{value}), 'pt' ] ] if $programme->{tags}{'country of production'};
}
sub get_rating {
my ($programme) = @_;
if ($programme->{tags}{'parental Rating'}) {
my $rating;
if ($programme->{tags}{'parental Rating'}{objects}[0]{value} == 0) {
$rating = [ [ "All Ages", 'Portuguese Movie Rating' ] ];
} else {
$rating = [ [ "M/" . $programme->{tags}{'parental Rating'}{objects}[0]{value}, 'Portuguese Movie Rating' ] ];
}
return $rating if $rating;
}
}
sub get_images {
my ($programme) = @_;
my @images;
for my $image (@{$programme->{images}}) {
next if !$image->{url};
my $type = "";
my $orient = "";
my $size = 3;
my $system = "vodafone";
my $width = 640;
my $height = 360;
if ($image->{imageTypeName} eq "cc") {
$orient = "L";
$type = "still";
} elsif ($image->{imageTypeName} eq "ca") {
$orient = "P";
$type = "poster";
$width = 360;
$height = 640;
} elsif ($image->{imageTypeName} eq "bg") {
$orient = "L";
$type = "backdrop";
}
push @images, [ $image->{url} . "/width/" . $width . "/height/" . $height . "/quality/95", { type => $type, size => $size, orient => $orient, system => $system } ];
}
return \@images if scalar @images;
}
sub get_date {
my ($programme) = @_;
return sanitizeUTF8($programme->{metas}{year}{value}) if $programme->{metas}{year}{value};
}
sub get_actors {
my ($programme) = @_;
if ($programme->{tags}{actors}) {
my @actors = map { sanitizeUTF8($_->{value}) } @{$programme->{tags}{actors}{objects}};
return \@actors;
}
}
sub get_directors {
my ($programme) = @_;
if ($programme->{tags}{director}) {
my @directors = map { sanitizeUTF8($_->{value}) } @{$programme->{tags}{director}{objects}};
return \@directors;
}
}